Merge remote-tracking branch 'origin/release/v3.8.23' into r4/combo

This commit is contained in:
diegosouzapw
2026-06-12 11:40:16 -03:00
120 changed files with 14470 additions and 4212 deletions

View File

@@ -1071,6 +1071,16 @@ APP_LOG_TO_FILE=true
# Comma-separated data sources. Default: litellm
# PRICING_SYNC_SOURCES=litellm
# ═══════════════════════════════════════════════════════════════════════════════
# 18b. ARENA ELO SYNC
# ═══════════════════════════════════════════════════════════════════════════════
# Enable auto-updating model intelligence from Arena AI leaderboard ELO scores.
# Used by: src/lib/arenaEloSync.ts
# ARENA_ELO_SYNC_ENABLED=false
# Sync interval in seconds. Default: 86400 (24 hours).
# ARENA_ELO_SYNC_INTERVAL=86400
# ═══════════════════════════════════════════════════════════════════════════════
# 19. MODEL SYNC (Dev)
# ═══════════════════════════════════════════════════════════════════════════════

5
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
# Funding links for OmniRoute — rendered as the "Sponsor" button on GitHub.
# Docs: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
github: diegosouzapw
# Additional platforms (uncomment and fill in before enabling):
# custom: ["https://omniroute.online/donate"]

View File

@@ -18,23 +18,50 @@ This plugin solves that by:
## Install
Once published to npm:
The plugin ships **pre-built inside the `omniroute` npm package** since v3.8.23.
If you have OmniRoute installed, the plugin is already on disk:
```sh
npm install @omniroute/opencode-plugin
# 1. One command — copy the plugin into OpenCode and update opencode.json
omniroute setup opencode --auth
# 2. Follow the interactive prompt to enter your OmniRoute API key
# 3. Restart OpenCode — /models lists the full live catalog
```
Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`:
The `--auth` flag runs `opencode auth login --provider omniroute` automatically.
Use `--base-url` to point at a non-default OmniRoute address:
```sh
omniroute setup opencode --base-url https://or.example.com --auth
```
### What it does
1. Locates the bundled plugin inside the omniroute installation
2. Copies `dist/` + `package.json` to `~/.config/opencode/plugins/omniroute/`
3. Writes/updates `opencode.json` with the plugin entry (idempotent, replaces legacy entries)
4. (With `--auth`) runs `opencode auth login` so the API key is stored
Re-run any time to update the plugin or change the base URL. Older entries for
`@omniroute/opencode-provider` or the legacy `opencode-omniroute-auth` package are
automatically cleaned up.
### Manual install (without omniroute CLI)
If you cannot run `omniroute setup opencode` (local dev, CI, air-gapped), reference
the built artifact directly:
```sh
# from inside the OmniRoute repo
cd @omniroute/opencode-plugin && npm run build && npm pack
# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/
```
And add the entry to `opencode.json` manually (see Quick Start below).
Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
## Quick start (single instance)
## Quick start (single instance, manual)
```jsonc
// opencode.json
@@ -42,7 +69,7 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
"$schema": "https://opencode.ai/config.json",
"plugin": [
[
"@omniroute/opencode-plugin",
"./plugins/omniroute-opencode-plugin/dist/index.js",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",

View File

@@ -2552,17 +2552,31 @@ export function createOmniRouteProviderHook(
const apiKey = (auth as { key: string }).key;
// baseURL resolution: plugin opts first, then credential-attached
// baseURL (auth backends sometimes stash it next to the key). No
// silent default to localhost: a misconfigured plugin should surface
// a clear error, not phantom /v1/models calls. Cast through unknown
// because the Auth union (OAuth | ApiAuth | WellKnownAuth) doesn't
// declare baseURL on any branch — we duck-type it as a defensive
// extension point.
// baseURL (auth backends sometimes stash it next to the key), then the
// provider config itself — a baseURL set via opencode.json provider
// options (or a config hook) lands on `provider.options` and is not
// visible through either of the first two links. No silent default to
// localhost: a misconfigured plugin should surface a clear warning,
// not phantom /v1/models calls. Cast through unknown because the Auth
// union (OAuth | ApiAuth | WellKnownAuth) doesn't declare baseURL on
// any branch — we duck-type it as a defensive extension point.
const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL;
const providerBaseURL = (
_provider as { options?: { baseURL?: unknown } } | undefined
)?.options?.baseURL;
const baseURL =
resolved.baseURL ??
(typeof authBaseURL === "string" ? authBaseURL : "");
(typeof authBaseURL === "string" && authBaseURL.length > 0 ? authBaseURL : undefined) ??
(typeof providerBaseURL === "string" && providerBaseURL.length > 0
? providerBaseURL
: undefined) ??
"";
if (!baseURL) {
console.warn(
`[omniroute-plugin] provider.models(${resolved.providerId}): ` +
`no baseURL resolvable — checked plugin opts, auth.json, and provider config. ` +
`Set baseURL in opencode.json plugin options or run \`opencode connect ${resolved.providerId}\` with a baseURL.`
);
return {};
}

View File

@@ -2,8 +2,31 @@
## [Unreleased]
### ✨ Added
- **`@omniroute/opencode-plugin` bundled + `omniroute setup opencode`** ([#3726] — thanks @herjarsa):
The opencode-plugin now ships pre-built inside the `omniroute` npm package (`package.json`
`files` includes `@omniroute/`). A new `omniroute setup opencode` CLI command automates
installation:
1. Copies the bundled plugin into `~/.config/opencode/plugins/omniroute/`
2. Creates/updates `opencode.json` with the plugin entry (idempotent,
replaces legacy `opencode-omniroute-auth` entries automatically)
3. Optional `--auth` flag runs `opencode auth login --provider <id>`
The prepublish pipeline (Step 8.8) now builds the plugin's `dist/` via tsup so every
published tarball includes it. Users no longer need to extract the plugin manually.
### 🐛 Fixed
- **`@omniroute/opencode-plugin`: baseURL resolution for partner/tiered providers**
([#3711] / [#3726] — thanks @herjarsa):
The `provider.models` hook now checks `_provider.options.baseURL` (set by the config
hook or opencode.json) as a third fallback after plugin opts and auth.json. Previously,
providers whose baseURL came from options returned zero models. A diagnostic `console.warn`
fires when no baseURL is resolvable from any source.
---
## [3.8.22] — TBD
## [3.8.23] — TBD
### 🐛 Bug Fixes
@@ -17,6 +40,12 @@
- **Combo strategy fallback coverage** (`tests/unit/combo-strategy-fallbacks.test.ts`, 11 tests): fill-first / p2c / random / cost-optimized / strict-random fallback paths (previously happy-path only), price-tie stability, stale strict-random deck degradation, unknown-strategy normalization to priority, and circuit-breaker HALF_OPEN recovery inside the combo loop + `preScreenTargets` (lazy-recovery contract).
- **`#1731` fast-skip suite restored** (`tests/integration/combo-provider-exhaustion.test.ts`): the five skipped tests were rewritten against the current routing policy (quota-exhausted 429 marks the provider for the request; transient 429 retries other connections; connection errors skip per-connection; nothing persists across requests) and re-enabled — 8/8 green.
- **Proxy context passthrough** (`tests/integration/proxy-context-passthrough.test.ts`): combo targets each execute under their own connection's proxy; `count_tokens` runs inside the connection's proxy context.
### 🐛 Fixed
- chore(quality-gate): reconcile check-file-size baseline — freeze 27 inherited/this-round grown files at current LOC + register providerLimits.ts (greens the file-size gate; shrink tracked via #3501)
- fix(gemini): standard Gemini provider no longer sends tool calls without a thought_signature (falls back to context mode when the signature is unavailable), fixing HTTP 400 on multi-turn thinking-model tool calls (#3688)
- fix(antigravity): preserve gemini-3.1-pro High/Low budget tiers (upstream accepts the suffixed ids; stop collapsing to bare gemini-3.1-pro) (#3696)
- fix: streaming combos now fail over to the next target when an upstream returns an empty/content-filtered response instead of surfacing a blank reply (#3685)
---
@@ -40,6 +69,7 @@
- **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77.
- **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77.
- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call_logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open *another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB.
- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call*logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open \_another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB.
- **chore(db-gate):** reclassify `KNOWN_UNEXPORTED``INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification.
- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture).
@@ -47,6 +77,7 @@
- **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture).
- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`).
- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(\*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`).
- **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing``usageLogs.ts`; `/api/cache/entries``semanticCache.ts`; `/api/logs/export``proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures.
@@ -153,6 +184,7 @@
- **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))
- **fix(resilience):** expose `providerCooldown` in `GET /api/resilience` and accept it in `PATCH` — PR #3556 added the global provider cooldown tracker to the settings model and `ResilienceTab` UI, but the API route never returned the field (causing a crash when the tab loaded) and `updateResilienceSchema` (`.strict()`) rejected PATCH bodies containing it with 400. `providerCooldownSettingsSchema` is now wired into the Zod schema, returned in the GET response, and merged in the PATCH handler. Caught during v3.8.20 VPS homologation (Hard Rule #18 — 5 TDD tests); shipped to main via [#3591](https://github.com/diegosouzapw/OmniRoute/pull/3591). ([#3590](https://github.com/diegosouzapw/OmniRoute/pull/3590) — thanks @diegosouzapw)
---

View File

@@ -1013,6 +1013,14 @@ Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** b
Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community.
## ❤️ Support
OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development:
-**Star the repo** — it genuinely helps visibility
- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — fund ongoing maintenance and new providers
- 🐛 **Report bugs and share feedback** in [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions)
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.

View File

@@ -0,0 +1,384 @@
/**
* omniroute setup opencode — Wire the bundled @omniroute/opencode-plugin
* into a local OpenCode install.
*
* Closes the gap where `npm install -g omniroute` ships the plugin
* inside the omniroute package (`@omniroute/opencode-plugin/dist/`) but
* OpenCode discovers plugins via `~/.config/opencode/plugins/` or
* via entries in `opencode.json`. Without this command, the user has
* to extract the tarball and wire it up by hand (see the plugin README,
* "Install" section).
*
* What it does, in order:
* 1. Resolves the bundled plugin path (source + built dist).
* 2. Resolves the OpenCode config directory (XDG-aware).
* 3. Copies the built plugin into `<opencode>/plugins/omniroute/`.
* 4. Creates or updates `opencode.json` with a single `plugin` entry
* pointing at the local copy (so OC ≥1.15 picks it up).
* 5. Optionally runs `opencode auth login --provider omniroute`
* so the next `opencode` invocation already has the API key.
*
* Idempotent: re-running with the same `--provider-id` updates the
* entry in place (path + baseURL) without duplicating it.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { spawnSync } from "node:child_process";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { t } from "../i18n.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// We walk up from this file to find the omniroute package root. The script
// lives at `<omniroute>/bin/cli/commands/setup-open-code.mjs`, so the
// package root is three levels up. Using import.meta.url (not process.cwd())
// means the command works the same way whether you run it from the source
// repo, a global install, or a symlinked location.
const PACKAGE_ROOT = resolve(__dirname, "..", "..", "..");
// The bundled plugin ships at PACKAGE_ROOT/@omniroute/opencode-plugin/
// (see root package.json `files`: ["@omniroute/", ...]). The env override
// exists so tests can point at a fixture without building the real plugin.
const BUNDLED_PLUGIN_DIR =
process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR ||
join(PACKAGE_ROOT, "@omniroute", "opencode-plugin");
/**
* Resolve the OpenCode config directory. Honours XDG_CONFIG_HOME and the
* platform-specific defaults documented at https://opencode.ai/.
*
* @returns {{ configDir: string, dataDir: string }}
*/
function resolveOpenCodeDirs() {
const home = os.homedir();
const xdgConfig = process.env.XDG_CONFIG_HOME;
const xdgData = process.env.XDG_DATA_HOME;
const platform = process.platform;
let configDir;
let dataDir;
if (platform === "darwin") {
// macOS: ~/Library/Application Support/opencode
configDir = join(home, "Library", "Application Support", "opencode");
dataDir = configDir; // OC uses the same root for config + data on macOS
} else if (platform === "win32") {
const appdata = process.env.APPDATA || join(home, "AppData", "Roaming");
const localAppdata = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
configDir = join(appdata, "opencode");
dataDir = join(localAppdata, "opencode");
} else {
// Linux + everything else: XDG-style
configDir = xdgConfig ? join(xdgConfig, "opencode") : join(home, ".config", "opencode");
dataDir = xdgData ? join(xdgData, "opencode") : join(home, ".local", "share", "opencode");
}
return { configDir, dataDir };
}
/**
* Locate the bundled @omniroute/opencode-plugin dist. The plugin may be
* present in two states:
*
* - Built (`dist/index.cjs` + `dist/index.js` exist) — preferred,
* ships from a published omniroute tarball after Step 8.8 of
* `scripts/build/prepublish.ts` runs.
* - Unbuilt (only `src/index.ts`) — local dev / fresh clone. We surface
* a clear error instead of running tsup here, because the CLI runtime
* may not have tsup available (it's a devDependency).
*
* @returns {{ distEntry: string, cjsEntry: string, packageDir: string }}
*/
function resolveBundledPlugin() {
if (!existsSync(BUNDLED_PLUGIN_DIR)) {
throw new Error(
`Bundled @omniroute/opencode-plugin not found at ${BUNDLED_PLUGIN_DIR}.\n` +
`This usually means omniroute was installed from a source tree that does not ` +
`include the workspace package. Try reinstalling omniroute (npm install -g omniroute) ` +
`or run \`cd @omniroute/opencode-plugin && npm install && npm run build\` from the source repo.`
);
}
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
const cjsEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.cjs");
if (!existsSync(esmEntry) || !existsSync(cjsEntry)) {
throw new Error(
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
);
}
// Prefer ESM. OpenCode (≥1.15) loads ESM modules natively.
return { distEntry: esmEntry, cjsEntry, packageDir: BUNDLED_PLUGIN_DIR };
}
/**
* Copy the plugin package into `<opencodeConfig>/plugins/omniroute/`. We
* copy the entire package (dist/ + package.json) so the dist file's
* require/import of `zod` and `@opencode-ai/plugin` resolves against the
* copy's own node_modules. Without the copy, OpenCode would need to
* resolve the peer deps from the omniroute package's tree, which is
* unreliable.
*/
function installPluginToOpenCode(pluginInfo, opencodeConfigDir) {
const targetDir = join(opencodeConfigDir, "plugins", "omniroute");
mkdirSync(dirname(targetDir), { recursive: true });
mkdirSync(targetDir, { recursive: true });
// Copy package.json + dist/. We intentionally do NOT recursively copy
// node_modules from the source — `peerDependenciesMeta` declares zod +
// @opencode-ai/plugin as peers, and the user's OpenCode install already
// provides them. Copying our own node_modules would risk duplicate zod
// instances (the @opencode-ai/plugin contract uses a singleton).
const packageJsonSrc = join(pluginInfo.packageDir, "package.json");
const distSrc = join(pluginInfo.packageDir, "dist");
cpSync(packageJsonSrc, join(targetDir, "package.json"));
cpSync(distSrc, join(targetDir, "dist"), { recursive: true });
return targetDir;
}
/**
* Update `opencode.json` to register the plugin. Idempotent: if an entry
* for the same `providerId` already exists, replace it in place. If the
* user has any other plugin entries, preserve them.
*
* @returns {{ configPath: string, changed: boolean }}
*/
function registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
}) {
const configPath = join(opencodeConfigDir, "opencode.json");
let cfg = {};
if (existsSync(configPath)) {
try {
cfg = JSON.parse(readFileSync(configPath, "utf8"));
} catch (err) {
throw new Error(
`Failed to parse existing ${configPath}: ${err.message}\n` +
`Fix or remove the file manually, then re-run \`omniroute setup opencode\`.`
);
}
}
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
// Plugin entries can be either a string ("@some/pkg") or a tuple
// ("@some/pkg", { options }). The README documents the tuple form, so
// we use that. The "module path" is a file:// URL relative to the
// opencode config dir — that is what opencode ≥1.15 resolves.
const entry = [
`./plugins/omniroute/dist/index.js`,
{
providerId,
baseURL,
...(displayName ? { displayName } : {}),
},
];
// Idempotency: drop any prior entry for the same providerId. We also
// drop a legacy `opencode-omniroute-auth` entry if present — that
// package is the obsolete predecessor of @omniroute/opencode-plugin
// and was the root cause of issue #3711.
const filtered = plugins.filter((p) => {
if (typeof p === "string") {
return !p.includes("opencode-omniroute-auth");
}
if (Array.isArray(p) && p[1] && typeof p[1] === "object") {
const pid = p[1].providerId;
if (pid === providerId) return false;
// Also drop the legacy auth plugin if it's there.
if (typeof p[0] === "string" && p[0].includes("opencode-omniroute-auth")) {
return false;
}
}
return true;
});
filtered.push(entry);
cfg.plugin = filtered;
// Make sure the config dir exists, then write the updated config.
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
return { configPath, changed: true };
}
/**
* Optionally invoke `opencode auth login --provider <providerId>`. We
* shell out (instead of importing) so this command works even if
* OpenCode's CLI surface shifts between minor versions — the user gets
* a clear "could not run opencode" message instead of a hard import
* failure.
*/
function runOpenCodeAuth(providerId) {
const isWin = process.platform === "win32";
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
stdio: "inherit",
shell: false,
});
if (res.error) {
// ENOENT = opencode is not on PATH
if (res.error.code === "ENOENT") {
printInfo(
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.`
);
return 1;
}
printError(`opencode auth login failed: ${res.error.message}`);
return 1;
}
return typeof res.status === "number" ? res.status : 1;
}
/**
* Top-level action handler. Kept exported so the integration test can
* drive it without spawning a subprocess.
*
* @param {object} opts
* @param {string} [opts.providerId="omniroute"]
* @param {string} [opts.baseURL="http://localhost:20128"] (Commander camelCases
* `--base-url` into `baseUrl`, so both spellings are accepted.)
* @param {string} [opts.configDir] Override the OpenCode config dir (tests / non-standard installs).
* @param {string} [opts.displayName]
* @param {boolean} [opts.auth=false] Run `opencode auth login` after wiring.
* @param {boolean} [opts.nonInteractive=false] Skip prompts.
* @returns {Promise<{ exitCode: number, configPath?: string, pluginTargetDir?: string }>}
*/
export async function runSetupOpenCodeCommand(opts = {}) {
const providerId = opts.providerId || "omniroute";
const baseURL = opts.baseURL || opts.baseUrl || "http://localhost:20128";
const displayName = opts.displayName || null;
const wantsAuth = Boolean(opts.auth);
const nonInteractive = Boolean(opts.nonInteractive);
printHeading("OmniRoute → OpenCode Plugin Setup");
const resolvedDirs = resolveOpenCodeDirs();
const opencodeConfigDir = opts.configDir || resolvedDirs.configDir;
const opencodeDataDir = resolvedDirs.dataDir;
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
printInfo(`OpenCode data dir: ${opencodeDataDir}`);
// 1. Resolve bundled plugin
let pluginInfo;
try {
pluginInfo = resolveBundledPlugin();
} catch (err) {
printError(err.message);
return { exitCode: 1 };
}
printInfo(`Bundled plugin: ${pluginInfo.distEntry}`);
// 2. Ensure OpenCode config dir exists (opencode will create it on
// first run, but creating it now means we can write opencode.json
// even if OC has never been launched).
if (!existsSync(opencodeConfigDir)) {
mkdirSync(opencodeConfigDir, { recursive: true });
printInfo(`Created OpenCode config dir (didn't exist yet).`);
}
// 3. Copy plugin into OpenCode's plugin dir
let pluginTargetDir;
try {
pluginTargetDir = installPluginToOpenCode(pluginInfo, opencodeConfigDir);
printSuccess(`Plugin installed at ${pluginTargetDir}`);
} catch (err) {
printError(`Failed to install plugin: ${err.message}`);
return { exitCode: 1 };
}
// 4. Register in opencode.json
let configPath;
try {
const reg = registerPluginInOpenCodeConfig({
opencodeConfigDir,
pluginTargetDir,
providerId,
baseURL,
displayName,
});
configPath = reg.configPath;
printSuccess(`opencode.json updated at ${configPath}`);
} catch (err) {
printError(`Failed to update opencode.json: ${err.message}`);
return { exitCode: 1, pluginTargetDir };
}
// 5. Optionally run auth login
if (wantsAuth) {
if (nonInteractive) {
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
printInfo(`Run manually: opencode auth login --provider ${providerId}`);
} else {
printHeading("Authenticating with OpenCode");
const authExit = runOpenCodeAuth(providerId);
if (authExit !== 0) {
return { exitCode: authExit, configPath, pluginTargetDir };
}
}
} else {
printInfo(
`Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)`
);
}
printSuccess("OpenCode plugin setup complete");
printInfo(`Restart OpenCode to pick up the new plugin entry.`);
return { exitCode: 0, configPath, pluginTargetDir };
}
/**
* Register the `omniroute setup opencode` subcommand on the parent
* `setup` command. Commander builds the doc/help from the chain, so
* `omniroute setup --help` automatically shows the new subcommand.
*
* @param {import("commander").Command} setupCommand the registered `setup` command
*/
export function registerSetupOpenCode(setupCommand) {
setupCommand
.command("opencode")
.description(
t("setup.opencode") ||
"Install and register the bundled @omniroute/opencode-plugin with a local OpenCode install"
)
.option(
"--provider-id <id>",
"OpenCode provider id to register (default: omniroute)",
"omniroute"
)
.option(
"--base-url <url>",
"OmniRoute base URL the plugin should talk to (default: http://localhost:20128)",
"http://localhost:20128"
)
.option("--display-name <name>", "Display name in the OpenCode UI (optional)")
.option(
"--auth",
"Run `opencode auth login --provider <providerId>` after wiring (interactive)",
false
)
.option("--non-interactive", "Do not prompt; skip the auth login step", false)
.action(async (opts, cmd) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// that here so global flags (--json, --base-url, --api-key) still
// flow through to the runner.
const globalOpts = cmd.parent?.parent?.optsWithGlobals?.() ?? {};
const merged = {
...opts,
output: globalOpts.output,
apiKey: opts.apiKey ?? globalOpts.apiKey,
baseUrl: opts.baseUrl ?? globalOpts.baseUrl,
};
const { exitCode } = await runSetupOpenCodeCommand(merged);
if (exitCode !== 0) process.exit(exitCode);
});
}

View File

@@ -10,6 +10,7 @@ import {
getProviderDisplayName,
resolveProviderChoice,
} from "../provider-catalog.mjs";
import { registerSetupOpenCode } from "./setup-open-code.mjs";
import { t } from "../i18n.mjs";
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -150,6 +151,11 @@ export function registerSetup(program) {
const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
// Wire up `omniroute setup opencode` subcommand. Kept inside registerSetup
// so it always travels with the parent command (avoids a separate register
// call in the registry that would silently break if the parent renames).
registerSetupOpenCode(program.commands.find((c) => c.name() === "setup"));
}
export async function runSetupCommand(opts = {}) {

View File

@@ -26,7 +26,8 @@
"testFailed": "Provider test failed: {error}",
"loginEnabled": "Login: enabled (password updated)",
"loginDisabled": "Login: disabled",
"providerInfo": "Provider: {info}"
"providerInfo": "Provider: {info}",
"opencode": "Install and configure the bundled @omniroute/opencode-plugin for OpenCode"
},
"doctor": {
"title": "OmniRoute Doctor",

View File

@@ -989,6 +989,12 @@ history, or compressing fallback requests; enabling it allows configured hedging
skips, and proactive fallback compression to trade routing/request fidelity for lower tail
latency.
Disable **Reasoning token buffer** when upstream providers require strict
`max_tokens` / `maxOutputTokens` limits. When enabled, combo routing only adds reasoning-model
headroom for models with a known output cap and leaves the client token limit unchanged when the
safe buffered value would exceed that cap. If the client limit is already above a known cap,
OmniRoute clamps it down to that cap before sending the upstream request.
---
### Health Dashboard

View File

@@ -685,6 +685,15 @@ Automatic model pricing data synchronization from external sources.
---
## Arena ELO Sync
| Variable | Default | Source File | Description |
| --------------------------- | ------------- | -------------------------- | ------------------------------------------------------------- |
| `ARENA_ELO_SYNC_ENABLED` | `false` | `src/lib/arenaEloSync.ts` | Opt-in periodic Arena AI leaderboard ELO sync. |
| `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | `src/lib/arenaEloSync.ts` | Sync interval in seconds. |
---
## 19. Model Sync (Dev)
| Variable | Default | Source File | Description |

View File

@@ -2,40 +2,40 @@
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"cap": 800,
"frozen": {
"open-sse/config/providerRegistry.ts": 4677,
"open-sse/executors/antigravity.ts": 1533,
"open-sse/executors/base.ts": 1175,
"open-sse/config/providerRegistry.ts": 4692,
"open-sse/executors/antigravity.ts": 1553,
"open-sse/executors/base.ts": 1199,
"open-sse/executors/chatgpt-web.ts": 2870,
"open-sse/executors/claude-web.ts": 1057,
"open-sse/executors/codex.ts": 1439,
"open-sse/executors/cursor.ts": 1391,
"open-sse/executors/deepseek-web.ts": 1116,
"open-sse/executors/deepseek-web.ts": 1117,
"open-sse/executors/duckduckgo-web.ts": 917,
"open-sse/executors/grok-web.ts": 1871,
"open-sse/executors/muse-spark-web.ts": 1284,
"open-sse/executors/perplexity-web.ts": 867,
"open-sse/executors/perplexity-web.ts": 868,
"open-sse/handlers/audioSpeech.ts": 952,
"open-sse/handlers/chatCore.ts": 6023,
"open-sse/handlers/chatCore.ts": 5808,
"open-sse/handlers/imageGeneration.ts": 3777,
"open-sse/handlers/responseSanitizer.ts": 1080,
"open-sse/handlers/search.ts": 1441,
"open-sse/handlers/responseSanitizer.ts": 1103,
"open-sse/handlers/search.ts": 1442,
"open-sse/handlers/videoGeneration.ts": 1026,
"open-sse/mcp-server/schemas/tools.ts": 1437,
"open-sse/mcp-server/server.ts": 1457,
"open-sse/mcp-server/tools/advancedTools.ts": 1118,
"open-sse/services/accountFallback.ts": 1633,
"open-sse/services/accountFallback.ts": 1708,
"open-sse/services/batchProcessor.ts": 828,
"open-sse/services/browserBackedChat.ts": 850,
"open-sse/services/claudeCodeCompatible.ts": 1202,
"open-sse/services/combo.ts": 4530,
"open-sse/services/combo.ts": 5054,
"open-sse/services/rateLimitManager.ts": 1017,
"open-sse/services/tokenRefresh.ts": 1896,
"open-sse/services/usage.ts": 3042,
"open-sse/translator/request/openai-to-gemini.ts": 822,
"open-sse/services/tokenRefresh.ts": 1997,
"open-sse/services/usage.ts": 3341,
"open-sse/translator/request/openai-to-gemini.ts": 844,
"open-sse/translator/response/openai-responses.ts": 873,
"open-sse/utils/cursorAgentProtobuf.ts": 1499,
"open-sse/utils/stream.ts": 2694,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1417,
"open-sse/utils/stream.ts": 2710,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2680,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
@@ -48,61 +48,66 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 4063,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 822,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 782,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 941,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 843,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1171,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 954,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderModels.ts": 155,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 897,
"src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906,
"src/app/(dashboard)/dashboard/providers/page.tsx": 1925,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127,
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1198,
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819,
"src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 932,
"src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx": 880,
"src/app/(dashboard)/dashboard/settings/components/PricingTab.tsx": 1012,
"src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1072,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 851,
"src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 983,
"src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1580,
"src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1924,
"src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx": 1016,
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1015,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
"src/app/api/oauth/[provider]/[action]/route.ts": 897,
"src/app/api/providers/[id]/models/route.ts": 2287,
"src/app/api/providers/[id]/models/route.ts": 2426,
"src/app/api/providers/[id]/test/route.ts": 842,
"src/app/api/usage/analytics/route.ts": 1355,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1435,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1490,
"src/lib/db/core.ts": 1820,
"src/lib/db/migrationRunner.ts": 1100,
"src/lib/db/models.ts": 1132,
"src/lib/db/providers.ts": 993,
"src/lib/db/proxies.ts": 1031,
"src/lib/db/settings.ts": 1101,
"src/lib/db/providers.ts": 1050,
"src/lib/db/proxies.ts": 1039,
"src/lib/db/settings.ts": 1108,
"src/lib/db/usageAnalytics.ts": 873,
"src/lib/evals/evalRunner.ts": 961,
"src/lib/memory/retrieval.ts": 1171,
"src/lib/modelsDevSync.ts": 934,
"src/lib/providers/validation.ts": 4201,
"src/lib/providers/validation.ts": 4209,
"src/lib/tailscaleTunnel.ts": 1189,
"src/lib/usage/callLogs.ts": 975,
"src/lib/usage/usageHistory.ts": 840,
"src/lib/usage/providerLimits.ts": 941,
"src/lib/usage/usageHistory.ts": 854,
"src/shared/components/OAuthModal.tsx": 956,
"src/shared/components/RequestLoggerV2.tsx": 1232,
"src/shared/components/analytics/charts.tsx": 1558,
"src/shared/constants/cliTools.ts": 875,
"src/shared/constants/pricing.ts": 1447,
"src/shared/constants/providers.ts": 3121,
"src/shared/constants/pricing.ts": 1470,
"src/shared/constants/providers.ts": 3144,
"src/shared/constants/sidebarVisibility.ts": 990,
"src/shared/services/cliRuntime.ts": 1073,
"src/shared/validation/schemas.ts": 2490,
"src/sse/handlers/chat.ts": 1381,
"src/sse/services/auth.ts": 2198
"src/shared/services/cliRuntime.ts": 1084,
"src/shared/validation/schemas.ts": 2515,
"src/sse/handlers/chat.ts": 1389,
"src/sse/services/auth.ts": 2207
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap."
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
"_rebaseline_2026_06_12_review_issues": "Re-baseline consciente do /review-issues v3.8.23: 27 arquivos com crescimento herdado (v3.8.22 nunca reconciliado) + fixes deste round (combo.ts #3685, openai-to-gemini.ts #3688, tokenRefresh.ts #3692, validation/proxies de outras merges). providerLimits.ts (941) adicionado como frozen (split coeso de usage). Shrink endereçado separadamente pelo #3501.",
"_rebaseline_2026_06_12_phase1g1j": "Phase 1g-1j (#3501): ProviderDetailPageClient.tsx 4063→3409 (extraídos ProviderPlaygroundPanel, useCommandCodeAuth, useExternalLinkFlow+ExternalLinkModal, useAuthFileHandlers — zero lógica nova). models/route.ts 2344→2426: drift do #3712 (vertex dynamic model discovery) reconciliado aqui.",
"_rebaseline_2026_06_12_phase1n1s": "Phase 1n-1s (#3501): ProviderDetailPageClient.tsx 2554→1376 (extraídos ConnectionsListPanel, ConnectionsHeaderToolbar, ZedImportCard, BatchTestResultsModal, AdaptaTutorialModal + hooks/useApiKeySave + 4 helper closures→providerPageHelpers.ts). providerPageHelpers.ts 822→897 justificado: recebe 4 closures do god-component (getApiLabel/getApiDefaultPath/getApiPath/getHeaderIconProviderId), zero lógica nova, cliente encolhe mais do que helpers crescem.",
"_rebaseline_2026_06_12_phase1t": "Phase 1t (#3501): ProviderDetailPageClient.tsx 1377→782 — META ≤800 ATINGIDA (extraídos ProviderPageHeader, CompatibleNodeCard, ProviderModalsPanel, EmptyConnectionsPlaceholder, UpstreamProxyCard, SearchProviderCard + hooks useConnectionGate/useProviderNodeActions). Drift concorrente reconciliado: ResilienceTab/sse-chat/sse-auth/accountFallback/combo (merges #3629 model-lockout etc.)."
}

View File

@@ -65,9 +65,10 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([
supportsVision: true,
toolCalling: true,
},
// Gemini 3.1 Pro budget tiers — agy already ships these; #3184 confirmed they work via
// the antigravity OAuth provider. The -high/-low suffix is aliased to the plain
// gemini-3.1-pro upstream id (see ANTIGRAVITY_MODEL_ALIASES / #3229).
// Gemini 3.1 Pro budget tiers — agy ships these and they route directly via the
// antigravity OAuth provider. The upstream ACCEPTS the suffixed ids verbatim (wire-
// confirmed via `agy --model gemini-3.1-pro-high`: 200 OK on /v1internal:streamGenerateContent).
// No alias needed; see #3696 (supersedes the #3229 premise).
{
id: "gemini-3.1-pro-high",
name: "Gemini 3.1 Pro (High)",
@@ -158,10 +159,10 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({
// (upstream `gemini-3-flash-agent`). It is NOT re-added to the public catalog.
"gemini-3.5-flash-preview": "gemini-3-flash-agent",
"gemini-3-pro-preview": "gemini-3.1-pro",
// agy catalog exposes -high/-low budget tiers, but the upstream rejects the suffix
// for gemini-3.x (#3229) — map them to the plain proven id.
"gemini-3.1-pro-high": "gemini-3.1-pro",
"gemini-3.1-pro-low": "gemini-3.1-pro",
// gemini-3.1-pro-high and gemini-3.1-pro-low are NOT aliased here: wire capture
// (#3696) confirmed the upstream accepts the suffixed ids verbatim → pass through.
// (The earlier #3229 assumption — "upstream rejects -high/-low for gemini-3.x" —
// was refuted by the agy --log-file 200 OK evidence.)
"gemini-3-pro-image-preview": "gemini-3-pro-image",
"gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p",
// Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here

View File

@@ -0,0 +1,34 @@
{
"gemini-2.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
"gemini-2.5-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2-flash": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2-flash-lite": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2.5-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 },
"gemini-2.5-pro-tts": { "rpm": 0, "rpd": 0, "tpm": 0 },
"imagen-4-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
"imagen-4-ultra-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
"imagen-4-fast-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
"gemma-4-26b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 },
"gemma-4-31b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 },
"gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
"gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
"gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 },
"gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 },
"nano-banana-gemini-2.5-flash-preview-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
"nano-banana-pro-gemini-3-pro-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
"nano-banana-2-gemini-3.1-flash-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
"lyria-3-clip": { "rpm": 0, "rpd": 0, "tpm": 0 },
"lyria-3-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
"veo-3-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
"veo-3-fast-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
"veo-3-lite-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
"gemini-3.1-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 },
"gemini-robotics-er-1.5-preview": { "rpm": 10, "rpd": 20, "tpm": 250000 },
"gemini-robotics-er-1.6-preview": { "rpm": 5, "rpd": 20, "tpm": 250000 },
"computer-use-preview": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-embedding-exp-04-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
"gemini-3.5-live-translate": { "rpm": -1, "rpd": -1, "tpm": 20000 },
"gemini-2.5-flash-native-audio-dialog": { "rpm": -1, "rpd": -1, "tpm": 1000000 },
"gemini-3-flash-live": { "rpm": -1, "rpd": -1, "tpm": 65000 }
}

View File

@@ -21,6 +21,28 @@ export function parseSAFromApiKey(apiKey: string): ServiceAccount {
}
}
/**
* A Service Account credential is a JSON object (type/client_email/private_key). A Vertex AI
* Express-mode API key is an opaque non-JSON string. Distinguishing them lets the executor
* support BOTH: Service Account JSON (JWT → OAuth → project-scoped endpoint + Bearer auth) and
* Express keys (project-less publisher endpoint + x-goog-api-key auth), instead of failing every
* Express key with "requires a valid Service Account JSON".
*/
export function looksLikeServiceAccountJson(apiKey: string): boolean {
if (!apiKey || typeof apiKey !== "string") return false;
try {
const parsed = JSON.parse(apiKey);
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
return false;
}
}
/** True for a Vertex AI Express-mode API key (a non-empty, non-JSON, non-OAuth credential). */
export function isExpressApiKey(apiKey?: string | null): boolean {
return typeof apiKey === "string" && apiKey.trim().length > 0 && !looksLikeServiceAccountJson(apiKey);
}
export async function getAccessToken(sa: ServiceAccount): Promise<string> {
if (!sa.client_email || !sa.private_key) {
throw new Error(
@@ -110,7 +132,13 @@ export class VertexExecutor extends BaseExecutor {
async execute(input: ExecuteInput) {
const { credentials, log } = input;
if (credentials.apiKey && !credentials.accessToken) {
// Defensive: trim stray surrounding whitespace from a pasted credential.
if (typeof credentials.apiKey === "string") {
credentials.apiKey = credentials.apiKey.trim();
}
// Service Account JSON → mint a short-lived OAuth token (Bearer). An Express-mode API key is
// sent as-is via x-goog-api-key (see buildHeaders), so no token exchange is needed for it.
if (credentials.apiKey && !credentials.accessToken && looksLikeServiceAccountJson(credentials.apiKey)) {
try {
const sa = parseSAFromApiKey(credentials.apiKey);
credentials.accessToken = await getAccessToken(sa);
@@ -123,6 +151,19 @@ export class VertexExecutor extends BaseExecutor {
}
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
// Vertex AI Express mode: project-less v1 publisher endpoint with the API key passed as a
// ?key= query parameter (verified working contract — same as the CaptionAI GeminiClient). The
// Express key is NOT accepted as a Bearer/OAuth credential or via x-goog-api-key on this API.
if (isExpressApiKey(credentials?.apiKey) && !credentials?.accessToken) {
const expressKey = encodeURIComponent(String(credentials.apiKey).trim());
if (isPartnerModel(model)) {
// Partner (Anthropic/etc.) models are not available via Express keys; best-effort.
return `https://aiplatform.googleapis.com/v1/publishers/openapi/chat/completions?key=${expressKey}`;
}
const op = stream ? "streamGenerateContent?alt=sse&" : "generateContent?";
return `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${op}key=${expressKey}`;
}
const region = credentials?.providerSpecificData?.region || "us-central1";
let project = "unknown-project";
@@ -146,6 +187,7 @@ export class VertexExecutor extends BaseExecutor {
if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
// Express-mode keys are carried in the ?key= query parameter (see buildUrl), not a header.
if (stream) {
headers["Accept"] = "text/event-stream";
}

View File

@@ -228,6 +228,7 @@ import {
getModelScopeRetryDelayMs,
isModelScopeProvider,
} from "../services/modelscopePolicy.ts";
import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts";
const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
@@ -1980,12 +1981,13 @@ export async function handleChatCore({
// Use credentials.connectionId as a fallback so that requests without an
// explicit session-level connectionId still register in the pendingRequests map.
const pendingConnId = connectionId || credentials?.connectionId || null;
const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, {
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
clientRequest: clientRawRequest?.body ?? body,
providerRequest: initialProviderRequest,
stage: "registered",
}) || generateRequestId();
const pendingRequestId =
trackPendingRequest(model, provider, pendingConnId, true, {
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
clientRequest: clientRawRequest?.body ?? body,
providerRequest: initialProviderRequest,
stage: "registered",
}) || generateRequestId();
// Initialize rate limit settings from persisted DB (once, lazy)
await initializeRateLimits();
@@ -2763,7 +2765,10 @@ export async function handleChatCore({
comboTargetLimits,
});
contextLimit = resolved.limit;
log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`);
log?.info?.(
"CONTEXT",
`Combo context limit: ${resolved.limit} (source=${resolved.source})`
);
} catch (err) {
log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err);
}
@@ -3098,6 +3103,12 @@ export async function handleChatCore({
translatedBody.messages,
DEFAULT_THINKING_CLAUDE_SIGNATURE
) as typeof translatedBody.messages;
// Anthropic API rejects requests with both temperature and top_p.
// VS Code Claude extension and similar clients send both; strip top_p.
if (translatedBody.temperature !== undefined && translatedBody.top_p !== undefined) {
delete translatedBody.top_p;
}
}
// Fix #2468: always extract role:"system" → top-level system.
@@ -3781,6 +3792,12 @@ export async function handleChatCore({
});
const res = normalizeExecutorResult(rawExecutorResult);
trace("post_executor", { status: res?.response?.status });
// Track Gemini RPM + RPD request counts for 429 classification
if (provider === "gemini") {
incrementRequestCount(modelToCall);
}
updatePendingRequest(model, provider, connectionId, {
stage: "provider_response_started",
});
@@ -5371,17 +5388,14 @@ export async function handleChatCore({
}
const responseHeaders: Record<string, string> = {
...buildStreamingResponseHeaders(
providerResponse.headers,
{
provider,
model,
cacheHit: false,
latencyMs: 0,
usage: null,
costUsd: 0,
}
),
...buildStreamingResponseHeaders(providerResponse.headers, {
provider,
model,
cacheHit: false,
latencyMs: 0,
usage: null,
costUsd: 0,
}),
"x-omniroute-request-id": pendingRequestId,
};
@@ -5489,7 +5503,9 @@ export async function handleChatCore({
});
} catch (e) {
// Best-effort — don't break the stream completion path if this fails
try { console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e)); } catch {}
try {
console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e));
} catch {}
}
if (apiKeyInfo?.id && streamUsage) {

View File

@@ -27,7 +27,9 @@ import {
looksLikeQuotaExhausted,
type FailureKind,
} from "../../src/shared/utils/classify429";
import { resolveProviderId } from "../../src/shared/constants/providers";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts";
export type ProviderProfile = {
baseCooldownMs: number;
@@ -65,6 +67,9 @@ type ModelFailureState = {
failureCount: number;
lastFailureAt: number;
resetAfterMs: number;
/** Cooldown applied on the last failure — extends the escalation window so a
* model that fails again right after its lockout expires keeps escalating. */
lastCooldownMs?: number;
};
type AccountState = JsonRecord & {
id?: string | null;
@@ -150,9 +155,6 @@ export const CREDITS_EXHAUSTED_SIGNALS = [
"credits exhausted",
"out of credits",
"payment required",
"resource has been exhausted",
"resource_exhausted",
"check quota",
"free tier of the model has been exhausted",
];
@@ -350,8 +352,20 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi
const modelLockouts = new Map<string, ModelLockoutEntry>();
const modelFailureState = new Map<string, ModelFailureState>();
// Aliases (e.g. "cx" → "codex") must share lockout state with their canonical
// provider, otherwise a model locked via one spelling stays routable via the other.
const canonicalProviderCache = new Map<string, string>();
function getCanonicalLockProvider(provider: string): string {
let canonical = canonicalProviderCache.get(provider);
if (!canonical) {
canonical = resolveProviderId(provider);
canonicalProviderCache.set(provider, canonical);
}
return canonical;
}
function getModelLockKey(provider: string, connectionId: string, model: string) {
return `${provider}:${connectionId}:${model}`;
return `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`;
}
function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) {
@@ -367,7 +381,9 @@ function cleanupModelLockKey(key: string, now = Date.now()) {
const failure = modelFailureState.get(key);
if (!failure) return;
if (now - failure.lastFailureAt <= failure.resetAfterMs) return;
// The escalation window extends past the applied cooldown: a model that fails
// again right after its lockout expires must keep escalating, not reset to 1.
if (now - failure.lastFailureAt <= failure.resetAfterMs + (failure.lastCooldownMs ?? 0)) return;
if (modelLockouts.has(key)) return;
modelFailureState.delete(key);
}
@@ -468,7 +484,7 @@ export function recordModelLockoutFailure(
status: number,
fallbackCooldownMs: number,
profile: ProviderProfile | null = null,
options: { exactCooldownMs?: number | null } = {}
options: { exactCooldownMs?: number | null; maxCooldownMs?: number } = {}
) {
ensureCleanupTimer();
const key = getModelLockKey(provider, connectionId, model);
@@ -483,24 +499,39 @@ export function recordModelLockoutFailure(
const resetAfterMs = getFailureWindowMs(profile);
const previous = modelFailureState.get(key);
const withinWindow = previous && now - previous.lastFailureAt <= previous.resetAfterMs;
// Escalation window extends past the previously applied cooldown so a model
// that fails again right after its lockout expires keeps escalating.
const withinWindow =
previous &&
now - previous.lastFailureAt <= previous.resetAfterMs + (previous.lastCooldownMs ?? 0);
const failureCount = withinWindow ? previous.failureCount + 1 : 1;
const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile);
// Cap exponential backoff so repeated failures cannot produce absurdly long
// lockouts; exact cooldowns (e.g. daily-quota until-midnight) are not capped.
const maxCooldownMs =
typeof options.maxCooldownMs === "number" && options.maxCooldownMs > 0
? options.maxCooldownMs
: BACKOFF_CONFIG.max;
const cooldownMs =
typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0
? options.exactCooldownMs
: Math.min(
getScaledCooldown(
baseCooldownMs,
failureCount,
profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel
),
maxCooldownMs
);
modelFailureState.set(key, {
failureCount,
lastFailureAt: now,
resetAfterMs,
lastCooldownMs: cooldownMs,
});
const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile);
const cooldownMs =
typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0
? options.exactCooldownMs
: getScaledCooldown(
baseCooldownMs,
failureCount,
profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel
);
lockModel(provider, connectionId, model, reason, cooldownMs, {
failureCount,
lastFailureAt: now,
@@ -590,6 +621,36 @@ export function shouldMarkAccountExhaustedFrom429(
);
}
export function classifyLockoutReason(status: number): string {
if (status === 429) return "rate_limit";
if (status === 403) return "quota_exhausted";
return "unknown";
}
export type DecayResult = { cleared: boolean; newFailureCount: number };
export function decayModelFailureCount(
provider: string,
connectionId: string,
model: string
): DecayResult {
const key = getModelLockKey(provider, connectionId, model);
const failure = modelFailureState.get(key);
if (!failure) return { cleared: false, newFailureCount: 0 };
const newFailureCount = Math.floor(failure.failureCount / 2);
if (newFailureCount === 0) {
modelFailureState.delete(key);
return { cleared: true, newFailureCount: 0 };
} else {
modelFailureState.set(key, {
...failure,
failureCount: newFailureCount,
});
return { cleared: false, newFailureCount };
}
}
/**
* Clear all in-memory model lockouts and failure state (for tests / full reset).
*/
@@ -933,8 +994,9 @@ export function parseRetryFromErrorText(errorText: unknown): number | null {
// 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 =
/\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);
/\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
);
if (isoMatch) {
const parsedTs = Date.parse(isoMatch[1]);
if (Number.isFinite(parsedTs)) {
@@ -1021,10 +1083,8 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue {
const configuredRule = matchErrorRuleByText(errorText);
if (configuredRule?.reason) return configuredRule.reason;
if (lower.includes("rate_limit")) return RateLimitReason.RATE_LIMIT_EXCEEDED;
if (
lower.includes("resource exhausted") ||
lower.includes("high demand")
) return RateLimitReason.MODEL_CAPACITY;
if (lower.includes("resource exhausted") || lower.includes("high demand"))
return RateLimitReason.MODEL_CAPACITY;
if (
lower.includes("unauthorized") ||
lower.includes("invalid api key") ||
@@ -1414,6 +1474,19 @@ export function checkFallbackError(
}
}
// Gemini-specific: use known published RPM/RPD limits to distinguish 429 types.
// Gemini returns the same error body for both, so we use per-model request
// counters to decide: if daily count >= RPD → quota_exhausted (midnight lockout);
// if minute count >= RPM → rate_limit_exceeded (exponential backoff).
if (provider === "gemini" && status === HTTP_STATUS.RATE_LIMITED && _model) {
if (isRpdExhausted(_model)) {
return buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED);
}
if (isRpmExhausted(_model)) {
return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED);
}
}
const configuredRule =
isRateLimitStatus && !preserveQuota429
? matchErrorRuleByStatus(status)

View File

@@ -2,10 +2,10 @@
* Unit tests for Auto-Combo Engine (Phase 5)
*/
import { describe, it, expect, beforeEach } from "vitest";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { calculateFactors, calculateScore, DEFAULT_WEIGHTS, validateWeights } from "../scoring";
import type { ProviderCandidate, ScoringWeights } from "../scoring";
import { getTaskFitness, getTaskTypes } from "../taskFitness";
import { getTaskFitness, getTaskFitnessWithSource, getTaskTypes, getModelsDevTierFitness, invalidateFitnessCache } from "../taskFitness";
import { SelfHealingManager } from "../selfHealing";
import { MODE_PACKS, getModePack, getModePackNames } from "../modePacks";
import { getStrategy } from "../routerStrategy";
@@ -410,3 +410,164 @@ describe("LKGP Strategy", () => {
expect(result.provider).toBe("openai");
});
});
describe("Task Fitness Resolution Chain", () => {
it("getTaskFitness should return static table score for known models", () => {
const score = getTaskFitness("claude-sonnet", "coding");
expect(score).toBe(0.95);
});
it("getTaskFitness should return 0.5 for unknown models with no wildcard match", () => {
const score = getTaskFitness("unknown-model-xyz", "coding");
expect(score).toBe(0.5);
});
it("getTaskFitness should apply wildcard boosts for model name patterns", () => {
const score = getTaskFitness("deepseek-coder-v2", "coding");
expect(score).toBeGreaterThan(0.5);
});
it("getTaskFitness should apply thinking wildcard for planning tasks", () => {
const score = getTaskFitness("some-thinking-model", "planning");
expect(score).toBeGreaterThan(0.5);
});
it("getTaskFitnessWithSource should return source='fitness_table' for known static models", () => {
const result = getTaskFitnessWithSource("claude-sonnet", "coding");
expect(result).toEqual({ score: 0.95, source: "fitness_table" });
});
it("getTaskFitnessWithSource should return source='wildcard_boost' for wildcard-matched models", () => {
const result = getTaskFitnessWithSource("fast-model", "coding");
expect(result).toEqual({ score: expect.any(Number), source: "wildcard_boost" });
});
it("getTaskTypes should return task types without 'default'", () => {
const types = getTaskTypes();
expect(types).toContain("coding");
expect(types).toContain("review");
expect(types).toContain("planning");
expect(types).not.toContain("default");
});
it("unknown models should return 0.5 (default) when no DB or static entry exists", () => {
const score = getTaskFitness("completely-unknown-model-xyz-999", "coding");
expect(score).toBe(0.5);
});
it("wildcard boosts still work for models containing 'coder'", () => {
const score = getTaskFitness("my-coder-pro", "coding");
// Base 0.5 + coder boost 0.15 + code boost 0.1 = 0.75
// "coder" contains "code", so both wildcard patterns match
expect(score).toBe(0.75);
});
it("wildcard boosts still work for models containing 'thinking'", () => {
const score = getTaskFitness("my-thinking-model", "planning");
// Base 0.5 + thinking boost 0.1 = 0.6
expect(score).toBe(0.6);
});
it("wildcard boosts still work for models containing 'thinking' for analysis tasks", () => {
const score = getTaskFitness("my-thinking-model", "analysis");
// Base 0.5 + thinking boost 0.1 = 0.6
expect(score).toBe(0.6);
});
it("wildcard boosts for 'code' pattern apply to coding tasks", () => {
const score = getTaskFitness("my-code-generator", "coding");
// Base 0.5 + code boost 0.1 = 0.6
expect(score).toBe(0.6);
});
it("wildcard boosts for 'fast' pattern apply to coding tasks", () => {
const score = getTaskFitness("my-fast-model", "coding");
// Base 0.5 + fast boost 0.05 = 0.55
expect(score).toBe(0.55);
});
it("getTaskFitnessWithSource returns 'wildcard_boost' for pattern-matched unknown models", () => {
const result = getTaskFitnessWithSource("my-coder-pro", "coding");
expect(result.source).toBe("wildcard_boost");
expect(result.score).toBeGreaterThan(0.5);
});
it("getTaskFitnessWithSource returns 'fitness_table' for statically known models", () => {
const result = getTaskFitnessWithSource("claude-sonnet", "review");
expect(result.source).toBe("fitness_table");
expect(result.score).toBe(0.92);
});
it("getTaskFitnessWithSource returns 'wildcard_boost' with 0.5 for unknown models with no pattern", () => {
const result = getTaskFitnessWithSource("totally-random-xyz", "coding");
expect(result.source).toBe("wildcard_boost");
expect(result.score).toBe(0.5);
});
});
describe("Task Fitness DB Resolution Chain", () => {
// These tests verify that when DB is available, the resolution chain
// (user_override → arena_elo → models_dev_tier → static → wildcard)
// works correctly. Since the DB module is loaded lazily via require(),
// these tests cover the cases where DB is NOT available (graceful fallback).
it("falls back to static FITNESS_TABLE when DB is not initialized", () => {
// In the test environment, DB is typically not initialized,
// so getTaskFitness should fall through to the static table
const score = getTaskFitness("claude-sonnet", "coding");
// Static table has claude-sonnet → 0.95 for coding
expect(score).toBe(0.95);
});
it("falls back to static FITNESS_TABLE for review task type", () => {
const score = getTaskFitness("claude-opus", "review");
// Static table has claude-opus → 0.95 for review
expect(score).toBe(0.95);
});
it("falls back to wildcard boosts when no static entry exists and DB unavailable", () => {
// "coder-unknown" has no static entry but matches "coder" wildcard
const score = getTaskFitness("coder-unknown", "coding");
expect(score).toBeGreaterThan(0.5);
expect(score).toBeLessThanOrEqual(1.0);
});
it("getModelsDevTierFitness returns null when DB is not initialized", () => {
// Without a running DB, this should return null gracefully
const score = getModelsDevTierFitness("claude-sonnet", "coding");
// Either null (no capabilities data) or a number from DB if DB happens to be up
if (score !== null) {
expect(score).toBeGreaterThanOrEqual(0);
expect(score).toBeLessThanOrEqual(1);
}
});
it("invalidateFitnessCache does not throw", () => {
expect(() => invalidateFitnessCache()).not.toThrow();
});
it("resolution chain: static table takes priority over wildcard for known models", () => {
// "claude-sonnet" is in the static table with coding=0.95
// It does NOT match "coder" wildcard because the static table is checked first
const score = getTaskFitness("claude-sonnet", "coding");
expect(score).toBe(0.95); // From static table, NOT wildcard
});
it("getTaskFitnessWithSource identifies fitness_table as source for known models", () => {
const result = getTaskFitnessWithSource("gpt-4o", "coding");
expect(result.source).toBe("fitness_table");
expect(result.score).toBe(0.9);
});
it("case insensitivity: model names are lowercased before lookup", () => {
const upperScore = getTaskFitness("CLAUDE-SONNET", "coding");
const lowerScore = getTaskFitness("claude-sonnet", "coding");
expect(upperScore).toBe(lowerScore);
});
it("case insensitivity: task types are lowercased before lookup", () => {
const upperScore = getTaskFitness("claude-sonnet", "CODING");
const lowerScore = getTaskFitness("claude-sonnet", "coding");
expect(upperScore).toBe(lowerScore);
});
});

View File

@@ -3,8 +3,24 @@
*
* Maps model patterns × task types → fitness score [0..1].
* Supports wildcards and prefix matching.
*
* Resolution chain (highest → lowest priority):
* 1. User override — DB `model_intelligence` where source='user_override'
* 2. Arena ELO — DB `model_intelligence` where source='arena_elo'
* 3. Models.dev tier — derived from `model_capabilities` table capability data
* 4. Static FITNESS_TABLE — existing hardcoded lookup (current behavior)
* 5. Wildcard boosts — existing pattern matching boosts (current behavior)
*/
// ─── Static fitness table (unchanged, fallback layer 4) ─────────────────
import { getDbInstance } from "../../../src/lib/db/core.ts";
import {
getModelIntelligenceBySource,
setUserFitnessOverrideEntry,
deleteUserFitnessOverrideEntry,
} from "../../../src/lib/db/modelIntelligence.ts";
const FITNESS_TABLE: Record<string, Record<string, number>> = {
coding: {
"claude-sonnet": 0.95,
@@ -131,34 +147,274 @@ const WILDCARD_BOOSTS: Array<{ pattern: string; taskType: string; boost: number
{ pattern: "thinking", taskType: "analysis", boost: 0.1 },
];
// ─── Models.dev tier → task fitness mapping (resolution layer 3) ────────
/**
* Get task fitness score for a model × taskType combination.
* Returns 0.5 (neutral) if no mapping found.
* Intelligence tier derived from models.dev capability data.
* Tier assignment rules:
* - `reasoning === true` → "premium"
* - `tool_call === true && context >= 128000` → "standard"
* - `tool_call === true` → "fast"
* - everything else → "budget"
*/
export function getTaskFitness(model: string, taskType: string): number {
const TIER_TASK_FITNESS: Record<string, Record<string, number>> = {
premium: {
coding: 0.92,
review: 0.93,
planning: 0.94,
analysis: 0.95,
debugging: 0.9,
documentation: 0.88,
default: 0.85,
},
standard: {
coding: 0.85,
review: 0.84,
planning: 0.85,
analysis: 0.85,
debugging: 0.82,
documentation: 0.85,
default: 0.78,
},
fast: {
coding: 0.78,
review: 0.72,
planning: 0.7,
analysis: 0.72,
debugging: 0.75,
documentation: 0.8,
default: 0.72,
},
budget: {
coding: 0.65,
review: 0.6,
planning: 0.55,
analysis: 0.58,
debugging: 0.6,
documentation: 0.7,
default: 0.55,
},
};
// ─── DB access helpers ──────────────────────────────────────────────────
const _intelligenceCache = new Map<string, number | null>();
function queryModelIntelligence(
model: string,
category: string,
source: string,
): number | null {
const cacheKey = `${model}:${category}:${source}`;
if (_intelligenceCache.has(cacheKey)) {
return _intelligenceCache.get(cacheKey)!;
}
try {
const entry = getModelIntelligenceBySource(model, source, category);
if (entry) {
_intelligenceCache.set(cacheKey, entry.score);
return entry.score;
}
return null;
} catch {
return null;
}
}
// ─── Models.dev capability → tier → fitness resolution ──────────────────
let _capabilitiesCache: Record<string, ModelCapRow> | null = null;
interface ModelCapRow {
tool_call: boolean | null;
reasoning: boolean | null;
limit_context: number | null;
}
function deriveTierFromCapabilities(cap: ModelCapRow): string {
if (cap.reasoning === true) return "premium";
if (cap.tool_call === true && (cap.limit_context ?? 0) >= 128000)
return "standard";
if (cap.tool_call === true) return "fast";
return "budget";
}
function loadModelCapabilities(): Record<string, ModelCapRow> | null {
if (_capabilitiesCache) return _capabilitiesCache;
try {
const db = getDbInstance();
const rows = db.prepare("SELECT * FROM model_capabilities").all() as Record<
string,
unknown
>[];
const cache: Record<string, ModelCapRow> = {};
for (const row of rows) {
const modelId = typeof row.model_id === "string" ? row.model_id : "";
if (!modelId) continue;
cache[modelId.toLowerCase()] = {
tool_call:
row.tool_call === true || row.tool_call === 1
? true
: row.tool_call === false || row.tool_call === 0
? false
: null,
reasoning:
row.reasoning === true || row.reasoning === 1
? true
: row.reasoning === false || row.reasoning === 0
? false
: null,
limit_context:
typeof row.limit_context === "number" ? row.limit_context : null,
};
}
_capabilitiesCache = cache;
return cache;
} catch {
return null;
}
}
export function getModelsDevTierFitness(
model: string,
taskType: string,
): number | null {
const normalizedModel = model.toLowerCase();
const normalizedTask = taskType.toLowerCase();
const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default;
// Direct match
const dbScore = queryModelIntelligence(
normalizedModel,
normalizedTask,
"models_dev_tier",
);
if (dbScore !== null) return dbScore;
const caps = loadModelCapabilities();
if (!caps) return null;
const capRow = caps[normalizedModel];
if (!capRow) return null;
const tier = deriveTierFromCapabilities(capRow);
const tierScores = TIER_TASK_FITNESS[tier];
if (!tierScores) return null;
return tierScores[normalizedTask] ?? tierScores.default ?? null;
}
// ─── Resolution chain ───────────────────────────────────────────────────
function lookupStaticFitnessTable(
normalizedModel: string,
normalizedTask: string,
): number | null {
const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default;
for (const [pattern, score] of Object.entries(table)) {
if (normalizedModel.includes(pattern)) return score;
}
return null;
}
// Wildcard boost
function lookupWildcardBoosts(
normalizedModel: string,
normalizedTask: string,
): number {
let baseScore = 0.5;
for (const wc of WILDCARD_BOOSTS) {
if (normalizedModel.includes(wc.pattern) && normalizedTask === wc.taskType) {
baseScore += wc.boost;
}
}
return Math.min(1.0, baseScore);
}
/**
* Get all task types available.
*/
export function getTaskFitness(model: string, taskType: string): number {
return getTaskFitnessWithSource(model, taskType).score;
}
export function getTaskFitnessWithSource(
model: string,
taskType: string,
): { score: number; source: string } {
const normalizedModel = model.toLowerCase();
const normalizedTask = taskType.toLowerCase();
const userOverride = queryModelIntelligence(
normalizedModel,
normalizedTask,
"user_override",
);
if (userOverride !== null) {
return { score: userOverride, source: "user_override" };
}
const arenaElo = queryModelIntelligence(
normalizedModel,
normalizedTask,
"arena_elo",
);
if (arenaElo !== null) {
return { score: arenaElo, source: "arena_elo" };
}
const tierScore = getModelsDevTierFitness(normalizedModel, normalizedTask);
if (tierScore !== null) {
return { score: tierScore, source: "models_dev_tier" };
}
const staticScore = lookupStaticFitnessTable(
normalizedModel,
normalizedTask,
);
if (staticScore !== null) {
return { score: staticScore, source: "fitness_table" };
}
return { score: lookupWildcardBoosts(normalizedModel, normalizedTask), source: "wildcard_boost" };
}
export function setUserFitnessOverride(
model: string,
category: string,
score: number,
): void {
try {
setUserFitnessOverrideEntry(
model.toLowerCase(),
category.toLowerCase(),
score,
);
invalidateFitnessCache();
} catch (err) {
throw new Error(
`Failed to set user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export function clearUserFitnessOverride(
model: string,
category: string,
): void {
try {
deleteUserFitnessOverrideEntry(model.toLowerCase(), category.toLowerCase());
invalidateFitnessCache();
} catch (err) {
throw new Error(
`Failed to clear user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
export function getTaskTypes(): string[] {
return Object.keys(FITNESS_TABLE).filter((k) => k !== "default");
}
export function invalidateFitnessCache(): void {
_capabilitiesCache = null;
_intelligenceCache.clear();
}

View File

@@ -8,8 +8,12 @@
import {
checkFallbackError,
classifyErrorText,
classifyLockoutReason,
decayModelFailureCount,
formatRetryAfter,
getRuntimeProviderProfile,
isModelLocked,
recordModelLockoutFailure,
recordProviderFailure,
isProviderFailureCode,
isProviderExhaustedReason,
@@ -44,6 +48,7 @@ import {
getLastSessionModel,
getHandoff,
} from "../../src/lib/db/contextHandoffs.ts";
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
import { getQuotaFetcher } from "./quotaPreflight.ts";
import * as semaphore from "./rateLimitSemaphore.ts";
@@ -71,11 +76,7 @@ import {
type ProviderCandidate,
type ScoringWeights,
} from "./autoCombo/scoring.ts";
import {
getResolvedModelCapabilities,
supportsReasoning,
supportsToolCalling,
} from "./modelCapabilities.ts";
import { getResolvedModelCapabilities, supportsToolCalling } from "./modelCapabilities.ts";
import { estimateTokens } from "./contextManager.ts";
import { getReasoningTokens } from "../../src/lib/usage/tokenAccounting.ts";
import { getSessionConnection } from "./sessionManager.ts";
@@ -108,6 +109,7 @@ import {
resolveResilienceSettings,
type ResilienceSettings,
} from "../../src/lib/resilience/settings";
import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "./reasoningTokenBuffer.ts";
// Status codes that should mark round-robin target semaphores as cooling down.
const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
@@ -446,7 +448,211 @@ export async function validateResponseQuality(
isStreaming: boolean,
log: { warn?: (...args: unknown[]) => void }
): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> {
if (isStreaming) return { valid: true };
// Issue #3685: For Claude SSE streaming responses, use a BOUNDED PEEK to
// detect the empty-content-block pattern (content_filter stop_reason with
// no content_block_* events) WITHOUT de-streaming non-empty responses.
//
// Strategy:
// - Read chunks from response.body one at a time, accumulating raw bytes.
// - Parse SSE events incrementally.
// - If a content_block_* event appears → stream HAS content. Stop buffering.
// Return a clonedResponse whose body replays buffered bytes then pipes the
// remainder of the original reader. Only the chunks up to the first content
// block were held in memory — the rest stream normally.
// - If the stream ends with a complete Claude lifecycle but NO content_block
// → return invalid (combo failover). The empty lifecycle is tiny so fully
// reading it is acceptable.
// - If the stream ends without a recognisable complete Claude lifecycle →
// return valid with a clonedResponse replaying all buffered bytes (don't
// misclassify non-Claude or partial streams as empty).
//
// Non-text/event-stream streaming responses are not buffered at all.
if (isStreaming) {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/event-stream")) {
return { valid: true };
}
if (!response.body) {
return { valid: true };
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
// Raw Uint8Array chunks accumulated so far — used to replay the prefix
// in the returned clonedResponse.
const bufferedChunks: Uint8Array[] = [];
// Decoded text accumulated across chunks for incremental SSE parsing.
// Only the tail of the most-recently-processed line window remains here
// between iterations (incomplete lines are deferred to the next chunk).
let decodedSoFar = "";
// SSE lifecycle state.
let hasMessageStart = false;
let hasContentBlock = false;
let hasLifecycleEnd = false;
// `event:` type line seen before the next `data:` line in the same event.
let pendingEventType = "";
/**
* Parse any complete SSE lines from `decodedSoFar`, updating lifecycle
* flags in the closure. The last (potentially incomplete) line is kept in
* `decodedSoFar` for the next iteration.
*
* Returns true when a content_block_* event is detected — the caller
* should stop peeking and treat the stream as non-empty.
*/
function parseAccumulatedSse(): boolean {
const lines = decodedSoFar.split(/\r?\n/);
// Retain the potentially-incomplete trailing fragment.
decodedSoFar = lines[lines.length - 1];
for (let i = 0; i < lines.length - 1; i++) {
const trimmed = lines[i].trim();
if (trimmed.startsWith("event:")) {
pendingEventType = trimmed.slice(6).trim();
continue;
}
if (!trimmed.startsWith("data:")) {
if (!trimmed) pendingEventType = "";
continue;
}
const data = trimmed.slice(5).trim();
if (!data || data === "[DONE]") continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
const eventType =
(typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || "";
pendingEventType = "";
switch (eventType) {
case "message_start":
hasMessageStart = true;
break;
case "content_block_start":
case "content_block_delta":
case "content_block_stop":
hasContentBlock = true;
// Signal caller to stop buffering immediately.
return true;
case "message_stop":
hasLifecycleEnd = true;
break;
case "message_delta": {
const delta = parsed.delta;
if (
delta &&
typeof delta === "object" &&
(delta as Record<string, unknown>).stop_reason != null
) {
hasLifecycleEnd = true;
}
break;
}
default:
break;
}
}
return false;
}
/**
* Build a Response whose body first replays all bytes in `bufferedChunks`,
* then forwards the remainder of `readerToForward` chunk-by-chunk.
* Preserves the original response's status, statusText, and headers.
*/
function buildReplayResponse(
readerToForward: ReadableStreamDefaultReader<Uint8Array>
): Response {
// Snapshot the prefix so mutations after this point don't affect it.
const prefix = bufferedChunks.slice();
let prefixIdx = 0;
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
// 1. Drain the buffered prefix one chunk at a time.
if (prefixIdx < prefix.length) {
controller.enqueue(prefix[prefixIdx++]);
return;
}
// 2. Forward the remainder from the original reader.
try {
const { done, value } = await readerToForward.read();
if (done) {
controller.close();
} else {
controller.enqueue(value);
}
} catch {
controller.close();
}
},
});
return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
// Main bounded-peek loop.
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// Stream finished — flush the TextDecoder and parse any remaining text.
const tail = decoder.decode(undefined, { stream: false });
if (tail) decodedSoFar += tail;
parseAccumulatedSse();
if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) {
// Complete Claude lifecycle with zero content blocks → failover.
log.warn?.(
"COMBO",
"Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover"
);
return { valid: false, reason: "streaming empty content block" };
}
// Incomplete lifecycle or non-Claude stream — replay all buffered
// bytes. The reader is exhausted so the forwarding reader will
// immediately signal done.
const clonedResponse = buildReplayResponse(reader);
return { valid: true, clonedResponse };
}
// Accumulate raw bytes for potential replay.
bufferedChunks.push(value);
// Decode incrementally (stream:true keeps multi-byte char state).
decodedSoFar += decoder.decode(value, { stream: true });
const foundContent = parseAccumulatedSse();
if (foundContent) {
// A content_block_* event was found — stop peeking. Return a
// clonedResponse that replays all buffered bytes (the current chunk
// is already in bufferedChunks) and then forwards the remainder of
// the original reader unchanged.
const clonedResponse = buildReplayResponse(reader);
return { valid: true, clonedResponse };
}
}
} catch {
// If reading the stream fails, pass through — other mechanisms
// (stream readiness timeout) will catch truly broken streams.
return { valid: true };
}
}
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("application/json") && !contentType.includes("text/")) {
@@ -2815,6 +3021,7 @@ export async function handleComboChat({
? resolveComboConfig(combo, settings)
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS);
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
// ── Per-model timeout wrapper ────────────────────────────────────────────
// Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can
@@ -3391,6 +3598,7 @@ export async function handleComboChat({
): Promise<{ ok: boolean; response?: Response } | null> => {
const target = orderedTargets[i];
const modelStr = target.modelStr;
const rawModel = parseModel(modelStr).model || modelStr;
const provider = target.provider;
const cb = getCircuitBreaker(provider);
@@ -3447,6 +3655,13 @@ export async function handleComboChat({
return null;
}
// Pre-check: skip models locked by the resilience system (model-level lockout)
if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) {
log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`);
if (i > 0) fallbackCount++;
return null;
}
// Pre-screen may have already determined this target unavailable (e.g.
// circuit-breaker OPEN at resolve time). Skip immediately in that case.
// For targets pre-screened as "available" we still call isModelAvailable
@@ -3603,23 +3818,25 @@ export async function handleComboChat({
}
}
// Issue #3587: Reasoning models (deepseek-v4-flash, nemotron, etc.) consume
// ALL max_tokens for reasoning_tokens, leaving content empty. Add a buffer
// to max_tokens so the model has enough tokens for both reasoning and content.
if (supportsReasoning(modelStr)) {
// Issue #3587: Reasoning models can spend the whole output budget on
// reasoning. Only add headroom when the complete buffer fits inside the
// model's known output cap; otherwise preserve the client's explicit limit.
{
const bodyRecord = attemptBody as Record<string, unknown>;
const currentMaxTokens = Number(bodyRecord.max_tokens) || 0;
if (currentMaxTokens > 0) {
// Add 50% buffer + 1000 floor to ensure reasoning + content both fit
const bufferedMaxTokens = Math.max(
currentMaxTokens + 1000,
Math.ceil(currentMaxTokens * 1.5)
);
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
modelStr,
bodyRecord.max_tokens,
{ enabled: reasoningTokenBufferEnabled }
);
if (currentMaxTokens !== null && bufferedMaxTokens !== null) {
bodyRecord.max_tokens = bufferedMaxTokens;
log.info(
"COMBO",
`Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
);
if (bufferedMaxTokens !== currentMaxTokens) {
log.info(
"COMBO",
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
);
}
}
}
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
@@ -3648,6 +3865,25 @@ export async function handleComboChat({
lastError = `Upstream response failed quality validation: ${quality.reason}`;
if (!lastStatus) lastStatus = 502;
if (i > 0) fallbackCount++;
if (provider && rawModel) {
const mlSettings = resolveModelLockoutSettings(settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(502)) {
recordModelLockoutFailure(
provider,
target.connectionId || "",
rawModel,
"quality_failure",
502,
mlSettings.baseCooldownMs,
profile,
{
exactCooldownMs: mlSettings.useExponentialBackoff
? 0
: mlSettings.baseCooldownMs,
}
);
}
}
emit("combo.target.failed", {
comboName: combo.name,
targetIndex: i,
@@ -3658,6 +3894,25 @@ export async function handleComboChat({
});
return null;
}
// Success decay: a healthy response walks the model's lockout failure
// count back down (and eventually clears an expired lockout entirely).
if (provider && rawModel) {
const dcResult = decayModelFailureCount(
provider,
target.connectionId || "",
rawModel
);
if (dcResult.cleared) {
log.info("COMBO", `Model ${modelStr} fully recovered — lockout cleared`);
} else if (dcResult.newFailureCount > 0) {
log.debug(
"COMBO",
`Model ${modelStr} decayed to failureCount=${dcResult.newFailureCount}`
);
}
}
const latencyMs = Date.now() - startTime;
emit("combo.target.succeeded", {
comboName: combo.name,
@@ -4031,7 +4286,36 @@ export async function handleComboChat({
!isTokenLimitBreach &&
[408, 429, 500, 502, 503, 504].includes(result.status);
if (retry < maxRetries && isTransient && !providerExhausted) {
continue; // Retry same model
// Record model lockout immediately on the first transient failure —
// once the model is cooling down, retrying it would waste an upstream
// call and extend the cooldown via exponential backoff.
let lockoutRecorded = false;
if (provider && rawModel && retry === 0) {
const mlSettings = resolveModelLockoutSettings(settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
recordModelLockoutFailure(
provider,
target.connectionId || "",
rawModel,
classifyLockoutReason(result.status),
result.status,
mlSettings.baseCooldownMs,
profile,
{
exactCooldownMs: mlSettings.useExponentialBackoff
? 0
: mlSettings.baseCooldownMs,
}
);
lockoutRecorded = true;
}
}
if (lockoutRecorded) {
log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
if (i > 0) fallbackCount++;
return null;
}
continue; // Retry same model (transient error, no lockout recorded)
}
// Done retrying this model
@@ -4046,6 +4330,25 @@ export async function handleComboChat({
lastError = errorText || String(result.status);
if (!lastStatus) lastStatus = result.status;
if (i > 0) fallbackCount++;
// Wire combo failures into the resilience dashboard (model-level lockout)
// alongside the provider-level cooldown below — they govern different scopes.
if (provider && rawModel) {
const mlSettings = resolveModelLockoutSettings(settings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
recordModelLockoutFailure(
provider,
target.connectionId || "",
rawModel,
classifyLockoutReason(result.status),
result.status,
mlSettings.baseCooldownMs,
profile,
{
exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs,
}
);
}
}
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") {
@@ -4222,6 +4525,7 @@ async function handleRoundRobinCombo({
const maxRetries = config.maxRetries ?? 1;
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
const resilienceSettings: ResilienceSettings = settings
? resolveResilienceSettings(settings)
@@ -4381,27 +4685,30 @@ async function handleRoundRobinCombo({
`[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}`
);
// Issue #3587: Reasoning models consume ALL max_tokens for reasoning_tokens.
// Add buffer to ensure reasoning + content both fit. Apply the buffer to a
// per-attempt COPY — never mutate the shared `body` — so it does not compound
// across round-robin iterations/retries (otherwise 4096 -> 6144 -> 9216 -> ...
// as each reasoning model re-reads an already-buffered value and overshoots the
// model's real limit, triggering 400s).
// Issue #3587: Reasoning models can spend the whole output budget on
// reasoning. Apply any safe buffer to a per-attempt copy so round-robin
// retries never compound across models.
let attemptBody = body;
if (supportsReasoning(modelStr)) {
const currentMaxTokens = Number((body as Record<string, unknown>).max_tokens) || 0;
if (currentMaxTokens > 0) {
const bufferedMaxTokens = Math.max(
currentMaxTokens + 1000,
Math.ceil(currentMaxTokens * 1.5)
);
{
const bodyRecord = body as Record<string, unknown>;
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
modelStr,
bodyRecord.max_tokens,
{ enabled: reasoningTokenBufferEnabled }
);
if (
currentMaxTokens !== null &&
bufferedMaxTokens !== null &&
bufferedMaxTokens !== currentMaxTokens
) {
attemptBody = {
...(body as Record<string, unknown>),
...bodyRecord,
max_tokens: bufferedMaxTokens,
} as typeof body;
log.info(
"COMBO-RR",
`Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
);
}
}

View File

@@ -26,6 +26,7 @@ const DEFAULT_COMBO_CONFIG = {
maxMessagesForSummary: 30,
maxComboDepth: 3,
trackMetrics: true,
reasoningTokenBufferEnabled: true,
manifestRouting: false,
resetAwareSessionWeight: 0.35,
resetAwareWeeklyWeight: 0.65,

View File

@@ -0,0 +1,145 @@
/**
* In-memory request counters for Gemini models — tracks both RPD (daily)
* and RPM (sliding 60s window) so that 429 responses can be classified
* as either quota_exhausted (RPD hit) or rate_limit_exceeded (RPM hit).
*
* Gemini returns identical error bodies for both types, so we rely on
* published per-model limits from geminiRateLimits.json to distinguish them.
*
* Counters are incremented on every Gemini request so that once usage
* reaches the published limit, subsequent 429s are correctly classified.
*/
import geminiLimits from "../config/geminiRateLimits.json";
// ── RPD (daily) state ────────────────────────────────────────────────────────
interface DailyCount {
date: string; // "YYYY-MM-DD"
count: number;
}
const dailyCounts = new Map<string, DailyCount>();
// ── RPM (sliding 60s window) state ───────────────────────────────────────────
const minuteWindows = new Map<string, number[]>();
// ── Helpers ──────────────────────────────────────────────────────────────────
function toDateKey(): string {
return new Date().toISOString().slice(0, 10);
}
function stripModelPrefix(modelId: string): string {
// Only strip the "gemini/" provider prefix, never "gemini-" which is part
// of the actual model name (e.g. "gemini-2.5-flash", "gemini-3.5-live-translate").
return modelId.replace(/^gemini\//, "").trim();
}
function lookupValue(modelId: string, field: "rpm" | "rpd"): number {
if (!modelId) return 0;
const key = stripModelPrefix(modelId);
const entry = (geminiLimits as Record<string, Record<string, number>>)[key];
if (!entry) {
for (const [knownKey, knownEntry] of Object.entries(geminiLimits)) {
if (key.endsWith(knownKey) || knownKey.endsWith(key)) {
const val = knownEntry[field];
return typeof val === "number" && val > 0 ? val : 0;
}
}
return 0;
}
const val = entry[field];
return typeof val === "number" && val > 0 ? val : 0;
}
// ── RPD exports ──────────────────────────────────────────────────────────────
export function getModelRpd(modelId: string): number {
return lookupValue(modelId, "rpd");
}
export function incrementDailyRequestCount(modelId: string): void {
if (!modelId) return;
const key = stripModelPrefix(modelId);
const today = toDateKey();
const existing = dailyCounts.get(key);
if (existing && existing.date === today) {
existing.count++;
} else {
dailyCounts.set(key, { date: today, count: 1 });
}
}
export function getDailyRequestCount(modelId: string): number {
if (!modelId) return 0;
const key = stripModelPrefix(modelId);
const today = toDateKey();
const entry = dailyCounts.get(key);
if (entry && entry.date === today) return entry.count;
return 0;
}
export function isRpdExhausted(modelId: string): boolean {
const rpd = getModelRpd(modelId);
if (rpd <= 0) return false;
return getDailyRequestCount(modelId) >= rpd;
}
// ── RPM exports ──────────────────────────────────────────────────────────────
export function getModelRpm(modelId: string): number {
return lookupValue(modelId, "rpm");
}
/** Prune timestamps older than 60 seconds from a model's window. */
function pruneMinuteWindow(key: string): void {
const now = Date.now();
const cutoff = now - 60_000;
const timestamps = minuteWindows.get(key);
if (!timestamps) return;
// Keep only timestamps >= cutoff
let i = 0;
while (i < timestamps.length && timestamps[i] < cutoff) i++;
if (i > 0) {
minuteWindows.set(key, timestamps.slice(i));
}
}
export function incrementMinuteRequestCount(modelId: string): void {
if (!modelId) return;
const key = stripModelPrefix(modelId);
pruneMinuteWindow(key);
const timestamps = minuteWindows.get(key) ?? [];
timestamps.push(Date.now());
minuteWindows.set(key, timestamps);
}
export function getMinuteRequestCount(modelId: string): number {
if (!modelId) return 0;
const key = stripModelPrefix(modelId);
pruneMinuteWindow(key);
return minuteWindows.get(key)?.length ?? 0;
}
export function isRpmExhausted(modelId: string): boolean {
const rpm = getModelRpm(modelId);
if (rpm <= 0) return false;
return getMinuteRequestCount(modelId) >= rpm;
}
// ── Increment both (convenience) ─────────────────────────────────────────────
/** Increment both daily and minute counters for a Gemini request. */
export function incrementRequestCount(modelId: string): void {
incrementDailyRequestCount(modelId);
incrementMinuteRequestCount(modelId);
}
// ── Reset (testing) ──────────────────────────────────────────────────────────
export function resetCounters(): void {
dailyCounts.clear();
minuteWindows.clear();
}

View File

@@ -0,0 +1,40 @@
import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts";
import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts";
const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens;
export function toPositiveInteger(value: unknown): number | null {
const numericValue =
typeof value === "number"
? value
: typeof value === "string" && value.trim() !== ""
? Number(value)
: null;
if (numericValue === null || !Number.isFinite(numericValue)) return null;
const normalized = Math.floor(numericValue);
return normalized > 0 ? normalized : null;
}
export function resolveReasoningBufferedMaxTokens(
modelStr: string,
currentMaxTokens: unknown,
options: { enabled?: boolean } = {}
): number | null {
if (options.enabled === false) return null;
const current = toPositiveInteger(currentMaxTokens);
if (current === null) return null;
const capabilities = getResolvedModelCapabilities(modelStr);
if (capabilities.supportsThinking !== true) return null;
const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens);
if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null;
if (current > maxOutputTokens) return maxOutputTokens;
if (current === maxOutputTokens) return current;
const buffered = Math.max(current + 1000, Math.ceil(current * 1.5));
if (buffered > maxOutputTokens) return current;
return buffered;
}

View File

@@ -181,6 +181,93 @@ function getRefreshCacheKey(provider, refreshToken) {
return `${provider}:${tokenHash}`;
}
/**
* OAuth2 error codes that mean the refresh token is permanently dead and
* retrying will never succeed → callers must emit the unrecoverable sentinel
* so the HealthCheck deactivates the account instead of looping every 60s.
* Deliberately EXCLUDES transient codes (server_error, temporarily_unavailable,
* slow_down) so we never deactivate an account over a recoverable blip.
*/
const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
"invalid_grant",
"invalid_request",
"refresh_token_reused",
"invalid_token",
"expired_token",
"unauthorized_client",
"access_denied",
]);
/**
* Extract a canonical OAuth error code from a refresh-endpoint error body of
* ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in
* several shapes — a plain object `{error:"invalid_grant"}`, a nested
* `{error:{code:"invalid_grant"}}`, a JSON **string** (double-encoded body),
* or the raw JSON text wrapped as `{error:"<json text>"}` by a catch branch.
* The old `errorBody.error === "invalid_grant"` only matched the first shape,
* so the others returned `null` → the HealthCheck refresh loop (root cause of
* the 1352× claude/aa5dd5cf invalidation storm).
*
* Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES)
* or null. Never matches loosely — a known code is accepted only when it is a
* bare code string or the value of an `"error"`/`"error_code"` field, so a 502
* HTML page or a `server_error` body never becomes a false positive.
*/
export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
if (raw == null || depth > 6) return null;
if (typeof raw === "string") {
const s = raw.trim();
if (!s) return null;
if (UNRECOVERABLE_OAUTH_ERROR_CODES.has(s)) return s;
// The string may itself be JSON (a double-encoded body, or the raw text).
if (s[0] === "{" || s[0] === "[" || s[0] === '"') {
try {
const nested = extractOAuthErrorCode(JSON.parse(s), depth + 1);
if (nested) return nested;
} catch {
// not valid JSON — fall through to the field scan
}
}
// Safety net: a known code appearing as the value of an "error"/"error_code"
// field inside otherwise-unparsed text. Scoped to avoid false positives.
const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i);
if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1];
return null;
}
if (typeof raw === "object") {
const o = raw as Record<string, unknown>;
return (
extractOAuthErrorCode(o.error, depth + 1) ??
extractOAuthErrorCode(o.code, depth + 1) ??
extractOAuthErrorCode(o.error_code, depth + 1)
);
}
return null;
}
/**
* Read an error response body ONCE and classify it. Returns the raw text (for
* logging) and the extracted unrecoverable OAuth code (or null). Reading once
* avoids the double-read bug where `response.json()` consumes the stream and a
* later `response.text()` returns empty.
*/
async function readRefreshErrorBody(
response: Response
): Promise<{ rawText: string; code: string | null }> {
const rawText = await response.text().catch(() => "");
let parsed: unknown = rawText;
try {
parsed = JSON.parse(rawText);
} catch {
// keep rawText as-is
}
const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText);
return { rawText, code };
}
/**
* Refresh OAuth access token using refresh token
*/
@@ -229,6 +316,10 @@ export async function refreshAccessToken(
status: response.status,
error: errorText,
});
const code = extractOAuthErrorCode(errorText);
if (code === "invalid_grant" || code === "invalid_request") {
return { error: "unrecoverable_refresh_error", code };
}
return null;
}
@@ -401,6 +492,10 @@ export async function refreshClineToken(refreshToken, log, proxyConfig: unknown
status: response.status,
error: errorText,
});
const code = extractOAuthErrorCode(errorText);
if (code === "invalid_grant" || code === "invalid_request") {
return { error: "unrecoverable_refresh_error", code };
}
return null;
}
@@ -664,19 +759,17 @@ export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: un
);
if (!response.ok) {
let errorBody: { error?: string; error_description?: string } = {};
try {
errorBody = await response.json();
} catch {
const text = await response.text().catch(() => "unknown");
errorBody = { error: text };
}
// Read + classify the body ONCE, shape-agnostic. A proxy/MITM can deliver
// the invalid_grant 400 as a JSON string, a double-encoded string, a
// nested {error:{code}}, or raw text — all must yield the sentinel so the
// HealthCheck deactivates instead of looping every 60s.
const { rawText, code } = await readRefreshErrorBody(response);
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
status: response.status,
error: errorBody,
error: rawText.slice(0, 300),
});
if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") {
return { error: "unrecoverable_refresh_error", code: errorBody.error };
if (code === "invalid_grant" || code === "invalid_request") {
return { error: "unrecoverable_refresh_error", code };
}
return null;
}
@@ -1186,6 +1279,10 @@ export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown
status: response.status,
error: errorText,
});
const code = extractOAuthErrorCode(errorText);
if (code === "invalid_grant" || code === "invalid_request") {
return { error: "unrecoverable_refresh_error", code };
}
return null;
}
@@ -1230,6 +1327,10 @@ export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown
status: response.status,
error: errorText,
});
const code = extractOAuthErrorCode(errorText);
if (code === "invalid_grant" || code === "invalid_request") {
return { error: "unrecoverable_refresh_error", code };
}
return null;
}

View File

@@ -2995,24 +2995,65 @@ export function buildKiroUsageResult(
};
}
/**
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
* Exported for testing.
*/
export async function discoverKiroProfileArn(
accessToken: string,
usageBaseUrl: string,
region: string
): Promise<string | undefined> {
try {
const response = await fetch(usageBaseUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
Accept: "application/json",
},
body: JSON.stringify({ maxResults: 10 }),
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
signal: AbortSignal.timeout(10000),
});
if (!response.ok) return undefined;
const data = toRecord(await response.json());
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
const normalizedRegion = region.toLowerCase();
const matched =
profiles.find((profile: unknown) => {
const arn = toRecord(profile).arn;
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
}) || profiles[0];
const arn = toRecord(matched).arn;
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
} catch {
return undefined;
}
}
/**
* Kiro (AWS CodeWhisperer) Usage
*/
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
try {
const profileArn = providerSpecificData?.profileArn;
if (!profileArn) {
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
}
let profileArn =
typeof providerSpecificData?.profileArn === "string"
? providerSpecificData.profileArn
: undefined;
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
// endpoint must all match the region. Derive the region from the stored region (preferred)
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
const regionFromArn =
typeof profileArn === "string"
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
: undefined;
const regionFromArn = profileArn
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
: undefined;
const region =
(typeof providerSpecificData?.region === "string" &&
providerSpecificData.region.trim().toLowerCase()) ||
@@ -3021,6 +3062,17 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec
const usageBaseUrl =
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
if (!profileArn && accessToken) {
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
}
if (!profileArn) {
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
}
// Kiro uses AWS CodeWhisperer GetUsageLimits API
const payload = {
origin: "AI_EDITOR",

View File

@@ -212,7 +212,7 @@ export function openaiToClaudeRequest(model, body, stream) {
if (body.temperature !== undefined) {
result.temperature = body.temperature;
}
if (body.top_p !== undefined) {
if (body.temperature === undefined && body.top_p !== undefined) {
result.top_p = body.top_p;
}
if (body.stop !== undefined) {

View File

@@ -816,7 +816,7 @@ register(
FORMATS.GEMINI,
(model, body, stream = false, credentials = null) =>
openaiToGeminiRequest(model, body, stream, credentials, {
signaturelessToolCallMode: "native",
signaturelessToolCallMode: "context",
}),
null
);

View File

@@ -7,6 +7,7 @@
"checkJs": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"esModuleInterop": true,
"strict": false,

View File

@@ -138,8 +138,15 @@ function buildProxyUrlString(parsed: URL, port: string): string {
return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`;
}
/**
* SOCKS5 proxy support defaults ON (opt-OUT). A fresh deploy with no env set
* should honour SOCKS5 proxies out of the box — they were silently rejected
* before (default OFF), making accounts fall back to the host IP. Only an
* explicit falsey value (false/0/no/off) disables it.
*/
export function isSocks5ProxyEnabled(): boolean {
return process.env.ENABLE_SOCKS5_PROXY === "true";
const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase();
return !["false", "0", "no", "off"].includes(raw);
}
export function proxyUrlForLogs(proxyUrl: string): string {
@@ -173,7 +180,7 @@ export function normalizeProxyUrl(
}
if (parsed.protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
"[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);
}
if (!parsed.hostname) {
@@ -233,7 +240,7 @@ export function proxyConfigToUrl(
}
if (protocol === "socks5:" && !allowSocks5) {
throw new Error(
"[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
"[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);
}

View File

@@ -109,7 +109,11 @@ function normalizeResponsesSseIds(payload: JsonRecord): boolean {
}
}
if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) {
if (
payload.response &&
typeof payload.response === "object" &&
!Array.isArray(payload.response)
) {
const response = payload.response as JsonRecord;
let responseChanged = false;
const normalizedResponse = { ...response };
@@ -1016,6 +1020,32 @@ export function createSSEStream(options: StreamOptions = {}) {
}
};
const emitClaudeEmptyStreamErrorAndAbort = (
controller: TransformStreamDefaultController,
decrementPendingRequest = true
) => {
clearIdleTimer();
const msg = "Claude returned an empty response (no content block)";
console.warn(
`[STREAM] Empty Claude stream at flush - emitting error (${provider || "provider"}:${model || "unknown"})`
);
const errorBody = buildErrorBody(502, msg);
const errorEvent: Record<string, unknown> = { type: "error", error: errorBody.error };
const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE);
reqLogger?.appendConvertedChunk?.(errOutput);
clientPayloadCollector.push(errorEvent);
controller.enqueue(encoder.encode(errOutput));
if (onFailure) {
try {
void onFailure({ status: 502, message: msg, code: "empty_response" });
} catch {}
}
if (decrementPendingRequest) {
trackPendingRequest(model, provider, connectionId, false);
}
controller.error(markPendingRequestCleared(new Error(msg)));
};
const emitTranslatedClientItem = (
controller: TransformStreamDefaultController,
item: Record<string, unknown>
@@ -1059,13 +1089,8 @@ export function createSSEStream(options: StreamOptions = {}) {
sourceFormat === FORMATS.CLAUDE &&
shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, itemSanitized)
) {
const eventType = getClaudeEventType(itemSanitized);
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta:
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: false,
});
emitClaudeEmptyStreamErrorAndAbort(controller);
return;
}
if (sourceFormat === FORMATS.CLAUDE && isClaudeEventPayload(itemSanitized)) {
@@ -1300,12 +1325,8 @@ export function createSSEStream(options: StreamOptions = {}) {
type: eventType,
})
) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta:
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: false,
});
emitClaudeEmptyStreamErrorAndAbort(controller);
return;
}
pendingPassthroughEventLine = line;
@@ -1337,7 +1358,8 @@ export function createSSEStream(options: StreamOptions = {}) {
// clients like OpenCode, so drop it only for Responses-native consumers.
const hasActiveDeltaValue = (value: unknown): boolean => {
if (typeof value === "string") return value.length > 0;
if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry));
if (Array.isArray(value))
return value.some((entry) => hasActiveDeltaValue(entry));
if (value && typeof value === "object") {
return Object.values(value).some((entry) => hasActiveDeltaValue(entry));
}
@@ -1605,7 +1627,12 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed,
passthroughResponsesOutputItems
);
if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
if (
stripped ||
backfilled ||
textualToolCallBackfilled ||
responsesIdsNormalized
) {
output = `data: ${JSON.stringify(parsed)}\n`;
injectedUsage = true;
}
@@ -1632,13 +1659,8 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed
)
) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta:
parsed.type === "message_stop" &&
!claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: false,
});
emitClaudeEmptyStreamErrorAndAbort(controller);
return;
}
updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed);
const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap);
@@ -1708,14 +1730,16 @@ export function createSSEStream(options: StreamOptions = {}) {
!parsed.choices[0].delta.reasoning_content
);
const hadNonStringToolCallId = Array.isArray(parsed.choices)
? parsed.choices.some((choice) =>
Array.isArray(choice?.delta?.tool_calls) &&
choice.delta.tool_calls.some(
(tc) => tc?.id != null && typeof tc.id !== "string"
)
? parsed.choices.some(
(choice) =>
Array.isArray(choice?.delta?.tool_calls) &&
choice.delta.tool_calls.some(
(tc) => tc?.id != null && typeof tc.id !== "string"
)
)
: false;
const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string";
const hadNonStringTopLevelId =
parsed?.id != null && typeof parsed.id !== "string";
parsed = sanitizeStreamingChunk(parsed);
if (
@@ -2148,13 +2172,8 @@ export function createSSEStream(options: StreamOptions = {}) {
bufferedPayload
)
) {
const eventType = getClaudeEventType(bufferedPayload);
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta:
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: false,
});
emitClaudeEmptyStreamErrorAndAbort(controller, false);
return;
}
if (isClaudeEventPayload(bufferedPayload)) {
updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload);
@@ -2164,7 +2183,8 @@ export function createSSEStream(options: StreamOptions = {}) {
// Normalize numeric IDs for final buffered data: chunk (same as transform path)
if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) {
const flushedParsed = bufferedPayload as JsonRecord;
const flushedType = typeof flushedParsed.type === "string" ? flushedParsed.type : "";
const flushedType =
typeof flushedParsed.type === "string" ? flushedParsed.type : "";
const isResponses = flushedType.startsWith("response.");
const isClaude = isClaudeEventPayload(flushedParsed);
if (isResponses) {
@@ -2181,7 +2201,9 @@ export function createSSEStream(options: StreamOptions = {}) {
}
if (Array.isArray(flushedParsed.choices)) {
for (const choice of flushedParsed.choices as JsonRecord[]) {
const tcs = (choice as JsonRecord | undefined)?.delta as JsonRecord | undefined;
const tcs = (choice as JsonRecord | undefined)?.delta as
| JsonRecord
| undefined;
if (Array.isArray(tcs?.tool_calls)) {
for (const tc of tcs.tool_calls as JsonRecord[]) {
if (tc?.id != null && typeof tc.id !== "string") {
@@ -2208,11 +2230,8 @@ export function createSSEStream(options: StreamOptions = {}) {
}
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop,
});
emitClaudeEmptyStreamErrorAndAbort(controller, false);
return;
} else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: false,
@@ -2489,11 +2508,8 @@ export function createSSEStream(options: StreamOptions = {}) {
if (sourceFormat === FORMATS.CLAUDE) {
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: true,
includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta,
includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop,
});
emitClaudeEmptyStreamErrorAndAbort(controller, false);
return;
} else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) {
emitSyntheticClaudeEmptyResponse(controller, {
includeContentBlock: false,
@@ -2631,7 +2647,7 @@ export function createSSEStream(options: StreamOptions = {}) {
);
}
export default createSSEStream
export default createSSEStream;
// Convenience functions for backward compatibility
export function createSSETransformStreamWithLogger(

BIN
public/audio/ui-notify.mp3 Normal file

Binary file not shown.

View File

@@ -129,7 +129,10 @@ if (!existsSync(standaloneServerJs)) {
stdio: "inherit",
});
if (!existsSync(standaloneServerJs)) {
console.error("\n ❌ Standalone build not found after `npm run build` at:", standaloneServerJs);
console.error(
"\n ❌ Standalone build not found after `npm run build` at:",
standaloneServerJs
);
console.error(" Make sure next.config.mjs has: output: 'standalone'");
process.exit(1);
}
@@ -263,6 +266,53 @@ if (existsSync(cliSrcFile)) {
}
}
// ── Step 8.8: Build @omniroute/opencode-plugin ──────────────
// The plugin ships bundled inside the omniroute npm package (see root
// package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be
// present in the publish tarball so `omniroute setup opencode` can copy it
// into the user's OpenCode plugin dir. If the build fails we surface the
// error — shipping without the plugin's dist breaks the documented install
// flow for every downstream user.
const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin");
const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js");
const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs");
if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) {
const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs);
if (!pluginAlreadyBuilt) {
console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)...");
try {
// The plugin is a standalone package (not an npm workspace), so the root
// install never populates its node_modules — and tsup with `dts: true`
// needs the plugin's own devDependencies (typescript, @opencode-ai/plugin
// types). Without this install a fresh CI publish fails at this step.
if (!existsSync(join(opencodePluginSrc, "node_modules"))) {
const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], {
cwd: opencodePluginSrc,
stdio: "inherit",
});
}
execFileSync(NPX_BIN, ["tsup"], {
cwd: opencodePluginSrc,
stdio: "inherit",
env: { ...process.env, NODE_ENV: "production" },
});
console.log(" ✅ @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/");
} catch (err: any) {
console.error(" ❌ Failed to build @omniroute/opencode-plugin:", err.message);
console.error(" The published package would be missing the plugin dist.");
console.error(
" Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug."
);
process.exit(1);
}
} else {
console.log(" ✅ @omniroute/opencode-plugin dist/ already present (skipping rebuild)");
}
} else {
console.log(" ⏭️ @omniroute/opencode-plugin not found in workspace (skipping build)");
}
// ── Step 9: Copy shared utilities needed at runtime ────────
const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js");
const sharedApiKeyDest = join(DIST_DIR, "src", "shared", "utils");
@@ -379,7 +429,9 @@ const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR)
if (remainingUnexpectedFiles.length > 0) {
console.error("\n ❌ Staged dist/ still contains unexpected publish artifacts:");
remainingUnexpectedFiles.forEach((violation: string) => console.error(` - dist/${violation}`));
remainingUnexpectedFiles.forEach((violation: string) =>
console.error(` - dist/${violation}`)
);
process.exit(1);
}

View File

@@ -10,9 +10,10 @@
// Uses createRoot + act to mount each hook inside a minimal wrapper component
// so we test real React hook semantics without a full Next.js server context.
import React, { act } from "react";
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import path from "node:path";
// ---------------------------------------------------------------------------
// Global mocks required by the extracted hooks
@@ -27,10 +28,7 @@ vi.mock("next/navigation", () => ({
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values) {
return Object.entries(values).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
key
);
return Object.entries(values).reduce((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key);
}
return key;
},
@@ -83,10 +81,13 @@ describe("useProviderConnections — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderConnections("openai", true, false);
const hookResult = useProviderConnections("openai", true, false);
useEffect(() => {
result = hookResult;
}, [hookResult]);
return (
<span data-testid="loaded">
{String(result.connections.length)}|{String(result.batchTesting)}
{String(hookResult.connections.length)}|{String(hookResult.batchTesting)}
</span>
);
}
@@ -112,7 +113,10 @@ describe("useProviderConnections — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderConnections("openai", true, false);
const hookResult = useProviderConnections("openai", true, false);
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
@@ -181,7 +185,10 @@ describe("useProviderSettings — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderSettings("openai");
const hookResult = useProviderSettings("openai");
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
@@ -207,7 +214,10 @@ describe("useProviderSettings — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderSettings("codex");
const hookResult = useProviderSettings("codex");
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
@@ -253,7 +263,10 @@ describe("useProviderModels — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderModels("openai", false);
const hookResult = useProviderModels("openai", false);
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
@@ -275,7 +288,10 @@ describe("useProviderModels — initial state", () => {
let result: HookResult | null = null;
function TestWrapper() {
result = useProviderModels("openai", false);
const hookResult = useProviderModels("openai", false);
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
@@ -316,8 +332,13 @@ describe("useProviderModels — initial state", () => {
// Cycle-safety: hooks must NOT import from ProviderDetailPageClient
// ---------------------------------------------------------------------------
const HOOKS_DIR =
"/home/diegosouzapw/dev/proxys/OmniRoute/.worktrees/fix-3501-phase1f/src/app/(dashboard)/dashboard/providers/[id]/hooks";
// Resolve the hooks dir from the repo root (vitest runs from cwd). Was a
// hardcoded absolute worktree path that broke the test outside that worktree
// (#3501 Phase 1g-1j).
const HOOKS_DIR = path.join(
process.cwd(),
"src/app/(dashboard)/dashboard/providers/[id]/hooks"
);
describe("Cycle-safety — hooks do not import ProviderDetailPageClient", () => {
// We allow the name in JSDoc comments; what we forbid is an actual ES import statement.

View File

@@ -0,0 +1,120 @@
"use client";
import { Modal } from "@/shared/components";
type AdaptaTutorialModalProps = {
isOpen: boolean;
onClose: () => void;
};
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Como conectar o Adapta Web" size="md">
<div className="flex flex-col gap-5 text-sm">
<p className="text-text-muted">
O Adapta usa autenticação via Clerk. O token{" "}
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> é um JWT
de longa duração que permite renovar sessões automaticamente.
</p>
<ol className="flex flex-col gap-4 list-none">
<li className="flex gap-3">
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
1
</span>
<div>
<p className="font-medium">Acesse o chat do Adapta</p>
<p className="text-text-muted mt-0.5">
Abra{" "}
<a
href="https://agent.adapta.one/agentic-chat"
target="_blank"
rel="noopener noreferrer"
className="underline text-primary"
>
agent.adapta.one/agentic-chat
</a>{" "}
e faça login com sua conta Gold ou Business.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
2
</span>
<div>
<p className="font-medium">Abra o DevTools</p>
<p className="text-text-muted mt-0.5">
Pressione{" "}
<kbd className="bg-surface-2 px-1.5 py-0.5 rounded text-xs font-mono">F12</kbd>{" "}
ou{" "}
<kbd className="bg-surface-2 px-1.5 py-0.5 rounded text-xs font-mono">
Cmd+Option+I
</kbd>{" "}
para abrir as Ferramentas do Desenvolvedor.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
3
</span>
<div>
<p className="font-medium"> em Application Cookies</p>
<p className="text-text-muted mt-0.5">
Na aba <strong>Application</strong> (Chrome/Edge) ou <strong>Storage</strong>{" "}
(Firefox), expanda <strong>Cookies</strong> e clique em{" "}
<code className="bg-surface-2 px-1 rounded font-mono text-xs">
.clerk.agent.adapta.one
</code>
.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
4
</span>
<div>
<p className="font-medium">
Copie o valor do cookie{" "}
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code>
</p>
<p className="text-text-muted mt-0.5">
Localize o cookie chamado{" "}
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> na
lista. Clique nele e copie o conteúdo da coluna <strong>Value</strong> começa
com <code className="bg-surface-2 px-1 rounded font-mono text-xs">eyJ</code>.
</p>
</div>
</li>
<li className="flex gap-3">
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
5
</span>
<div>
<p className="font-medium">Cole aqui e salve</p>
<p className="text-text-muted mt-0.5">
Clique em <strong>Add Connection</strong>, cole o valor do{" "}
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> no
campo de API Key e salve. O OmniRoute renovará a sessão automaticamente.
</p>
</div>
</li>
</ol>
<div
className="rounded-lg p-3 text-xs text-text-muted"
style={{ backgroundColor: "rgba(110,58,211,0.08)", borderLeft: "3px solid #6E3AD3" }}
>
<strong>Dica:</strong> O cookie <code className="font-mono">__client</code> tem
validade longa (meses). será necessário renová-lo se você sair da conta ou o Adapta
invalidar a sessão.
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
type BatchTestResultsModalProps = {
batchTestResults: {
error?: any;
results?: Array<{
connectionId?: string;
connectionName?: string;
valid?: boolean;
latencyMs?: number;
diagnosis?: { type?: string };
}>;
summary?: {
passed: number;
failed: number;
total: number;
};
} | null;
providerInfo: any;
providerId: string;
emailsVisible: boolean;
onClose: () => void;
t: any;
};
export default function BatchTestResultsModal({
batchTestResults,
providerInfo,
providerId,
emailsVisible,
onClose,
t,
}: BatchTestResultsModalProps) {
if (!batchTestResults) return null;
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
onClick={onClose}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<h3 className="font-semibold">{t("testResults")}</h3>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
aria-label={t("close")}
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</div>
<div className="p-5">
{batchTestResults.error &&
(!batchTestResults.results || batchTestResults.results.length === 0) ? (
<div className="text-center py-6">
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">
error
</span>
<p className="text-sm text-red-400">{String(batchTestResults.error)}</p>
</div>
) : (
<div className="flex flex-col gap-3">
{batchTestResults.summary && (
<div className="flex items-center gap-3 text-xs mb-1">
<span className="text-text-muted">{providerInfo?.name || providerId}</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
{t("passedCount", { count: batchTestResults.summary.passed })}
</span>
{batchTestResults.summary.failed > 0 && (
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
{t("failedCount", { count: batchTestResults.summary.failed })}
</span>
)}
<span className="text-text-muted ml-auto">
{t("testedCount", { count: batchTestResults.summary.total })}
</span>
</div>
)}
{(batchTestResults.results || []).map((r: any, i: number) => (
<div
key={r.connectionId || i}
className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]"
>
<span
className={`material-symbols-outlined text-[16px] ${
r.valid ? "text-emerald-500" : "text-red-500"
}`}
>
{r.valid ? "check_circle" : "error"}
</span>
<div className="flex-1 min-w-0">
<span className="font-medium">
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
</span>
</div>
{r.latencyMs !== undefined && (
<span className="text-text-muted font-mono tabular-nums">
{t("millisecondsAbbr", { value: r.latencyMs })}
</span>
)}
<span
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
r.valid
? "bg-emerald-500/15 text-emerald-400"
: "bg-red-500/15 text-red-400"
}`}
>
{r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")}
</span>
</div>
))}
{(!batchTestResults.results || batchTestResults.results.length === 0) && (
<div className="text-center py-4 text-text-muted text-sm">
{t("noActiveConnectionsInGroup")}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,121 @@
"use client";
// Phase 1t.2 extraction — Issue #3501
import { useRouter } from "next/navigation";
import { Card, Button } from "@/shared/components";
import { getApiLabel, getApiPath } from "../providerPageHelpers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface ProviderNode {
baseUrl?: string;
apiType?: string;
chatPath?: string;
prefix?: string;
[key: string]: unknown;
}
interface CompatibleNodeCardProps {
providerId: string;
providerNode: ProviderNode;
isCcCompatible: boolean;
isAnthropicCompatible: boolean;
isAnthropicProtocolCompatible: boolean;
gateConnectionFlow: (callback: () => void) => void;
openApiKeyAddFlow: () => void;
onOpenEditNodeModal: () => void;
t: ProviderMessageTranslator;
}
export default function CompatibleNodeCard({
providerId,
providerNode,
isCcCompatible,
isAnthropicCompatible,
isAnthropicProtocolCompatible,
gateConnectionFlow,
openApiKeyAddFlow,
onOpenEditNodeModal,
t,
}: CompatibleNodeCardProps) {
const router = useRouter();
return (
<Card>
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold">
{isCcCompatible
? t("ccCompatibleDetailsTitle")
: isAnthropicCompatible
? t("anthropicCompatibleDetails")
: t("openaiCompatibleDetails")}
</h2>
<p className="text-sm text-text-muted">
{getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "}
{(providerNode.baseUrl || "").replace(/\/$/, "")}/
{getApiPath(
isCcCompatible,
isAnthropicCompatible,
providerNode?.apiType,
providerNode?.chatPath
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
{t("add")}
</Button>
<Button
size="sm"
variant="secondary"
icon="edit"
onClick={onOpenEditNodeModal}
>
{t("edit")}
</Button>
<Button
size="sm"
variant="secondary"
icon="delete"
onClick={async () => {
if (
!confirm(
t("deleteCompatibleNodeConfirm", {
type: isCcCompatible
? t("ccCompatibleLabel")
: isAnthropicCompatible
? t("anthropic")
: t("openai"),
})
)
)
return;
try {
const res = await fetch(`/api/provider-nodes/${providerId}`, {
method: "DELETE",
});
if (res.ok) {
router.push("/dashboard/providers");
}
} catch (error) {
console.error("Error deleting provider node:", error);
}
}}
>
{t("delete")}
</Button>
</div>
</div>
{isCcCompatible && (
<div className="mb-4 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
warning
</span>
<p>{t("ccCompatibleValidationHint")}</p>
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,378 @@
"use client";
import { Button, Toggle } from "@/shared/components";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
type ConnectionsHeaderToolbarProps = {
providerId: string;
providerInfo: any; // resolveDashboardProviderInfo result
isCompatible: boolean;
isCommandCode: boolean;
isOAuth: boolean;
providerSupportsPat: boolean;
connections: any[]; // ConnectionRowConnection[]
batchTesting: boolean;
batchRetesting: boolean;
retestingId: string | null;
distributingProxies: boolean;
proxyConfig: any;
// from useProviderSettings
preferClaudeCodeForUnprefixedClaudeModels: boolean;
claudeRoutingSettingsLoaded: boolean;
claudeRoutingSettingsLoadError: string | null;
savingClaudeRoutingPreference: boolean;
handleToggleClaudeRoutingPreference: () => void;
loadClaudeRoutingSettings: () => Promise<void>;
codexGlobalServiceMode: string;
codexGlobalServiceModeOptions: Array<{ value: string; label: string }>;
codexSettingsLoaded: boolean;
codexSettingsLoadError: string | null;
savingCodexGlobalServiceMode: boolean;
handleChangeCodexGlobalServiceMode: (mode: any) => void;
loadCodexSettings: () => Promise<void>;
// Modal triggers
onSetProxyTarget: (target: { level: string; id: string; label: string }) => void;
handleDistributeProxies: () => void;
handleBatchTestAll: () => void;
gateConnectionFlow: (callback: () => void) => void;
openApiKeyAddFlow: () => void;
openPrimaryAddFlow: () => void;
openExternalLinkFlow: () => void;
handleOpenCommandCodeConnect: () => void;
commandCodeAuthState: { phase: string };
onOpenOAuthModal: () => void;
onOpenCodexCliGuide: () => void;
onOpenImportCodex: () => void;
onOpenImportClaude: () => void;
onOpenImportGemini: () => void;
t: ProviderMessageTranslator;
};
export default function ConnectionsHeaderToolbar({
providerId,
providerInfo,
isCompatible,
isCommandCode,
isOAuth,
providerSupportsPat,
connections,
batchTesting,
batchRetesting,
retestingId,
distributingProxies,
proxyConfig,
preferClaudeCodeForUnprefixedClaudeModels,
claudeRoutingSettingsLoaded,
claudeRoutingSettingsLoadError,
savingClaudeRoutingPreference,
handleToggleClaudeRoutingPreference,
loadClaudeRoutingSettings,
codexGlobalServiceMode,
codexGlobalServiceModeOptions,
codexSettingsLoaded,
codexSettingsLoadError,
savingCodexGlobalServiceMode,
handleChangeCodexGlobalServiceMode,
loadCodexSettings,
onSetProxyTarget,
handleDistributeProxies,
handleBatchTestAll,
gateConnectionFlow,
openApiKeyAddFlow,
openPrimaryAddFlow,
openExternalLinkFlow,
handleOpenCommandCodeConnect,
commandCodeAuthState,
onOpenOAuthModal,
onOpenCodexCliGuide,
onOpenImportCodex,
onOpenImportClaude,
onOpenImportGemini,
t,
}: ConnectionsHeaderToolbarProps) {
return (
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">{t("connections")}</h2>
{providerId === "claude" && (
<div
className="inline-flex items-center gap-2 rounded-lg border border-orange-500/20 bg-orange-500/5 px-2 py-1 text-xs font-medium text-text-muted"
title={providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsTooltip",
"Route bare claude-* model IDs from Claude Code clients through the Claude Code account instead of asking for a provider prefix."
)}
>
<span className="material-symbols-outlined text-[14px] text-orange-500">
alt_route
</span>
<span>
{providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsLabel",
"Claude Code default"
)}
</span>
<Toggle
size="sm"
checked={preferClaudeCodeForUnprefixedClaudeModels}
onChange={handleToggleClaudeRoutingPreference}
disabled={savingClaudeRoutingPreference || !claudeRoutingSettingsLoaded}
ariaLabel={providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsAria",
"Prefer Claude Code for unprefixed Claude models"
)}
title={
preferClaudeCodeForUnprefixedClaudeModels
? providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsDisable",
"Disable Claude Code preference for bare claude-* model IDs"
)
: providerText(
t,
"preferClaudeCodeForUnprefixedClaudeModelsEnable",
"Enable Claude Code preference for bare claude-* model IDs"
)
}
/>
<span className="text-[11px] text-text-muted/70">
{preferClaudeCodeForUnprefixedClaudeModels
? providerText(t, "toggleOnShort", "On")
: providerText(t, "toggleOffShort", "Off")}
</span>
{claudeRoutingSettingsLoadError ? (
<button
type="button"
onClick={() => void loadClaudeRoutingSettings()}
className="rounded border border-orange-500/30 px-2 py-0.5 text-[11px] font-medium text-orange-600 hover:bg-orange-500/10 dark:text-orange-300"
title={claudeRoutingSettingsLoadError}
>
{providerText(t, "retry", "Retry")}
</button>
) : null}
</div>
)}
{providerId === "codex" && (
<div
className="inline-flex items-center gap-2 rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1 text-xs font-medium text-text-muted"
title={providerText(
t,
"providerDetailServiceModeTooltip",
"Set a global Codex service mode, or leave accounts on their individual service-tier setting."
)}
>
<span>
{providerText(t, "providerDetailServiceModeLabel", "Global service mode:")}
</span>
<select
value={codexGlobalServiceMode}
onChange={(event) =>
handleChangeCodexGlobalServiceMode(event.target.value as CodexGlobalServiceMode)
}
disabled={savingCodexGlobalServiceMode || !codexSettingsLoaded}
aria-label="Global Codex service mode"
className="rounded-md border border-border bg-bg px-2 py-1 text-xs text-text-main outline-none transition-colors focus:border-primary disabled:opacity-60"
>
{codexGlobalServiceModeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{codexSettingsLoadError ? (
<button
type="button"
onClick={() => void loadCodexSettings()}
className="rounded border border-sky-500/30 px-2 py-0.5 text-[11px] font-medium text-sky-600 hover:bg-sky-500/10 dark:text-sky-300"
title={codexSettingsLoadError}
>
{providerText(t, "retry", "Retry")}
</button>
) : null}
</div>
)}
{/* Provider-level proxy indicator/button */}
<button
onClick={() =>
onSetProxyTarget({
level: "provider",
id: providerId,
label: providerInfo?.name || providerId,
})
}
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all ${
proxyConfig?.providers?.[providerId]
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
}`}
title={
proxyConfig?.providers?.[providerId]
? t("providerProxyTitleConfigured", {
host: proxyConfig.providers[providerId].host || t("configured"),
})
: t("providerProxyConfigureHint")
}
>
<span className="material-symbols-outlined text-[14px]">vpn_lock</span>
{proxyConfig?.providers?.[providerId]
? proxyConfig.providers[providerId].host || t("providerProxy")
: t("providerProxy")}
</button>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{connections.length > 0 && (
<button
onClick={() => handleDistributeProxies()}
disabled={distributingProxies || batchTesting || !!retestingId}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
distributingProxies
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("distributeProxies")}
aria-label={t("distributeProxies")}
>
<span className="material-symbols-outlined text-[14px]">
{distributingProxies ? "sync" : "swap_horiz"}
</span>
{distributingProxies ? t("distributing") : t("distributeProxies")}
</button>
)}
{connections.length > 1 && (
<button
onClick={handleBatchTestAll}
disabled={batchTesting || batchRetesting || !!retestingId}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
batchTesting
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
}`}
title={t("testAll")}
aria-label={t("testAll")}
>
<span className="material-symbols-outlined text-[14px]">
{batchTesting ? "sync" : "play_arrow"}
</span>
{batchTesting ? t("testing") : t("testAll")}
</button>
)}
{!isCompatible ? (
<>
{isCommandCode ? (
<>
<Button
size="sm"
icon="open_in_new"
loading={
commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying"
}
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
>
Connect
</Button>
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
Manual API key
</Button>
</>
) : (
<>
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(openPrimaryAddFlow)}
>
{providerSupportsPat ? "Add PAT" : t("add")}
</Button>
{providerId === "qoder" && (
<Button
size="sm"
variant="secondary"
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
>
Experimental OAuth
</Button>
)}
{providerId === "codex" && (
<Button
size="sm"
variant="secondary"
icon="menu_book"
onClick={() => onOpenCodexCliGuide()}
>
Codex CLI Guide
</Button>
)}
{providerId === "codex" && (
<Button
size="sm"
variant="secondary"
icon="share"
onClick={() => gateConnectionFlow(openExternalLinkFlow)}
>
Adicionar Externo
</Button>
)}
{providerId === "codex" && (
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportCodex)}
>
{typeof (t as any).has === "function" && (t as any).has("importCodexAuth")
? t("importCodexAuth")
: "Import auth"}
</Button>
)}
{providerId === "claude" && (
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportClaude)}
>
{typeof (t as any).has === "function" && (t as any).has("importClaudeAuth")
? t("importClaudeAuth")
: "Import auth"}
</Button>
)}
{providerId === "gemini-cli" && (
<Button
size="sm"
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGemini)}
>
{typeof (t as any).has === "function" && (t as any).has("importGeminiAuth")
? t("importGeminiAuth")
: "Import auth"}
</Button>
)}
</>
)}
</>
) : (
connections.length === 0 && (
<Button
size="sm"
icon="add"
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
{t("add")}
</Button>
)
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,630 @@
"use client";
import React from "react";
import { type ConnectionRowConnection } from "./ConnectionRow";
import ConnectionRow from "./ConnectionRow";
import { Button } from "@/shared/components";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
import { compareTr } from "@/shared/utils/turkishText";
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
type ConnectionsListPanelProps = {
connections: ConnectionRowConnection[];
providerId: string;
isCcCompatible: boolean;
isOAuth: boolean;
codexGlobalServiceMode: CodexGlobalServiceMode | string;
selectedIds: Set<string>;
batchUpdating: string | null;
batchRetesting: boolean;
batchDeleting: boolean;
batchTesting: boolean;
retestingId: string | null;
refreshingId: string | null;
distributingProxies: boolean;
healthFilter: string;
page: number;
PAGE_SIZE: number;
connProxyMap: Record<string, { proxy?: { host?: string }; level?: string } | undefined>;
proxyConfig: any;
applyingCodexAuthId: string | null;
exportingCodexAuthId: string | null;
applyingClaudeAuthId: string | null;
exportingClaudeAuthId: string | null;
applyingGeminiAuthId: string | null;
exportingGeminiAuthId: string | null;
emailsVisible: boolean;
// Setters
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setPage: React.Dispatch<React.SetStateAction<number>>;
setHealthFilter: (v: string) => void;
// Callbacks from useProviderConnections
handleDelete: (id: string) => void;
handleUpdateConnectionStatus: (id: string, isActive: boolean) => void;
handleToggleRateLimit: (id: string, enabled: boolean) => void;
handleToggleClaudeExtraUsage: (id: string, enabled: boolean) => void;
handleToggleCliproxyapiMode: (id: string, enabled: boolean) => void;
handleToggleCodexLimit: (id: string, type: "use5h" | "useWeekly", enabled: boolean) => void;
handleToggleProxyEnabled: (id: string, enabled: boolean) => void;
handleTogglePerKeyProxyEnabled: (id: string, enabled: boolean) => void;
handleRetestConnection: (id: string) => void;
handleRefreshToken: (id: string) => void;
handleSwapPriority: (a: ConnectionRowConnection, b: ConnectionRowConnection) => void;
handleBatchSetActive: (active: boolean) => void;
handleBatchDeleteOpenModal: () => void;
handleBatchRetest: () => void;
handleToggleSelectOne: (id: string) => void;
handleToggleSelectAll: () => void;
handleDistributeProxies: (tag?: string) => void;
cpaProviderEnabled: boolean;
// Modal triggers (all pass through from client, no closing over client internals)
onOpenEditModal: (conn: ConnectionRowConnection) => void;
onOpenOAuth: (conn: ConnectionRowConnection) => void;
onSetProxyTarget: (target: { level: string; id: string; label: string }) => void;
onOpenApplyCodexModal: (connId: string) => void;
onExportCodexAuthFile: (connId: string) => void;
onOpenApplyClaudeModal: (connId: string) => void;
onExportClaudeAuthFile: (connId: string) => void;
onOpenApplyGeminiModal: (connId: string) => void;
onExportGeminiAuthFile: (connId: string) => void;
gateConnectionFlow: (callback: () => void) => void;
t: any; // ProviderMessageTranslator
};
export default function ConnectionsListPanel({
connections,
providerId,
isCcCompatible,
isOAuth,
codexGlobalServiceMode,
selectedIds,
batchUpdating,
batchRetesting,
batchDeleting,
batchTesting,
retestingId,
refreshingId,
distributingProxies,
healthFilter,
page,
PAGE_SIZE,
connProxyMap,
proxyConfig,
applyingCodexAuthId,
exportingCodexAuthId,
applyingClaudeAuthId,
exportingClaudeAuthId,
applyingGeminiAuthId,
exportingGeminiAuthId,
emailsVisible,
setSelectedIds,
setPage,
setHealthFilter,
handleDelete,
handleUpdateConnectionStatus,
handleToggleRateLimit,
handleToggleClaudeExtraUsage,
handleToggleCliproxyapiMode,
handleToggleCodexLimit,
handleToggleProxyEnabled,
handleTogglePerKeyProxyEnabled,
handleRetestConnection,
handleRefreshToken,
handleSwapPriority,
handleBatchSetActive,
handleBatchDeleteOpenModal,
handleBatchRetest,
handleToggleSelectOne,
handleToggleSelectAll,
handleDistributeProxies,
cpaProviderEnabled,
onOpenEditModal,
onOpenOAuth,
onSetProxyTarget,
onOpenApplyCodexModal,
onExportCodexAuthFile,
onOpenApplyClaudeModal,
onExportClaudeAuthFile,
onOpenApplyGeminiModal,
onExportGeminiAuthFile,
gateConnectionFlow,
t,
}: ConnectionsListPanelProps) {
const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
const allSelected = selectedIds.size === connections.length && connections.length > 0;
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting;
const bulkActions = selectedIds.size > 0 && (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="secondary"
size="sm"
icon="toggle_on"
loading={batchUpdating === "activate"}
disabled={bulkBusy && batchUpdating !== "activate"}
onClick={() => handleBatchSetActive(true)}
>
{t("batchActivateSelected")}
</Button>
<Button
variant="secondary"
size="sm"
icon="toggle_off"
loading={batchUpdating === "deactivate"}
disabled={bulkBusy && batchUpdating !== "deactivate"}
onClick={() => handleBatchSetActive(false)}
>
{t("batchDeactivateSelected")}
</Button>
<Button
variant="secondary"
size="sm"
icon="play_arrow"
loading={batchRetesting}
disabled={(bulkBusy && !batchRetesting) || !!retestingId}
onClick={handleBatchRetest}
>
{t("batchRetestSelected")}
</Button>
<Button
variant="danger"
size="sm"
icon="delete"
loading={batchDeleting}
disabled={bulkBusy && !batchDeleting}
onClick={handleBatchDeleteOpenModal}
>
{t("batchDeleteSelected", { count: selectedIds.size })}
</Button>
</div>
);
const isHealthy = (c: ConnectionRowConnection): boolean => {
const s = c.testStatus;
return c.isActive !== false && (!s || s === "active" || s === "success");
};
const STATUS_FILTER_OPTIONS = [
{ value: "all", label: t("filterAll", "All") },
{ value: "active", label: t("filterActive", "Active") },
{ value: "error", label: t("filterError", "Error") },
{ value: "banned", label: t("filterBanned", "Banned") },
{
value: "credits_exhausted",
label: t("filterCreditsExhausted", "Credits Exhausted"),
},
];
const filtered =
healthFilter === "all"
? sorted
: sorted.filter((c) => {
if (healthFilter === "active") return isHealthy(c);
if (healthFilter === "error")
return (
!isHealthy(c) &&
c.testStatus !== "banned" &&
c.testStatus !== "credits_exhausted"
);
return c.testStatus === healthFilter;
});
const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const clampedPage = Math.min(page, totalFilteredPages - 1);
const pageStart = clampedPage * PAGE_SIZE;
const pageEnd = pageStart + PAGE_SIZE;
const filterPills = (
<div className="flex items-center gap-1.5 flex-wrap">
{STATUS_FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setHealthFilter(opt.value);
setPage(0);
setSelectedIds(new Set());
}}
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
healthFilter === opt.value
? "bg-primary text-white"
: "bg-muted/60 text-text-muted hover:bg-muted"
}`}
>
{opt.label}
</button>
))}
</div>
);
const paginationBar =
totalFilteredPages > 1 ? (
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
<span className="text-xs text-text-muted">
{pageStart + 1}{Math.min(pageEnd, filtered.length)} / {filtered.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
icon="chevron_left"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
/>
<span className="text-xs text-text-muted min-w-[4rem] text-center">
{clampedPage + 1} / {totalFilteredPages}
</span>
<Button
variant="ghost"
size="sm"
icon="chevron_right"
disabled={clampedPage >= totalFilteredPages - 1}
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
/>
</div>
</div>
) : null;
if (!hasAnyTag) {
const pageConnections = filtered.slice(pageStart, pageEnd);
const allSelectedPage =
pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
const someSelectedPage = pageConnections.some((c) => selectedIds.has(c.id));
return (
<>
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelectedPage}
ref={(el) => {
if (el) el.indeterminate = someSelectedPage;
}}
onChange={() => {
if (allSelectedPage) {
const toRemove = new Set(pageConnections.map((c) => c.id));
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of toRemove) next.delete(id);
return next;
});
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const c of pageConnections) next.add(c.id);
return next;
});
}
}}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
filtered.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
{filterPills}
</div>
{bulkActions}
</div>
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
{pageConnections.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-text-muted">
{t("noFilteredConnections", "No connections match the current filter.")}
</div>
) : (
pageConnections.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={conn.authType === "oauth"}
isClaude={providerId === "claude"}
codexGlobalServiceMode={codexGlobalServiceMode}
isFirst={index === 0}
isLast={index === pageConnections.length - 1}
isSelected={selectedIds.has(conn.id)}
onToggleSelect={() => handleToggleSelectOne(conn.id)}
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
onToggleClaudeExtraUsage={(enabled) =>
handleToggleClaudeExtraUsage(conn.id, enabled)
}
isCodex={providerId === "codex"}
isGeminiCli={providerId === "gemini-cli"}
isCcCompatible={isCcCompatible}
cliproxyapiEnabled={cpaProviderEnabled}
onToggleCliproxyapiMode={(enabled) =>
handleToggleCliproxyapiMode(conn.id, enabled)
}
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => onOpenEditModal(conn)}
onDelete={() => handleDelete(conn.id)}
onReauth={
conn.authType === "oauth"
? () => gateConnectionFlow(() => onOpenOAuth(conn))
: undefined
}
onRefreshToken={
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex" ? () => onOpenApplyCodexModal(conn.id) : undefined
}
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
onExportCodexAuthFile={
providerId === "codex" ? () => onExportCodexAuthFile(conn.id) : undefined
}
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
onApplyClaudeAuthLocal={
providerId === "claude" ? () => onOpenApplyClaudeModal(conn.id) : undefined
}
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
onExportClaudeAuthFile={
providerId === "claude" ? () => onExportClaudeAuthFile(conn.id) : undefined
}
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
onApplyGeminiAuthLocal={
providerId === "gemini-cli"
? () => onOpenApplyGeminiModal(conn.id)
: undefined
}
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
onExportGeminiAuthFile={
providerId === "gemini-cli"
? () => onExportGeminiAuthFile(conn.id)
: undefined
}
isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id}
onProxy={() =>
onSetProxyTarget({
level: "key",
id: conn.id,
label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
proxySource={connProxyMap[conn.id]?.level || null}
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
onTogglePerKeyProxyEnabled={(enabled) =>
handleTogglePerKeyProxyEnabled(conn.id, enabled)
}
/>
))
)}
</div>
{paginationBar}
</>
);
}
// Build ordered tag groups: untagged first, then alphabetically
const groupMap = new Map<string, ConnectionRowConnection[]>();
for (const conn of filtered) {
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
if (!groupMap.has(tag)) groupMap.set(tag, []);
groupMap.get(tag)!.push(conn);
}
const groupKeys = Array.from(groupMap.keys()).sort((a, b) => {
if (a === "") return -1;
if (b === "") return 1;
return compareTr(a, b);
});
return (
<>
{selectedIds.size > 0 || connections.length > 0 ? (
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
<div className="flex items-center gap-2 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={handleToggleSelectAll}
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
/>
<span className="text-sm font-medium text-text-muted">
{selectedIds.size > 0
? providerCountText(
t,
"selectedCount",
selectedIds.size,
"{count} selected",
"{count} selected"
)
: providerCountText(
t,
"accountsCount",
filtered.length,
"{count} account",
"{count} accounts"
)}
</span>
</label>
{filterPills}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Distribute Proxies lives in the provider toolbar (top action bar);
removed the duplicate here that rendered simultaneously when nothing
was selected. Per-tag groups keep their own scoped button. */}
{bulkActions}
</div>
</div>
) : null}
<div className="flex flex-col gap-0 border border-t-0 border-border rounded-b-lg overflow-hidden">
{groupKeys.map((tag, gi) => {
const groupConns = groupMap.get(tag)!;
return (
<div
key={tag || "__untagged__"}
className={
gi > 0
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
: ""
}
>
{tag && (
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
label
</span>
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
{tag}
</span>
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
<Button
variant="ghost"
size="sm"
icon="shield"
loading={distributingProxies}
onClick={() => handleDistributeProxies(tag)}
>
Distribute Proxies
</Button>
<span className="text-[10px] text-text-muted/40">{groupConns.length}</span>
</div>
)}
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
{groupConns.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={conn.authType === "oauth"}
isClaude={providerId === "claude"}
codexGlobalServiceMode={codexGlobalServiceMode}
isFirst={gi === 0 && index === 0}
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
isSelected={selectedIds.has(conn.id)}
onToggleSelect={() => handleToggleSelectOne(conn.id)}
onMoveUp={() =>
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
}
onMoveDown={() =>
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
}
onToggleActive={(isActive) =>
handleUpdateConnectionStatus(conn.id, isActive)
}
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
onToggleClaudeExtraUsage={(enabled) =>
handleToggleClaudeExtraUsage(conn.id, enabled)
}
isCodex={providerId === "codex"}
isGeminiCli={providerId === "gemini-cli"}
isCcCompatible={isCcCompatible}
cliproxyapiEnabled={cpaProviderEnabled}
onToggleCliproxyapiMode={(enabled) =>
handleToggleCliproxyapiMode(conn.id, enabled)
}
onToggleCodex5h={(enabled) =>
handleToggleCodexLimit(conn.id, "use5h", enabled)
}
onToggleCodexWeekly={(enabled) =>
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
}
onRetest={() => handleRetestConnection(conn.id)}
isRetesting={retestingId === conn.id}
onEdit={() => onOpenEditModal(conn)}
onDelete={() => handleDelete(conn.id)}
onReauth={
conn.authType === "oauth"
? () => gateConnectionFlow(() => onOpenOAuth(conn))
: undefined
}
onRefreshToken={
conn.authType === "oauth"
? () => handleRefreshToken(conn.id)
: undefined
}
isRefreshing={refreshingId === conn.id}
onApplyCodexAuthLocal={
providerId === "codex"
? () => onOpenApplyCodexModal(conn.id)
: undefined
}
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
onExportCodexAuthFile={
providerId === "codex"
? () => onExportCodexAuthFile(conn.id)
: undefined
}
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
onApplyClaudeAuthLocal={
providerId === "claude"
? () => onOpenApplyClaudeModal(conn.id)
: undefined
}
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
onExportClaudeAuthFile={
providerId === "claude"
? () => onExportClaudeAuthFile(conn.id)
: undefined
}
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
onApplyGeminiAuthLocal={
providerId === "gemini-cli"
? () => onOpenApplyGeminiModal(conn.id)
: undefined
}
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
onExportGeminiAuthFile={
providerId === "gemini-cli"
? () => onExportGeminiAuthFile(conn.id)
: undefined
}
isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id}
onProxy={() =>
onSetProxyTarget({
level: "key",
id: conn.id,
label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id),
})
}
hasProxy={!!connProxyMap[conn.id]?.proxy}
proxySource={connProxyMap[conn.id]?.level || null}
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
onToggleProxyEnabled={(enabled) =>
handleToggleProxyEnabled(conn.id, enabled)
}
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
onTogglePerKeyProxyEnabled={(enabled) =>
handleTogglePerKeyProxyEnabled(conn.id, enabled)
}
/>
))}
</div>
</div>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
// Phase 1t.6 extraction — Issue #3501
import { Button } from "@/shared/components";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface CommandCodeAuthState {
phase: string;
[key: string]: unknown;
}
interface EmptyConnectionsPlaceholderProps {
isOAuth: boolean;
isCompatible: boolean;
isCommandCode: boolean;
providerId: string;
providerSupportsPat: boolean;
commandCodeAuthState: CommandCodeAuthState;
gateConnectionFlow: (callback: () => void) => void;
openApiKeyAddFlow: () => void;
openPrimaryAddFlow: () => void;
handleOpenCommandCodeConnect: () => void;
onOpenOAuthModal: () => void;
onOpenImportCodex: () => void;
onOpenImportClaude: () => void;
onOpenImportGemini: () => void;
t: ProviderMessageTranslator;
}
export default function EmptyConnectionsPlaceholder({
isOAuth,
isCompatible,
isCommandCode,
providerId,
providerSupportsPat,
commandCodeAuthState,
gateConnectionFlow,
openApiKeyAddFlow,
openPrimaryAddFlow,
handleOpenCommandCodeConnect,
onOpenOAuthModal,
onOpenImportCodex,
onOpenImportClaude,
onOpenImportGemini,
t,
}: EmptyConnectionsPlaceholderProps) {
return (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
<span className="material-symbols-outlined text-[32px]">
{isOAuth ? "lock" : "key"}
</span>
</div>
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
{!isCompatible && (
<div className="flex items-center justify-center gap-2">
{isCommandCode ? (
<>
<Button
icon="open_in_new"
loading={
commandCodeAuthState.phase === "starting" ||
commandCodeAuthState.phase === "polling" ||
commandCodeAuthState.phase === "applying"
}
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
>
Connect
</Button>
<Button
variant="secondary"
icon="add"
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
>
Manual API key
</Button>
</>
) : (
<>
<Button icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
{providerSupportsPat ? "Add PAT" : t("addConnection")}
</Button>
{providerId === "qoder" && (
<Button
variant="secondary"
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
>
Experimental OAuth
</Button>
)}
{providerId === "codex" && (
<Button
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportCodex)}
>
{typeof t.has === "function" && t.has("importCodexAuth")
? t("importCodexAuth")
: "Import auth"}
</Button>
)}
{providerId === "claude" && (
<Button
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportClaude)}
>
{typeof t.has === "function" && t.has("importClaudeAuth")
? t("importClaudeAuth")
: "Import auth"}
</Button>
)}
{providerId === "gemini-cli" && (
<Button
variant="secondary"
icon="upload_file"
onClick={() => gateConnectionFlow(onOpenImportGemini)}
>
{typeof t.has === "function" && t.has("importGeminiAuth")
? t("importGeminiAuth")
: "Import auth"}
</Button>
)}
</>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { Modal, Button } from "@/shared/components";
type ExternalLinkModalProps = {
isOpen: boolean;
onClose: () => void;
loading: boolean;
error: string | null;
url: string;
copied: string | false;
onCopy: (text: string, key: string) => void;
};
export default function ExternalLinkModal({
isOpen,
onClose,
loading,
error,
url,
copied,
onCopy,
}: ExternalLinkModalProps) {
return (
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Externo — link do Codex">
<div className="space-y-4">
<p className="text-sm text-text-muted">
Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a
página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso
único, expira em 15 minutos.
</p>
{loading ? (
<p className="text-sm text-text-muted">Gerando link</p>
) : error ? (
<p className="text-sm text-red-500">{error}</p>
) : url ? (
<>
<div className="rounded-lg border border-border bg-bg-base p-3 break-all text-sm text-text-main">
{url}
</div>
<div className="flex gap-2">
<Button
className="flex-1"
icon="open_in_new"
onClick={() => window.open(url, "_blank", "noopener")}
>
Abrir
</Button>
<Button
variant="secondary"
icon="content_copy"
onClick={() => onCopy(url, "extlink")}
>
{copied === "extlink" ? "Copiado" : "Copiar"}
</Button>
</div>
<p className="flex items-center gap-2 text-xs text-text-muted">
<span className="material-symbols-outlined animate-spin text-[16px]">sync</span>
Aguardando a autenticação no navegador da pessoa esta janela atualiza sozinha.
</p>
</>
) : null}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,142 @@
"use client";
/**
* ImportProgressModal — Issue #3501 Phase 1k
*
* Extracted from the inline Import Progress Modal JSX in ProviderDetailPageClient.
* Pure presentational component driven entirely by props.
*
* Cycle-safe: no import from ProviderDetailPageClient.
*/
import { Modal } from "@/shared/components";
import type { ImportProgress } from "../hooks/useModelImportHandlers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface ImportProgressModalProps {
importProgress: ImportProgress;
isOpen: boolean;
onClose: () => void;
t: ProviderMessageTranslator;
}
export default function ImportProgressModal({
importProgress,
isOpen,
onClose,
t,
}: ImportProgressModalProps) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={t("importingModelsTitle")}
size="md"
closeOnOverlay={false}
showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"}
>
<div className="flex flex-col gap-4">
{/* Status text */}
<div className="flex items-center gap-3">
{importProgress.phase === "fetching" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "importing" && (
<span className="material-symbols-outlined text-primary animate-spin">
progress_activity
</span>
)}
{importProgress.phase === "done" && (
<span className="material-symbols-outlined text-green-500">check_circle</span>
)}
{importProgress.phase === "error" && (
<span className="material-symbols-outlined text-red-500">error</span>
)}
<span className="text-sm font-medium text-text-main">{importProgress.status}</span>
</div>
{/* Progress bar */}
{(importProgress.phase === "importing" || importProgress.phase === "done") &&
importProgress.total > 0 && (
<div className="w-full">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-text-muted">
{importProgress.current} / {importProgress.total}
</span>
<span className="text-xs text-text-muted">
{Math.round((importProgress.current / importProgress.total) * 100)}%
</span>
</div>
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 ease-out"
style={{
width: `${(importProgress.current / importProgress.total) * 100}%`,
background:
importProgress.phase === "done"
? "linear-gradient(90deg, #22c55e, #16a34a)"
: "linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
</div>
)}
{/* Fetching indeterminate bar */}
{importProgress.phase === "fetching" && (
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full animate-pulse"
style={{
width: "60%",
background:
"linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
}}
/>
</div>
)}
{/* Error message */}
{importProgress.phase === "error" && importProgress.error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<p className="text-sm text-red-400">{importProgress.error}</p>
</div>
)}
{/* Log list */}
{importProgress.logs.length > 0 && (
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
<div className="flex flex-col gap-1">
{importProgress.logs.map((log, i) => (
<p
key={i}
className={`text-xs font-mono ${
typeof log === "string" && log.startsWith("✓")
? "text-green-500 font-semibold"
: "text-text-muted"
}`}
>
{log}
</p>
))}
</div>
</div>
)}
{/* Close button */}
{importProgress.phase === "done" && (
<div className="flex justify-center">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:opacity-90 transition-opacity"
>
{t("close")}
</button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,431 @@
"use client";
// Phase 1t.5 extraction — Issue #3501
// Pure composition of all modal elements rendered by ProviderDetailPageClient.
import { ConfirmModal, OAuthModal, KiroOAuthWrapper, CursorAuthModal, TraeAuthModal, ProxyConfigModal } from "@/shared/components";
import RiskNoticeModal from "../../components/RiskNoticeModal";
import CodexCliGuideModal from "../../components/CodexCliGuideModal";
import SiliconFlowEndpointModal from "./SiliconFlowEndpointModal";
import AddApiKeyModal from "./modals/AddApiKeyModal";
import EditConnectionModal from "./modals/EditConnectionModal";
import EditCompatibleNodeModal from "./modals/EditCompatibleNodeModal";
import ExternalLinkModal from "./ExternalLinkModal";
import BatchTestResultsModal from "./BatchTestResultsModal";
import ImportProgressModal from "./ImportProgressModal";
import { AdaptaTutorialModal } from "./AdaptaTutorialModal";
import {
ImportCodexAuthModal,
ApplyCodexAuthModal,
} from "./modals/ImportCodexAuthModal";
import {
ImportClaudeAuthModal,
ApplyClaudeAuthModal,
} from "./modals/ImportClaudeAuthModal";
import {
ImportGeminiAuthModal,
ApplyGeminiAuthModal,
} from "./modals/ImportGeminiAuthModal";
import { type ConnectionRowConnection } from "./ConnectionRow";
import { type BatchTestResults } from "../hooks/useProviderConnections";
import { type ImportProgress } from "../hooks/useModelImportHandlers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface ProviderInfo {
name: string;
riskNoticeVariant?: string;
[key: string]: unknown;
}
interface ProxyTarget {
level: string;
id: string;
label: string;
}
interface ProviderModalsPanelProps {
providerId: string;
providerInfo: ProviderInfo;
isCompatible: boolean;
isAnthropicProtocolCompatible: boolean;
isCcCompatible: boolean;
isCommandCode: boolean;
isUpstreamProxyProvider: boolean;
subscriptionRisk: boolean;
// Risk notice
showRiskNoticeModal: boolean;
handleConfirmRiskNotice: () => void;
handleCancelRiskNotice: () => void;
// OAuth
showOAuthModal: boolean;
reauthConnection: ConnectionRowConnection | null;
handleOAuthSuccess: () => void;
setShowOAuthModal: (show: boolean) => void;
// SiliconFlow
showSiliconFlowEndpointModal: boolean;
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
setShowSiliconFlowEndpointModal: (open: boolean) => void;
setShowAddApiKeyModal: (open: boolean) => void;
// AddApiKey
showAddApiKeyModal: boolean;
siliconFlowInitialBaseUrl: string | undefined;
commandCodeAuthState: { phase: string; [key: string]: unknown };
handleStartCommandCodeAuth: () => void;
handleSaveApiKey: (data: any) => Promise<void>;
handleCloseAddApiKeyModal: () => void;
// Batch delete confirm
batchDeleteConfirmOpen: boolean;
setBatchDeleteConfirmOpen: (open: boolean) => void;
handleBatchDeleteConfirm: () => void;
selectedIds: Set<string>;
batchDeleting: boolean;
// Codex auth
applyCodexModalConnectionId: string | null;
setApplyCodexModalConnectionId: (id: string | null) => void;
applyingCodexAuthId: string | null;
handleApplyCodexAuthLocal: (id: string) => Promise<void>;
importCodexModalOpen: boolean;
setImportCodexModalOpen: (open: boolean) => void;
fetchConnections: () => Promise<void>;
// External link
externalLinkModalOpen: boolean;
setExternalLinkModalOpen: (open: boolean) => void;
externalLinkLoading: boolean;
externalLinkError: string | null;
externalLinkUrl: string | null;
externalLinkCopied: boolean;
externalLinkCopy: () => void;
// Edit connection
showEditModal: boolean;
setShowEditModal: (open: boolean) => void;
selectedConnection: { id: string } | null;
handleUpdateConnection: (data: any) => Promise<string | null>;
// Edit compatible node
showEditNodeModal: boolean;
setShowEditNodeModal: (open: boolean) => void;
providerNode: any;
handleUpdateNode: (data: any) => Promise<void>;
// Codex CLI guide
codexCliGuideOpen: boolean;
setCodexCliGuideOpen: (open: boolean) => void;
// Claude auth
applyClaudeModalConnectionId: string | null;
setApplyClaudeModalConnectionId: (id: string | null) => void;
applyingClaudeAuthId: string | null;
handleApplyClaudeAuthLocal: (id: string) => Promise<void>;
importClaudeModalOpen: boolean;
setImportClaudeModalOpen: (open: boolean) => void;
// Gemini auth
applyGeminiModalConnectionId: string | null;
setApplyGeminiModalConnectionId: (id: string | null) => void;
applyingGeminiAuthId: string | null;
handleApplyGeminiAuthLocal: (id: string) => Promise<void>;
importGeminiModalOpen: boolean;
setImportGeminiModalOpen: (open: boolean) => void;
// Batch test results
batchTestResults: BatchTestResults | null;
setBatchTestResults: (r: BatchTestResults | null) => void;
emailsVisible: boolean;
// Proxy config
proxyTarget: ProxyTarget | null;
setProxyTarget: (t: ProxyTarget | null) => void;
fetchProxyConfig: () => Promise<void>;
// Import progress
importProgress: ImportProgress;
showImportModal: boolean;
setShowImportModal: (open: boolean) => void;
// Tutorial
showTutorialModal: boolean;
setShowTutorialModal: (open: boolean) => void;
t: ProviderMessageTranslator;
}
export default function ProviderModalsPanel({
providerId,
providerInfo,
isCompatible,
isAnthropicProtocolCompatible,
isCcCompatible,
isUpstreamProxyProvider,
subscriptionRisk,
showRiskNoticeModal,
handleConfirmRiskNotice,
handleCancelRiskNotice,
showOAuthModal,
reauthConnection,
handleOAuthSuccess,
setShowOAuthModal,
showSiliconFlowEndpointModal,
setSiliconFlowInitialBaseUrl,
setShowSiliconFlowEndpointModal,
setShowAddApiKeyModal,
showAddApiKeyModal,
siliconFlowInitialBaseUrl,
commandCodeAuthState,
handleStartCommandCodeAuth,
handleSaveApiKey,
handleCloseAddApiKeyModal,
isCommandCode,
batchDeleteConfirmOpen,
setBatchDeleteConfirmOpen,
handleBatchDeleteConfirm,
selectedIds,
batchDeleting,
applyCodexModalConnectionId,
setApplyCodexModalConnectionId,
applyingCodexAuthId,
handleApplyCodexAuthLocal,
importCodexModalOpen,
setImportCodexModalOpen,
fetchConnections,
externalLinkModalOpen,
setExternalLinkModalOpen,
externalLinkLoading,
externalLinkError,
externalLinkUrl,
externalLinkCopied,
externalLinkCopy,
showEditModal,
setShowEditModal,
selectedConnection,
handleUpdateConnection,
showEditNodeModal,
setShowEditNodeModal,
providerNode,
handleUpdateNode,
codexCliGuideOpen,
setCodexCliGuideOpen,
applyClaudeModalConnectionId,
setApplyClaudeModalConnectionId,
applyingClaudeAuthId,
handleApplyClaudeAuthLocal,
importClaudeModalOpen,
setImportClaudeModalOpen,
applyGeminiModalConnectionId,
setApplyGeminiModalConnectionId,
applyingGeminiAuthId,
handleApplyGeminiAuthLocal,
importGeminiModalOpen,
setImportGeminiModalOpen,
batchTestResults,
setBatchTestResults,
emailsVisible,
proxyTarget,
setProxyTarget,
fetchProxyConfig,
importProgress,
showImportModal,
setShowImportModal,
showTutorialModal,
setShowTutorialModal,
t,
}: ProviderModalsPanelProps) {
return (
<>
{showRiskNoticeModal && subscriptionRisk && (
<RiskNoticeModal
variant={(providerInfo.riskNoticeVariant as string) ?? "oauth"}
providerId={providerId}
providerName={providerInfo.name}
onConfirm={handleConfirmRiskNotice}
onCancel={handleCancelRiskNotice}
/>
)}
{!isUpstreamProxyProvider &&
(providerId === "kiro" || providerId === "amazon-q" ? (
<KiroOAuthWrapper
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
providerInfo={{ ...providerInfo, id: providerId }}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "cursor" ? (
<CursorAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : providerId === "trae" ? (
<TraeAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
) : (
<OAuthModal
isOpen={showOAuthModal}
reauthConnection={reauthConnection}
provider={providerId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
))}
{providerId === "siliconflow" && (
<SiliconFlowEndpointModal
isOpen={showSiliconFlowEndpointModal}
onSelect={(baseUrl) => {
setSiliconFlowInitialBaseUrl(baseUrl);
setShowSiliconFlowEndpointModal(false);
setShowAddApiKeyModal(true);
}}
onClose={() => {
setShowSiliconFlowEndpointModal(false);
setSiliconFlowInitialBaseUrl(undefined);
}}
/>
)}
{!isUpstreamProxyProvider && (
<AddApiKeyModal
isOpen={showAddApiKeyModal}
provider={providerId}
providerName={providerInfo.name}
initialBaseUrl={siliconFlowInitialBaseUrl}
isCompatible={isCompatible}
isAnthropic={isAnthropicProtocolCompatible}
isCcCompatible={isCcCompatible}
isCommandCode={isCommandCode}
commandCodeAuthState={commandCodeAuthState}
onStartCommandCodeAuth={handleStartCommandCodeAuth}
onSave={handleSaveApiKey}
onClose={handleCloseAddApiKeyModal}
/>
)}
<ConfirmModal
isOpen={batchDeleteConfirmOpen}
onClose={() => setBatchDeleteConfirmOpen(false)}
onConfirm={handleBatchDeleteConfirm}
title={t("batchDeleteConfirmTitle", "Delete connections")}
message={t("batchDeleteConfirm", { count: selectedIds.size })}
confirmText={t("batchDeleteConfirmButton", "Delete")}
cancelText={t("cancel", "Cancel")}
loading={batchDeleting}
/>
{providerId === "codex" && applyCodexModalConnectionId && (
<ApplyCodexAuthModal
key={applyCodexModalConnectionId}
connectionId={applyCodexModalConnectionId}
inProgress={!!applyingCodexAuthId}
onConfirm={handleApplyCodexAuthLocal}
onClose={() => setApplyCodexModalConnectionId(null)}
/>
)}
{!isUpstreamProxyProvider && (
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
)}
{!isUpstreamProxyProvider && isCompatible && (
<EditCompatibleNodeModal
isOpen={showEditNodeModal}
node={providerNode}
onSave={handleUpdateNode}
onClose={() => setShowEditNodeModal(false)}
isAnthropic={isAnthropicProtocolCompatible}
isCcCompatible={isCcCompatible}
/>
)}
<CodexCliGuideModal isOpen={codexCliGuideOpen} onClose={() => setCodexCliGuideOpen(false)} />
{providerId === "codex" && importCodexModalOpen && (
<ImportCodexAuthModal
key="import-codex-modal"
onClose={() => setImportCodexModalOpen(false)}
onSuccess={() => {
setImportCodexModalOpen(false);
void fetchConnections();
}}
/>
)}
{providerId === "codex" && externalLinkModalOpen && (
<ExternalLinkModal
isOpen={externalLinkModalOpen}
onClose={() => setExternalLinkModalOpen(false)}
loading={externalLinkLoading}
error={externalLinkError}
url={externalLinkUrl}
copied={externalLinkCopied}
onCopy={externalLinkCopy}
/>
)}
{providerId === "claude" && applyClaudeModalConnectionId && (
<ApplyClaudeAuthModal
key={applyClaudeModalConnectionId}
connectionId={applyClaudeModalConnectionId}
inProgress={!!applyingClaudeAuthId}
onConfirm={handleApplyClaudeAuthLocal}
onClose={() => setApplyClaudeModalConnectionId(null)}
/>
)}
{providerId === "claude" && importClaudeModalOpen && (
<ImportClaudeAuthModal
key="import-claude-modal"
onClose={() => setImportClaudeModalOpen(false)}
onSuccess={() => {
setImportClaudeModalOpen(false);
void fetchConnections();
}}
/>
)}
{providerId === "gemini-cli" && applyGeminiModalConnectionId && (
<ApplyGeminiAuthModal
key={applyGeminiModalConnectionId}
connectionId={applyGeminiModalConnectionId}
inProgress={!!applyingGeminiAuthId}
onConfirm={handleApplyGeminiAuthLocal}
onClose={() => setApplyGeminiModalConnectionId(null)}
/>
)}
{providerId === "gemini-cli" && importGeminiModalOpen && (
<ImportGeminiAuthModal
key="import-gemini-modal"
onClose={() => setImportGeminiModalOpen(false)}
onSuccess={() => {
setImportGeminiModalOpen(false);
void fetchConnections();
}}
/>
)}
<BatchTestResultsModal
batchTestResults={batchTestResults}
providerInfo={providerInfo}
providerId={providerId}
emailsVisible={emailsVisible}
onClose={() => setBatchTestResults(null)}
t={t}
/>
{proxyTarget && (
<ProxyConfigModal
isOpen={!!proxyTarget}
onClose={() => setProxyTarget(null)}
level={proxyTarget.level}
levelId={proxyTarget.id}
levelLabel={proxyTarget.label}
onSaved={() => {
void fetchProxyConfig();
}}
/>
)}
<ImportProgressModal
importProgress={importProgress}
isOpen={showImportModal}
onClose={() => {
if (importProgress.phase === "done" || importProgress.phase === "error") {
setShowImportModal(false);
}
}}
t={t}
/>
{providerId === "adapta-web" && (
<AdaptaTutorialModal
isOpen={showTutorialModal}
onClose={() => setShowTutorialModal(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,466 @@
"use client";
/**
* ProviderModelsSection — Issue #3501 Phase 1m
*
* Extracted from the renderModelsSection() inline function in
* ProviderDetailPageClient. Receives all model/compat state + handlers
* as props (from useModelImportHandlers, useModelVisibilityHandlers,
* useModelCompatState, useProviderModels).
*
* Cycle-safe: no import from ProviderDetailPageClient.
*/
import { Button } from "@/shared/components";
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
import ModelRow, { ModelVisibilityToolbar } from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection";
import CompatibleModelsSection from "./CompatibleModelsSection";
import type { ModelCompatSavePatch } from "../hooks/useModelVisibilityHandlers";
export interface ProviderModelsSectionProps {
// Provider identity
providerId: string;
providerAlias: string;
providerStorageAlias: string;
providerDisplayAlias: string;
providerInfo: {
name?: string;
passthroughModels?: boolean;
} | null;
// Provider-type flags
isCcCompatible: boolean;
isAnthropicCompatible: boolean;
isAnthropicProtocolCompatible: boolean;
isManagedAvailableModelsProvider: boolean;
compatibleSupportsModelImport: boolean;
// Models data
models: Array<{ id: string; name?: string; source?: string }>;
modelMeta: { customModels: any[]; modelCompatOverrides?: any[] };
modelAliases: Record<string, string>;
syncedAvailableModels: any[];
compatibleFallbackModels: any[];
// Clipboard
copied: string | null;
onCopy: (text: string) => void;
// Model alias handlers
onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
onDeleteAlias: (alias: string) => Promise<void>;
fetchProviderModelMeta: () => Promise<void>;
// Connections
connections: any[];
selectedConnection: any;
// Phase 1k: import handlers
canImportModels: boolean;
importingModels: boolean;
handleImportModels: () => Promise<void>;
isAutoSyncEnabled: boolean;
togglingAutoSync: boolean;
handleToggleAutoSync: () => Promise<void>;
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
// Phase 1l: visibility handlers
compatSavingModelId: string | null;
togglingModelId: string | null;
bulkVisibilityAction: "select" | "deselect" | null;
clearingModels: boolean;
modelFilter: string;
testingModelId: string | null;
modelTestStatus: Record<string, "ok" | "error">;
testingAll: boolean;
testProgress: { done: number; total: number } | null;
autoHideFailed: boolean;
visibilityFilter: "all" | "visible" | "hidden";
providerAliasEntries: [string, string][];
setModelFilter: (v: string) => void;
setAutoHideFailed: (v: boolean) => void;
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
handleToggleModelHidden: (
providerKey: string,
modelId: string,
hidden: boolean
) => Promise<void>;
handleBulkToggleModelHidden: (
providerKey: string,
modelIds: string[],
hidden: boolean
) => Promise<void>;
handleClearAllModels: () => Promise<void>;
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
// Compat state (from useModelCompatState)
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
effectiveModelHidden: (modelId: string) => boolean;
getUpstreamHeadersRecordForModel: (modelId: string, protocol: string) => Record<string, string>;
// Translation
t: ProviderMessageTranslator;
}
export default function ProviderModelsSection({
providerId,
providerAlias,
providerStorageAlias,
providerDisplayAlias,
providerInfo,
isCcCompatible,
isAnthropicCompatible,
isAnthropicProtocolCompatible,
isManagedAvailableModelsProvider,
compatibleSupportsModelImport,
models,
modelMeta,
modelAliases,
syncedAvailableModels,
compatibleFallbackModels,
copied,
onCopy,
onSetAlias,
onDeleteAlias,
fetchProviderModelMeta,
connections,
selectedConnection,
canImportModels,
importingModels,
handleImportModels,
isAutoSyncEnabled,
togglingAutoSync,
handleToggleAutoSync,
handleCompatibleImportWithProgress,
compatSavingModelId,
togglingModelId,
bulkVisibilityAction,
clearingModels,
modelFilter,
testingModelId,
modelTestStatus,
testingAll,
testProgress,
autoHideFailed,
visibilityFilter,
providerAliasEntries,
setModelFilter,
setAutoHideFailed,
setVisibilityFilter,
saveModelCompatFlags,
handleToggleModelHidden,
handleBulkToggleModelHidden,
handleClearAllModels,
onTestModel,
handleTestAll,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
effectiveModelHidden,
getUpstreamHeadersRecordForModel,
t,
}: ProviderModelsSectionProps) {
const autoSyncToggle = compatibleSupportsModelImport && canImportModels && (
<button
onClick={handleToggleAutoSync}
disabled={togglingAutoSync}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-border bg-transparent cursor-pointer text-[12px] disabled:opacity-50 disabled:cursor-not-allowed"
title={t("autoSyncTooltip")}
>
<span
className="material-symbols-outlined text-[16px]"
style={{ color: isAutoSyncEnabled ? "#22c55e" : "var(--color-text-muted)" }}
>
{isAutoSyncEnabled ? "toggle_on" : "toggle_off"}
</span>
<span className="text-text-main">{t("autoSync")}</span>
</button>
);
const clearAllButton = (modelMeta.customModels.length > 0 ||
providerAliasEntries.length > 0) && (
<button
onClick={handleClearAllModels}
disabled={clearingModels}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-red-300 dark:border-red-800 bg-transparent cursor-pointer text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
title={t("clearAllModels")}
>
<span className="material-symbols-outlined text-[16px]">delete_sweep</span>
<span>{t("clearAllModels")}</span>
</button>
);
if (isManagedAvailableModelsProvider) {
const description =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: isCcCompatible
? t("ccCompatibleModelsDescription")
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const inputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: isCcCompatible
? "claude-sonnet-4-6"
: isAnthropicCompatible
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
<div className="flex items-center gap-2 mb-4">
{autoSyncToggle}
{clearAllButton}
</div>
<CompatibleModelsSection
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
fallbackModels={compatibleFallbackModels}
description={description}
inputLabel={inputLabel}
inputPlaceholder={inputPlaceholder}
copied={copied}
onCopy={onCopy}
onSetAlias={onSetAlias}
onDeleteAlias={onDeleteAlias}
connections={connections}
isAnthropic={isAnthropicProtocolCompatible}
onImportWithProgress={handleCompatibleImportWithProgress}
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
onModelsChanged={fetchProviderModelMeta}
allowImport={compatibleSupportsModelImport}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
onTestAll={handleTestAll}
testingAll={testingAll}
testProgress={testProgress}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
</div>
);
}
if (providerInfo?.passthroughModels) {
const passthroughDescription =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: providerId === "bedrock"
? t("bedrockModelsDescription")
: t("passthroughModelsDescription", { provider: providerInfo?.name || providerId });
const passthroughInputLabel =
providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const passthroughInputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: providerId === "bedrock"
? t("bedrockModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImportModels}
disabled={!canImportModels || importingModels}
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{clearAllButton}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
description={passthroughDescription}
inputLabel={passthroughInputLabel}
inputPlaceholder={passthroughInputPlaceholder}
copied={copied}
onCopy={onCopy}
onSetAlias={onSetAlias}
onDeleteAlias={onDeleteAlias}
t={t}
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
saveModelCompatFlags={saveModelCompatFlags}
compatSavingModelId={compatSavingModelId}
isModelHidden={effectiveModelHidden}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
}
onBulkToggleHidden={(modelIds, hidden) =>
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
providerId={providerId}
connectionId={selectedConnection?.id ?? ""}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
</div>
);
}
const importButton = (
<div className="flex items-center gap-2 mb-4">
<Button
size="sm"
variant="secondary"
icon="download"
onClick={handleImportModels}
disabled={!canImportModels || importingModels}
>
{importingModels ? t("importingModels") : t("importFromModels")}
</Button>
{autoSyncToggle}
{!canImportModels && (
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
)}
</div>
);
if (models.length === 0) {
return (
<div>
{importButton}
<p className="text-sm text-text-muted">{t("noModelsConfigured")}</p>
</div>
);
}
const modelsWithVisibility = models.map((model) => ({
...model,
isHidden: effectiveModelHidden(model.id),
}));
const filteredModels = modelsWithVisibility.filter((model) => {
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
modelId: model.id,
modelName: model.name,
source: model.source,
});
const matchesVisibility =
visibilityFilter === "all"
? true
: visibilityFilter === "visible"
? !model.isHidden
: model.isHidden;
return matchesQuery && matchesVisibility;
});
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
const testAllTargets = filteredModels
.filter((m) => !m.isHidden)
.map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` }));
return (
<div>
{importButton}
{modelsWithVisibility.length > 0 && (
<ModelVisibilityToolbar
t={t}
filterValue={modelFilter}
onFilterChange={setModelFilter}
activeCount={activeCount}
totalCount={modelsWithVisibility.length}
onSelectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
false
)
}
onDeselectAll={() =>
handleBulkToggleModelHidden(
providerId,
filteredModels.map((model) => model.id),
true
)
}
selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null}
deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null}
onTestAll={() => handleTestAll(testAllTargets)}
testingAll={testingAll}
testProgress={testProgress}
visibilityFilter={visibilityFilter}
onVisibilityFilterChange={setVisibilityFilter}
autoHideFailed={autoHideFailed}
onAutoHideFailedChange={setAutoHideFailed}
/>
)}
<div className="flex flex-wrap gap-3">
{filteredModels.map((model) => {
return (
<ModelRow
key={model.id}
model={model}
fullModel={`${providerDisplayAlias}/${model.id}`}
provider={providerId}
copied={copied}
onCopy={onCopy}
t={t}
showDeveloperToggle
effectiveModelNormalize={effectiveModelNormalize}
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
saveModelCompatFlags={saveModelCompatFlags}
compatDisabled={compatSavingModelId === model.id}
onToggleHidden={(modelId, hidden) =>
handleToggleModelHidden(providerId, modelId, hidden)
}
togglingHidden={togglingModelId === model.id}
onTestModel={onTestModel}
testStatus={modelTestStatus[model.id] || null}
testingModel={testingModelId === model.id}
/>
);
})}
{filteredModels.length === 0 && modelFilter && (
<p className="text-sm text-text-muted py-2">
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
filter: modelFilter,
})}
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
// Phase 1t.1 extraction — Issue #3501
import Link from "next/link";
import ProviderIcon from "@/shared/components/ProviderIcon";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import { getHeaderIconProviderId } from "../providerPageHelpers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface ProviderInfo {
id: string;
name: string;
website?: string;
color: string;
apiType?: string;
}
interface ProviderPageHeaderProps {
providerId: string;
providerInfo: ProviderInfo;
connectionsCount: number;
isOpenAICompatible: boolean;
isAnthropicProtocolCompatible: boolean;
onOpenTutorial: () => void;
t: ProviderMessageTranslator;
}
export default function ProviderPageHeader({
providerId,
providerInfo,
connectionsCount,
isOpenAICompatible,
isAnthropicProtocolCompatible,
onOpenTutorial,
t,
}: ProviderPageHeaderProps) {
return (
<div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
{t("backToProviders")}
</Link>
<div className="flex items-center gap-4">
<div
className="rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${providerInfo.color}15` }}
>
<ProviderIcon
providerId={getHeaderIconProviderId(
isOpenAICompatible,
isAnthropicProtocolCompatible,
providerInfo.id,
providerInfo.apiType
)}
size={48}
type="color"
/>
</div>
<div>
{providerInfo.website ? (
<a
href={providerInfo.website}
target="_blank"
rel="noopener noreferrer"
className="text-3xl font-semibold tracking-tight hover:underline inline-flex items-center gap-2"
style={{ color: providerInfo.color }}
>
{providerInfo.name}
<span className="material-symbols-outlined text-lg opacity-60">open_in_new</span>
</a>
) : (
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
)}
<div className="flex items-center gap-2">
<p className="text-text-muted">
{t("connectionCountLabel", { count: connectionsCount })}
</p>
<EmailPrivacyToggle size="md" />
{providerId === "adapta-web" && (
<button
onClick={onOpenTutorial}
className="text-sm font-medium underline underline-offset-2 opacity-70 hover:opacity-100 transition-opacity"
style={{ color: providerInfo.color }}
>
Tutorial
</button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
// Phase 1g extraction — Issue #3501
// Renders a playground section on the individual provider page.
// Shows ServiceKindTabs if the provider declares multiple kinds; falls back to
// a single-kind panel or the LlmChatCard for standard LLM providers.
import { useState } from "react";
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
import { ImageExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard";
import { TtsExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard";
import { SttExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/SttExampleCard";
import { WebSearchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebSearchExampleCard";
import { WebFetchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard";
import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard";
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
import type { ServiceKind } from "@/shared/constants/providers";
import { AI_PROVIDERS } from "@/shared/constants/providers";
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
"embedding",
"image",
"tts",
"stt",
"webSearch",
"webFetch",
"video",
"music",
];
export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Element | null {
switch (kind) {
case "llm":
return <LlmChatCard providerId={providerId} />;
case "embedding":
return <EmbeddingExampleCard providerId={providerId} />;
case "image":
return <ImageExampleCard providerId={providerId} />;
case "tts":
return <TtsExampleCard providerId={providerId} />;
case "stt":
return <SttExampleCard providerId={providerId} />;
case "webSearch":
return <WebSearchExampleCard providerId={providerId} />;
case "webFetch":
return <WebFetchExampleCard providerId={providerId} />;
case "video":
return <VideoExampleCard providerId={providerId} />;
case "music":
return <MusicExampleCard providerId={providerId} />;
default:
return null;
}
}
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
// Resolve serviceKinds from AI_PROVIDERS.
// For providers without explicit serviceKinds (most LLM providers), we infer
// "llm" as the default.
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
| (Record<string, unknown> & { serviceKinds?: string[] })
| undefined;
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
const ALL_VALID_KINDS = [
"llm",
"embedding",
"image",
"imageToText",
"tts",
"stt",
"webSearch",
"webFetch",
"video",
"music",
] as const;
const kinds: ServiceKind[] =
rawKinds.length > 0
? rawKinds.filter((k): k is ServiceKind => (ALL_VALID_KINDS as readonly string[]).includes(k))
: ["llm"];
// Filter out kinds that have no playground implementation yet
const playgroundableKinds = kinds.filter((k) => k !== "imageToText");
// useState must be called unconditionally (Rules of Hooks)
const [activeKind, setActiveKind] = useState<ServiceKind>(playgroundableKinds[0] ?? "llm");
if (playgroundableKinds.length === 0) return null;
return (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Playground</h2>
<ServiceKindTabs
kinds={playgroundableKinds}
activeKind={activeKind}
onSelect={setActiveKind}
/>
{renderKindPanel(activeKind, providerId)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
// Phase 1t.7 extraction — Issue #3501
import { Card } from "@/shared/components";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface SearchProviderCardProps {
providerId: string;
t: ProviderMessageTranslator;
}
export default function SearchProviderCard({ providerId, t }: SearchProviderCardProps) {
return (
<Card>
<h2 className="text-lg font-semibold mb-4">{t("searchProvider")}</h2>
<p className="text-sm text-text-muted">{t("searchProviderDesc")}</p>
{providerId === "perplexity-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
<span className="material-symbols-outlined text-sm text-blue-400">link</span>
<p className="text-xs text-blue-300">{t("perplexitySearchSharedKeyInfo")}</p>
</div>
)}
{providerId === "google-pse-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<span className="material-symbols-outlined text-sm text-amber-300">tune</span>
<p className="text-xs text-amber-200">{t("googlePseInfo")}</p>
</div>
)}
{providerId === "searxng-search" && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
<span className="material-symbols-outlined text-sm text-emerald-300">dns</span>
<p className="text-xs text-emerald-200">{t("searxngInfo")}</p>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
// Phase 1t.7 extraction — Issue #3501
import Link from "next/link";
import { Card } from "@/shared/components";
import { providerText } from "../providerPageHelpers";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface UpstreamProxyCardProps {
t: ProviderMessageTranslator;
}
export default function UpstreamProxyCard({ t }: UpstreamProxyCardProps) {
return (
<Card>
<div className="flex flex-col gap-3">
<div>
<h2 className="text-lg font-semibold">
{providerText(t, "upstreamProxyManagedTitle", "Managed via Upstream Proxy Settings")}
</h2>
<p className="text-sm text-text-muted mt-1">
{providerText(
t,
"upstreamProxyManagedDescription",
"CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls."
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href="/dashboard/cli-code"
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">terminal</span>
{t("openCliTools")}
</Link>
<Link
href="/dashboard/settings"
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">settings</span>
{t("openSettings")}
</Link>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import { useState, useCallback } from "react";
import { Button, Card } from "@/shared/components";
type ZedImportCardProps = {
fetchConnections: () => Promise<void>;
notify: { success: (msg: string) => void; error: (msg: string) => void; info: (msg: string) => void };
};
export default function ZedImportCard({ fetchConnections, notify }: ZedImportCardProps) {
const [importingZed, setImportingZed] = useState(false);
const [showZedManual, setShowZedManual] = useState(false);
const [zedManualProvider, setZedManualProvider] = useState("openai");
const [zedManualToken, setZedManualToken] = useState("");
const [importingZedManual, setImportingZedManual] = useState(false);
const handleZedImport = useCallback(async () => {
if (importingZed) return;
setImportingZed(true);
try {
const res = await fetch("/api/providers/zed/import", { method: "POST" });
const data = await res.json();
if (!res.ok || !data.success) {
if (data.zedDockerEnvironment) {
setShowZedManual(true);
}
notify.error(data.error || "Zed import failed");
} else if (!data.count) {
const found = data.credentials?.length ?? 0;
if (found === 0) {
notify.info("No Zed credentials found in keychain");
} else {
notify.info(
`Found ${found} keychain credential(s), but none matched supported providers`
);
}
} else {
notify.success(
`Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)`
);
await fetchConnections();
}
} catch (e: any) {
notify.error(e?.message || "Zed import failed");
} finally {
setImportingZed(false);
}
}, [importingZed, notify, fetchConnections]);
const handleZedManualImport = useCallback(async () => {
if (importingZedManual || !zedManualToken.trim()) return;
setImportingZedManual(true);
try {
const res = await fetch("/api/providers/zed/manual-import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }),
});
const data = await res.json();
if (!res.ok || !data.success) {
notify.error(data.error?.message ?? data.error ?? "Manual import failed");
} else {
notify.success(`Imported ${zedManualProvider} token from Zed`);
setZedManualToken("");
await fetchConnections();
}
} catch (e: any) {
notify.error(e?.message || "Manual import failed");
} finally {
setImportingZedManual(false);
}
}, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]);
return (
<>
<Card>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">download</span>
Import from Zed Keychain
</h2>
<p className="text-sm text-text-muted mt-1">
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
installed on this machine.
</p>
</div>
<Button
size="sm"
variant="secondary"
icon={importingZed ? "sync" : "download"}
onClick={handleZedImport}
disabled={importingZed}
>
{importingZed ? "Importing…" : "Import from Zed"}
</Button>
</div>
</Card>
<Card>
<div className="flex flex-col gap-3">
<button
className="flex items-center justify-between w-full text-left"
onClick={() => setShowZedManual((v) => !v)}
>
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">edit</span>
Manual Token Import
</h2>
<span className="material-symbols-outlined text-[18px] text-text-muted">
{showZedManual ? "expand_less" : "expand_more"}
</span>
</button>
{showZedManual && (
<div className="flex flex-col gap-3 mt-1">
<p className="text-sm text-text-muted">
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
API key that Zed stored under{" "}
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
it from the Zed AI settings panel.
</p>
<div className="flex gap-2 flex-col sm:flex-row">
<select
className="input input-sm"
value={zedManualProvider}
onChange={(e) => setZedManualProvider(e.target.value)}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="google">Google</option>
<option value="mistral">Mistral</option>
<option value="xai">xAI</option>
<option value="openrouter">OpenRouter</option>
<option value="deepseek">DeepSeek</option>
</select>
<input
type="password"
className="input input-sm flex-1"
placeholder="Paste API key…"
value={zedManualToken}
onChange={(e) => setZedManualToken(e.target.value)}
/>
<Button
size="sm"
variant="secondary"
icon={importingZedManual ? "sync" : "upload"}
onClick={handleZedManualImport}
disabled={importingZedManual || !zedManualToken.trim()}
>
{importingZedManual ? "Saving…" : "Import"}
</Button>
</div>
</div>
)}
</div>
</Card>
</>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
/**
* useApiKeySave — Issue #3501 Phase 1s
*
* Owns the handleSaveApiKey async function that was previously inline in
* ProviderDetailPageClient. Extracts it into a custom hook so the client
* can simply destructure the callback.
*
* Cycle-safe: no import from ProviderDetailPageClient.
*/
import { useCallback } from "react";
import type React from "react";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
import type { ImportProgress } from "./useModelImportHandlers";
type UseApiKeySaveParams = {
providerId: string;
fetchConnections: () => Promise<void>;
fetchProviderModelMeta: () => Promise<void>;
setImportProgress: React.Dispatch<React.SetStateAction<ImportProgress>>;
setShowImportModal: (open: boolean) => void;
setShowAddApiKeyModal: (open: boolean) => void;
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
notify: { success: (msg: string) => void; error: (msg: string) => void; info?: (msg: string) => void };
t: ProviderMessageTranslator;
};
export function useApiKeySave({
providerId,
fetchConnections,
fetchProviderModelMeta,
setImportProgress,
setShowImportModal,
setShowAddApiKeyModal,
setSiliconFlowInitialBaseUrl,
t,
}: UseApiKeySaveParams) {
const handleSaveApiKey = useCallback(
async (formData: Record<string, unknown>) => {
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
});
if (res.ok) {
const connectionData = await res.json();
const newConnection = connectionData?.connection;
await fetchConnections();
setShowAddApiKeyModal(false);
setSiliconFlowInitialBaseUrl(undefined);
// Universal: sync models from the provider endpoint on every new connection
// (was previously Gemini-only). Do NOT re-introduce a providerId guard here.
if (newConnection?.id) {
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: t("fetchingModels"),
logs: [],
error: "",
importedCount: 0,
});
try {
const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, {
method: "POST",
signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang
});
const syncData = await syncRes.json();
if (!syncRes.ok || syncData.error) {
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("failedFetchModels"),
error: syncData.error?.message || syncData.error || t("failedImportModels"),
}));
return null;
}
const syncedCount = syncData.syncedModels || 0;
const availableCount =
typeof syncData.availableModelsCount === "number"
? syncData.availableModelsCount
: Array.isArray(syncData.models)
? syncData.models.length
: syncedCount;
const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || [];
const logs: string[] = [];
if (syncedModelList.length > 0) {
logs.push(`${availableCount} models available`);
logs.push("");
for (const m of syncedModelList) {
logs.push(` ${m.name || m.id}`);
}
}
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("modelsImported", { count: availableCount }),
total: availableCount,
current: availableCount,
importedCount: availableCount,
logs,
}));
await fetchProviderModelMeta();
} catch (syncError) {
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("failedFetchModels"),
error: String(syncError),
}));
}
}
return null;
}
const data = await res.json().catch(() => ({}));
const errorMsg = data.error?.message || data.error || t("failedSaveConnection");
return errorMsg;
} catch (error) {
console.log("Error saving connection:", error);
return t("failedSaveConnectionRetry");
}
},
[
providerId,
fetchConnections,
fetchProviderModelMeta,
setImportProgress,
setShowImportModal,
setShowAddApiKeyModal,
setSiliconFlowInitialBaseUrl,
t,
]
);
return { handleSaveApiKey };
}

View File

@@ -0,0 +1,300 @@
"use client";
// Phase 1j extraction — Issue #3501
// Manages Codex / Claude / Gemini auth-file apply+export state and handlers.
import { useState } from "react";
type Notify = { success: (msg: string) => void; error: (msg: string) => void };
type UseAuthFileHandlersParams = {
parseApiErrorMessage: (res: Response, fallback: string) => Promise<string>;
getAttachmentFilename: (res: Response, fallback: string) => string;
notify: Notify;
t: (key: string) => string;
};
export function useAuthFileHandlers({
parseApiErrorMessage,
getAttachmentFilename,
notify,
t,
}: UseAuthFileHandlersParams) {
// ── Codex ──────────────────────────────────────────────────────────────────
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState<string | null>(
null
);
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
// ── Claude ─────────────────────────────────────────────────────────────────
const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState<string | null>(null);
const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState<string | null>(
null
);
const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState<string | null>(null);
// ── Gemini ─────────────────────────────────────────────────────────────────
const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState<string | null>(null);
const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState<string | null>(
null
);
const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState<string | null>(null);
// ── Handlers ───────────────────────────────────────────────────────────────
const handleApplyCodexAuthLocal = async (connectionId: string) => {
if (applyingCodexAuthId) return;
setApplyingCodexAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("codexAuthAppliedLocal")
? t("codexAuthAppliedLocal")
: "Codex auth.json applied locally";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("codexAuthApplyFailed")
? t("codexAuthApplyFailed")
: "Failed to apply Codex auth.json locally";
try {
const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
notify.success(defaultSuccess);
setApplyCodexModalConnectionId(null);
} catch (error) {
console.error("Error applying Codex auth locally:", error);
notify.error(defaultError);
} finally {
setApplyingCodexAuthId(null);
}
};
const handleExportCodexAuthFile = async (connectionId: string) => {
if (exportingCodexAuthId) return;
setExportingCodexAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("codexAuthExported")
? t("codexAuthExported")
: "Codex auth.json exported";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("codexAuthExportFailed")
? t("codexAuthExportFailed")
: "Failed to export Codex auth.json";
try {
const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
const blob = await res.blob();
const filename = getAttachmentFilename(res, "codex-auth.json");
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
notify.success(defaultSuccess);
} catch (error) {
console.error("Error exporting Codex auth file:", error);
notify.error(defaultError);
} finally {
setExportingCodexAuthId(null);
}
};
const handleApplyClaudeAuthLocal = async (connectionId: string) => {
if (applyingClaudeAuthId) return;
setApplyingClaudeAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("claudeAuthAppliedLocal")
? t("claudeAuthAppliedLocal")
: "Claude auth applied locally";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("claudeAuthApplyFailed")
? t("claudeAuthApplyFailed")
: "Failed to apply Claude auth locally";
try {
const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
notify.success(defaultSuccess);
setApplyClaudeModalConnectionId(null);
} catch (error) {
console.error("Error applying Claude auth locally:", error);
notify.error(defaultError);
} finally {
setApplyingClaudeAuthId(null);
}
};
const handleExportClaudeAuthFile = async (connectionId: string) => {
if (exportingClaudeAuthId) return;
setExportingClaudeAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("claudeAuthExported")
? t("claudeAuthExported")
: "Claude auth file exported";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("claudeAuthExportFailed")
? t("claudeAuthExportFailed")
: "Failed to export Claude auth file";
try {
const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
const blob = await res.blob();
const filename = getAttachmentFilename(res, "claude-auth.json");
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
notify.success(defaultSuccess);
} catch (error) {
console.error("Error exporting Claude auth file:", error);
notify.error(defaultError);
} finally {
setExportingClaudeAuthId(null);
}
};
const handleApplyGeminiAuthLocal = async (connectionId: string) => {
if (applyingGeminiAuthId) return;
setApplyingGeminiAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("geminiAuthAppliedLocal")
? t("geminiAuthAppliedLocal")
: "Gemini auth applied locally";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("geminiAuthApplyFailed")
? t("geminiAuthApplyFailed")
: "Failed to apply Gemini auth locally";
try {
const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
notify.success(defaultSuccess);
setApplyGeminiModalConnectionId(null);
} catch (error) {
console.error("Error applying Gemini auth locally:", error);
notify.error(defaultError);
} finally {
setApplyingGeminiAuthId(null);
}
};
const handleExportGeminiAuthFile = async (connectionId: string) => {
if (exportingGeminiAuthId) return;
setExportingGeminiAuthId(connectionId);
const defaultSuccess =
typeof (t as any).has === "function" && (t as any).has("geminiAuthExported")
? t("geminiAuthExported")
: "Gemini auth file exported";
const defaultError =
typeof (t as any).has === "function" && (t as any).has("geminiAuthExportFailed")
? t("geminiAuthExportFailed")
: "Failed to export Gemini auth file";
try {
const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, {
method: "POST",
});
if (!res.ok) {
notify.error(await parseApiErrorMessage(res, defaultError));
return;
}
const blob = await res.blob();
const filename = getAttachmentFilename(res, "gemini-auth.json");
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
notify.success(defaultSuccess);
} catch (error) {
console.error("Error exporting Gemini auth file:", error);
notify.error(defaultError);
} finally {
setExportingGeminiAuthId(null);
}
};
return {
// Codex
applyingCodexAuthId,
applyCodexModalConnectionId,
setApplyCodexModalConnectionId,
exportingCodexAuthId,
handleApplyCodexAuthLocal,
handleExportCodexAuthFile,
// Claude
applyingClaudeAuthId,
applyClaudeModalConnectionId,
setApplyClaudeModalConnectionId,
exportingClaudeAuthId,
handleApplyClaudeAuthLocal,
handleExportClaudeAuthFile,
// Gemini
applyingGeminiAuthId,
applyGeminiModalConnectionId,
setApplyGeminiModalConnectionId,
exportingGeminiAuthId,
handleApplyGeminiAuthLocal,
handleExportGeminiAuthFile,
};
}

View File

@@ -0,0 +1,290 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
export type UseCommandCodeAuthParams = {
providerId: string;
fetchConnections: () => Promise<void> | void;
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
setShowAddApiKeyModal: (show: boolean) => void;
notify: { success: (msg: string) => void; error: (msg: string) => void };
};
export function useCommandCodeAuth({
fetchConnections,
setSiliconFlowInitialBaseUrl,
setShowAddApiKeyModal,
notify,
}: UseCommandCodeAuthParams) {
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
phase: "idle",
state: "",
authUrl: "",
callbackUrl: "",
expiresAt: null,
message: "",
});
const commandCodeAuthWindowRef = useRef<Window | null>(null);
const commandCodeAuthTimerRef = useRef<number | null>(null);
const clearCommandCodeAuthTimer = useCallback(() => {
if (commandCodeAuthTimerRef.current !== null) {
window.clearTimeout(commandCodeAuthTimerRef.current);
commandCodeAuthTimerRef.current = null;
}
}, []);
useEffect(() => {
return () => {
clearCommandCodeAuthTimer();
commandCodeAuthWindowRef.current?.close?.();
};
}, [clearCommandCodeAuthTimer]);
const handleCloseAddApiKeyModal = useCallback(() => {
clearCommandCodeAuthTimer();
setSiliconFlowInitialBaseUrl(undefined);
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
setCommandCodeAuthState({
phase: "idle",
state: "",
authUrl: "",
callbackUrl: "",
expiresAt: null,
message: "",
});
setShowAddApiKeyModal(false);
}, [clearCommandCodeAuthTimer, setSiliconFlowInitialBaseUrl, setShowAddApiKeyModal]);
const handleCommandCodeAuthApply = useCallback(
async (state: string, connectionId?: string, name?: string, setDefault?: boolean) => {
setCommandCodeAuthState((current) => ({
...current,
phase: "applying",
message: "Applying browser-approved key…",
}));
try {
const res = await fetch("/api/providers/command-code/auth/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ state, connectionId, name, setDefault }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.error || "Failed to apply Command Code auth";
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: errorMessage,
}));
notify.error(errorMessage);
return false;
}
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: "Command Code connected",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success("Command Code connection added");
return true;
} catch (error) {
console.error("Error applying Command Code auth:", error);
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Failed to apply Command Code auth",
}));
notify.error("Failed to apply Command Code auth");
return false;
}
},
[fetchConnections, handleCloseAddApiKeyModal, notify]
);
const handleStartCommandCodeAuth = useCallback(async () => {
if (commandCodeAuthState.phase === "starting" || commandCodeAuthState.phase === "polling") {
return;
}
clearCommandCodeAuthTimer();
commandCodeAuthWindowRef.current?.close?.();
const popup = window.open("about:blank", "_blank");
setCommandCodeAuthState({
phase: "starting",
state: "",
authUrl: "",
callbackUrl: "",
expiresAt: null,
message: "Opening Command Code Studio…",
});
try {
const res = await fetch("/api/providers/command-code/auth/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.state || !data.authUrl) {
const errorMessage = data.error || "Failed to start Command Code auth";
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: errorMessage,
}));
notify.error(errorMessage);
popup?.close?.();
return;
}
setCommandCodeAuthState({
phase: "polling",
state: data.state,
authUrl: data.authUrl,
callbackUrl: data.callbackUrl || "",
expiresAt: data.expiresAt || null,
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
});
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore opener cleanup failures.
}
popup.location.href = data.authUrl;
commandCodeAuthWindowRef.current = popup;
} else {
const fallbackPopup = window.open(data.authUrl, "_blank", "noopener,noreferrer");
if (!fallbackPopup) {
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
}));
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
return;
}
commandCodeAuthWindowRef.current = fallbackPopup;
}
const deadline = data.expiresAt ? new Date(data.expiresAt).getTime() : Date.now() + 180000;
const poll = async () => {
if (Date.now() >= deadline) {
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: "Command Code link expired",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error("Command Code auth expired");
clearCommandCodeAuthTimer();
return;
}
try {
const statusRes = await fetch(
`/api/providers/command-code/auth/status?state=${encodeURIComponent(data.state)}`,
{ method: "GET", cache: "no-store" }
);
const statusData = await statusRes.json().catch(() => ({}));
const status = String(statusData.status || statusData.state || statusData.phase || "")
.toLowerCase()
.trim();
if (status === "expired") {
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: "Command Code link expired",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error("Command Code auth expired");
clearCommandCodeAuthTimer();
return;
}
if (status === "applied") {
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: "Command Code connected",
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success("Command Code connection added");
clearCommandCodeAuthTimer();
return;
}
if (status === "received") {
setCommandCodeAuthState((current) => ({
...current,
phase: "received",
message: "Browser approved, applying…",
}));
clearCommandCodeAuthTimer();
await handleCommandCodeAuthApply(
data.state,
statusData.connectionId,
statusData.name,
statusData.setDefault
);
return;
}
} catch {
// Keep polling until the contract reports a terminal state or timeout.
}
commandCodeAuthTimerRef.current = window.setTimeout(poll, 2000);
};
commandCodeAuthTimerRef.current = window.setTimeout(poll, 1000);
} catch (error) {
console.error("Error starting Command Code auth:", error);
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Failed to start Command Code auth",
}));
notify.error("Failed to start Command Code auth");
popup?.close?.();
commandCodeAuthWindowRef.current = null;
clearCommandCodeAuthTimer();
}
}, [
clearCommandCodeAuthTimer,
handleCloseAddApiKeyModal,
commandCodeAuthState.phase,
fetchConnections,
handleCommandCodeAuthApply,
notify,
]);
const handleOpenCommandCodeConnect = useCallback(() => {
setShowAddApiKeyModal(true);
void handleStartCommandCodeAuth();
}, [handleStartCommandCodeAuth, setShowAddApiKeyModal]);
return {
commandCodeAuthState,
clearCommandCodeAuthTimer,
handleCloseAddApiKeyModal,
handleCommandCodeAuthApply,
handleStartCommandCodeAuth,
handleOpenCommandCodeConnect,
};
}

View File

@@ -0,0 +1,48 @@
// Phase 1t.3 extraction — Issue #3501
// Encapsulates gateConnectionFlow + risk-notice modal state + confirm/cancel handlers.
import { useState, useRef, useCallback } from "react";
import { isRiskAcknowledged, useRiskAcknowledged } from "../../hooks/useRiskAcknowledged";
interface UseConnectionGateParams {
providerId: string;
subscriptionRisk: boolean;
}
export function useConnectionGate({ providerId, subscriptionRisk }: UseConnectionGateParams) {
const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false);
const pendingRiskActionRef = useRef<(() => void) | null>(null);
const { acknowledged: riskAcknowledged, acknowledge: acknowledgeRisk } =
useRiskAcknowledged(providerId);
const gateConnectionFlow = useCallback(
(callback: () => void) => {
if (subscriptionRisk && !riskAcknowledged && !isRiskAcknowledged(providerId)) {
pendingRiskActionRef.current = callback;
setShowRiskNoticeModal(true);
return;
}
callback();
},
[providerId, riskAcknowledged, subscriptionRisk]
);
const handleConfirmRiskNotice = useCallback(() => {
acknowledgeRisk();
setShowRiskNoticeModal(false);
const pendingAction = pendingRiskActionRef.current;
pendingRiskActionRef.current = null;
pendingAction?.();
}, [acknowledgeRisk]);
const handleCancelRiskNotice = useCallback(() => {
pendingRiskActionRef.current = null;
setShowRiskNoticeModal(false);
}, []);
return {
showRiskNoticeModal,
gateConnectionFlow,
handleConfirmRiskNotice,
handleCancelRiskNotice,
};
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect, useCallback } from "react";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
type UseExternalLinkFlowParams = {
providerId: string;
notify: { success: (msg: string) => void; error: (msg: string) => void };
fetchConnections: () => Promise<void> | void;
};
export function useExternalLinkFlow({
providerId,
notify,
fetchConnections,
}: UseExternalLinkFlowParams) {
const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false);
const [externalLinkUrl, setExternalLinkUrl] = useState("");
const [externalLinkToken, setExternalLinkToken] = useState<string | null>(null);
const [externalLinkLoading, setExternalLinkLoading] = useState(false);
const [externalLinkError, setExternalLinkError] = useState<string | null>(null);
const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard();
// "Adicionar Externo": generate a single-use public link so a third party can
// complete the Codex device flow in their own browser.
const openExternalLinkFlow = useCallback(async () => {
setExternalLinkModalOpen(true);
setExternalLinkUrl("");
setExternalLinkToken(null);
setExternalLinkError(null);
setExternalLinkLoading(true);
try {
const res = await fetch(`/api/oauth/${providerId}/public-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = await res.json().catch(() => ({}));
if (res.ok && data?.url) {
setExternalLinkUrl(data.url);
setExternalLinkToken(data.token || null);
} else {
setExternalLinkError(data?.error || "Falha ao gerar o link.");
}
} catch {
setExternalLinkError("Não foi possível contatar o servidor.");
} finally {
setExternalLinkLoading(false);
}
}, [providerId]);
// While the share popup is open, poll the ticket status so the dashboard can
// notify + refresh the connections the moment the external visitor finishes.
useEffect(() => {
if (!externalLinkModalOpen || !externalLinkToken) return;
let active = true;
const interval = setInterval(async () => {
if (!active) return;
try {
const res = await fetch(
`/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}`
);
const data = await res.json().catch(() => ({}));
if (!active) return;
if (data?.status === "completed") {
active = false;
clearInterval(interval);
notify.success("Conta Codex conectada pelo link externo.");
fetchConnections();
setExternalLinkModalOpen(false);
setExternalLinkToken(null);
} else if (data?.status === "expired") {
active = false;
clearInterval(interval);
setExternalLinkError("O link expirou sem ser concluído.");
}
} catch {
/* transient network error — keep polling */
}
}, 3000);
return () => {
active = false;
clearInterval(interval);
};
}, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]);
return {
externalLinkModalOpen,
setExternalLinkModalOpen,
externalLinkUrl,
externalLinkToken,
externalLinkLoading,
externalLinkError,
externalLinkCopied,
externalLinkCopy,
openExternalLinkFlow,
};
}

View File

@@ -0,0 +1,382 @@
"use client";
/**
* useModelImportHandlers — Issue #3501 Phase 1k
*
* Owns import-progress state and handlers that were previously inline in
* ProviderDetailPageClient:
* - importingModels, showImportModal, importProgress, togglingAutoSync
* - handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync
* - canImportModels (derived), isAutoSyncEnabled (derived), autoSyncConnection (derived)
*
* Cycle-safe: imports only from leaf modules and React.
* No import from ProviderDetailPageClient.
*/
import React, { useState } from "react";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
type NotifyStore = ReturnType<typeof useNotificationStore>;
// ──── types ──────────────────────────────────────────────────────────────────
export interface ImportProgress {
current: number;
total: number;
phase: "idle" | "fetching" | "importing" | "done" | "error";
status: string;
logs: string[];
error: string;
importedCount: number;
}
export interface UseModelImportHandlersParams {
providerId: string;
models: Array<{ id: string; name?: string }>;
modelMeta: { customModels: Array<{ id: string }>; modelCompatOverrides?: unknown[] };
modelAliases: Record<string, string>;
connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record<string, unknown> }>;
isFreeNoAuth: boolean;
handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
fetchAliases: () => Promise<void>;
fetchProviderModelMeta: () => Promise<void>;
fetchConnections: () => Promise<void>;
notify: NotifyStore;
t: ProviderMessageTranslator;
providerStorageAlias: string;
}
export interface UseModelImportHandlersReturn {
importingModels: boolean;
showImportModal: boolean;
importProgress: ImportProgress;
togglingAutoSync: boolean;
canImportModels: boolean;
isAutoSyncEnabled: boolean;
autoSyncConnection: UseModelImportHandlersParams["connections"][number] | undefined;
setShowImportModal: (v: boolean) => void;
setImportProgress: React.Dispatch<React.SetStateAction<ImportProgress>>;
handleImportModels: () => Promise<void>;
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
handleToggleAutoSync: () => Promise<void>;
}
// ──── hook ───────────────────────────────────────────────────────────────────
export function useModelImportHandlers({
providerId,
models,
modelMeta,
modelAliases,
connections,
isFreeNoAuth,
handleSetAlias,
fetchAliases,
fetchProviderModelMeta,
fetchConnections,
notify,
t,
providerStorageAlias,
}: UseModelImportHandlersParams): UseModelImportHandlersReturn {
const [importingModels, setImportingModels] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const [importProgress, setImportProgress] = useState<ImportProgress>({
current: 0,
total: 0,
phase: "idle",
status: "",
logs: [],
error: "",
importedCount: 0,
});
const [togglingAutoSync, setTogglingAutoSync] = useState(false);
// Derived
const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false);
const autoSyncConnection = connections.find((conn) => conn.isActive !== false);
const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync;
const handleImportModels = async () => {
if (importingModels) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection && !isFreeNoAuth) return;
const importTargetId = activeConnection?.id ?? providerId;
setImportingModels(true);
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: t("fetchingModels"),
logs: [],
error: "",
importedCount: 0,
});
try {
const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`);
const data = await res.json();
if (!res.ok) {
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("failedFetchModels"),
error: data.error || t("failedImportModels"),
}));
return;
}
const fetchedModels = data.models || [];
if (fetchedModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("noModelsFound"),
logs: [t("noModelsReturnedFromEndpoint")],
}));
return;
}
const existingIds = new Set([
...(modelMeta.customModels || []).map((m: any) => m.id),
...models.map((m: any) => m.id),
]);
const newModels = fetchedModels.filter(
(model: any) => !existingIds.has(model.id || model.name || model.model)
);
if (newModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status: t("allModelsAlreadyImported") || "All models already imported",
logs: [t("noNewModelsToImport") || "No new models to import"],
importedCount: 0,
total: 0,
current: 0,
}));
return;
}
setImportProgress((prev) => ({
...prev,
phase: "importing",
total: newModels.length,
current: 0,
status: t("importingModelsProgress", { current: 0, total: newModels.length }),
logs: [
t("foundModelsStartingImport", { count: newModels.length }),
...(newModels.length < fetchedModels.length
? [
t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) ||
`Skipping ${fetchedModels.length - newModels.length} existing models`,
]
: []),
],
}));
let importedCount = 0;
for (let i = 0; i < newModels.length; i++) {
const model = newModels[i];
const modelId = model.id || model.name || model.model;
if (!modelId) continue;
const parts = modelId.split("/");
const baseAlias = parts[parts.length - 1];
setImportProgress((prev) => ({
...prev,
current: i + 1,
status: t("importingModelsProgress", { current: i + 1, total: newModels.length }),
logs: [...prev.logs, t("importingModelById", { modelId })],
}));
await fetch("/api/provider-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: providerId,
modelId,
modelName: model.name || modelId,
source: "imported",
...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}),
...(Array.isArray(model.supportedEndpoints)
? { supportedEndpoints: model.supportedEndpoints }
: {}),
}),
});
if (!modelAliases[baseAlias]) {
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
}
importedCount += 1;
}
await fetchAliases();
setImportProgress((prev) => ({
...prev,
phase: "done",
current: newModels.length,
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAddedExisting"),
logs: [
...prev.logs,
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (importedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("importFailed"),
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
}));
} finally {
setImportingModels(false);
}
};
const handleCompatibleImportWithProgress = async (connectionId: string) => {
setShowImportModal(true);
setImportProgress({
current: 0,
total: 0,
phase: "fetching",
status: t("fetchingModels"),
logs: [],
error: "",
importedCount: 0,
});
try {
const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, {
method: "POST",
signal: AbortSignal.timeout(60_000),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t("failedImportModels"));
}
const importedModels = Array.isArray(data.importedModels) ? data.importedModels : [];
const importedCount =
typeof data.importedCount === "number" ? data.importedCount : importedModels.length;
const changedCount =
typeof data.importedChanges?.total === "number"
? data.importedChanges.total
: importedCount;
const totalChangedCount =
changedCount +
(typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0);
if (importedModels.length === 0) {
setImportProgress((prev) => ({
...prev,
phase: "done",
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
return;
}
setImportProgress((prev) => ({
...prev,
phase: "done",
total: importedModels.length,
current: importedModels.length,
status:
importedCount > 0
? t("importSuccessCount", { count: importedCount })
: t("noNewModelsAdded"),
logs: [
t("foundModelsStartingImport", { count: importedModels.length }),
...importedModels.map((model: any) =>
t("importingModelById", { modelId: model.id || model.name || model.model })
),
importedCount > 0
? t("importDoneCount", { count: importedCount })
: t("noNewModelsAdded"),
],
importedCount,
}));
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
}
} catch (error) {
console.log("Error importing models:", error);
setImportProgress((prev) => ({
...prev,
phase: "error",
status: t("importFailed"),
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
}));
}
};
const handleToggleAutoSync = async () => {
if (!autoSyncConnection || togglingAutoSync) return;
setTogglingAutoSync(true);
try {
const newValue = !isAutoSyncEnabled;
await fetch(`/api/providers/${(autoSyncConnection as any).id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerSpecificData: { autoSync: newValue },
}),
});
await fetchConnections();
notify[newValue ? "success" : "info"](
newValue ? t("autoSyncEnabled") : t("autoSyncDisabled")
);
} catch (error) {
console.log("Error toggling auto-sync:", error);
notify.error(t("autoSyncToggleFailed"));
} finally {
setTogglingAutoSync(false);
}
};
return {
importingModels,
showImportModal,
importProgress,
togglingAutoSync,
canImportModels,
isAutoSyncEnabled,
autoSyncConnection,
setShowImportModal,
setImportProgress,
handleImportModels,
handleCompatibleImportWithProgress,
handleToggleAutoSync,
};
}

View File

@@ -0,0 +1,411 @@
"use client";
/**
* useModelVisibilityHandlers — Issue #3501 Phase 1l
*
* Owns model-visibility/compat state and handlers previously inline in
* ProviderDetailPageClient:
* - State: compatSavingModelId, togglingModelId, bulkVisibilityAction,
* clearingModels, modelFilter, testingModelId, modelTestStatus,
* testingAll, testProgress, autoHideFailed, visibilityFilter
* - Derived: providerAliasEntries
* - Handlers: saveModelCompatFlags, handleToggleModelHidden,
* handleBulkToggleModelHidden, handleClearAllModels,
* onTestModel, handleTestAll
*
* onTestModel and handleTestAll share handleToggleModelHidden — kept in the
* same hook to avoid cross-hook cycles.
*
* Cycle-safe: imports only from leaf modules. No import from
* ProviderDetailPageClient.
*/
import { useState, useMemo } from "react";
import {
formatProviderModelsErrorResponse,
providerText,
type ProviderMessageTranslator,
type CompatByProtocolMap,
} from "../providerPageHelpers";
import { useNotificationStore } from "@/store/notificationStore";
type NotifyStore = ReturnType<typeof useNotificationStore>;
// ──── types ──────────────────────────────────────────────────────────────────
/** Subset of ModelCompatSavePatch fields needed by this hook. */
export interface ModelCompatSavePatch {
normalizeToolCallId?: boolean;
preserveOpenAIDeveloperRole?: boolean;
upstreamHeaders?: Record<string, string>;
compatByProtocol?: CompatByProtocolMap;
isHidden?: boolean;
}
export interface UseModelVisibilityHandlersParams {
providerId: string;
modelAliases: Record<string, string>;
/** The computed custom-model map from useModelCompatState. */
customMap: Map<string, unknown>;
providerStorageAlias: string;
fetchProviderModelMeta: () => Promise<void>;
fetchAliases: () => Promise<void>;
notify: NotifyStore;
t: ProviderMessageTranslator;
formatProviderModelsErrorResponse?: typeof formatProviderModelsErrorResponse;
/** The current selected connection (may be null). */
selectedConnection: any;
/** The provider node (may be null). */
providerNode: any;
}
export interface UseModelVisibilityHandlersReturn {
compatSavingModelId: string | null;
togglingModelId: string | null;
bulkVisibilityAction: "select" | "deselect" | null;
clearingModels: boolean;
modelFilter: string;
testingModelId: string | null;
modelTestStatus: Record<string, "ok" | "error">;
testingAll: boolean;
testProgress: { done: number; total: number } | null;
autoHideFailed: boolean;
visibilityFilter: "all" | "visible" | "hidden";
providerAliasEntries: [string, string][];
setModelFilter: (v: string) => void;
setAutoHideFailed: (v: boolean) => void;
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
handleToggleModelHidden: (
providerKey: string,
modelId: string,
hidden: boolean
) => Promise<void>;
handleBulkToggleModelHidden: (
providerKey: string,
modelIds: string[],
hidden: boolean
) => Promise<void>;
handleClearAllModels: () => Promise<void>;
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
}
// ──── hook ───────────────────────────────────────────────────────────────────
export function useModelVisibilityHandlers({
providerId,
modelAliases,
customMap,
providerStorageAlias,
fetchProviderModelMeta,
fetchAliases,
notify,
t,
selectedConnection,
providerNode,
}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<
"select" | "deselect" | null
>(null);
const [clearingModels, setClearingModels] = useState(false);
const [modelFilter, setModelFilter] = useState("");
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
const [testingAll, setTestingAll] = useState(false);
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
const [autoHideFailed, setAutoHideFailed] = useState(true);
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
const providerAliasEntries = useMemo(
() =>
Object.entries(modelAliases).filter(
([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
) as [string, string][],
[modelAliases, providerStorageAlias]
);
const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => {
setCompatSavingModelId(modelId);
try {
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
let body: Record<string, unknown>;
const onlyCompatByProtocol =
patch.compatByProtocol &&
patch.normalizeToolCallId === undefined &&
patch.preserveOpenAIDeveloperRole === undefined &&
!("upstreamHeaders" in patch);
if (c) {
if (onlyCompatByProtocol) {
body = {
provider: providerId,
modelId,
compatByProtocol: patch.compatByProtocol,
};
} else {
body = {
provider: providerId,
modelId,
modelName: (c.name as string) || modelId,
source: (c.source as string) || "manual",
apiFormat: (c.apiFormat as string) || "chat-completions",
supportedEndpoints:
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
? c.supportedEndpoints
: ["chat"],
normalizeToolCallId:
patch.normalizeToolCallId !== undefined
? patch.normalizeToolCallId
: Boolean(c.normalizeToolCallId),
preserveOpenAIDeveloperRole:
patch.preserveOpenAIDeveloperRole !== undefined
? patch.preserveOpenAIDeveloperRole
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
? Boolean(c.preserveOpenAIDeveloperRole)
: true,
};
if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol;
}
} else {
body = { provider: providerId, modelId, ...patch };
}
const res = await fetch("/api/provider-models", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
notify.error(
detail ? `${t("failedSaveCustomModel")}${detail}` : t("failedSaveCustomModel")
);
return;
}
} catch {
notify.error(t("failedSaveCustomModel"));
return;
} finally {
setCompatSavingModelId(null);
}
try {
await fetchProviderModelMeta();
} catch {
/* refresh failure is non-critical — data was already saved */
}
};
const handleToggleModelHidden = async (
providerKey: string,
modelId: string,
hidden: boolean
): Promise<void> => {
setTogglingModelId(modelId);
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden }),
}
);
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
setTogglingModelId(null);
}
};
const handleBulkToggleModelHidden = async (
providerKey: string,
modelIds: string[],
hidden: boolean
): Promise<void> => {
if (modelIds.length === 0) return;
setBulkVisibilityAction(hidden ? "deselect" : "select");
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isHidden: hidden, modelIds }),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
notify.error(detail || t("failedSaveCustomModel"));
return;
}
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
} catch {
notify.error(t("failedSaveCustomModel"));
} finally {
setBulkVisibilityAction(null);
}
};
const handleClearAllModels = async () => {
if (clearingModels) return;
if (!confirm(t("clearAllModelsConfirm"))) return;
setClearingModels(true);
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`,
{ method: "DELETE" }
);
if (res.ok) {
// Also delete all aliases that belong to this provider
await Promise.all(
providerAliasEntries.map(([alias]) =>
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
}).catch(() => {})
)
);
await fetchProviderModelMeta();
await fetchAliases();
notify.success(t("clearAllModelsSuccess"));
} else {
notify.error(t("clearAllModelsFailed"));
}
} catch {
notify.error(t("clearAllModelsFailed"));
} finally {
setClearingModels(false);
}
};
const onTestModel = async (modelId: string, fullModel: string) => {
setTestingModelId(modelId);
setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined as any }));
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: selectedConnection?.provider || providerNode?.id || providerId,
modelId: fullModel,
connectionId: selectedConnection?.id,
}),
});
const data = await res.json();
if (res.ok && data.status === "ok") {
notify.success(
providerText(t, "testModelSuccess", `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, {
modelId,
latencyMs: data.latencyMs,
})
);
setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" }));
} else {
notify.error(data.error || "Model test failed");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
await handleToggleModelHidden(providerStorageAlias, modelId, true);
}
} catch (err) {
notify.error("Network error testing model");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
await handleToggleModelHidden(providerStorageAlias, modelId, true);
} finally {
setTestingModelId(null);
}
};
const handleTestAll = async (
targets: Array<{ modelId: string; fullModel: string }>
): Promise<void> => {
if (testingAll) return;
if (targets.length === 0) {
notify.error(providerText(t, "noModelsToTest", "No models to test"));
return;
}
setTestingAll(true);
setTestProgress({ done: 0, total: targets.length });
let ok = 0;
let error = 0;
let hiddenCount = 0;
const CHUNK_SIZE = 3;
for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
const chunk = targets.slice(i, i + CHUNK_SIZE);
await Promise.all(
chunk.map(async ({ modelId, fullModel }) => {
try {
const result: {
results?: Record<
string,
{
status?: "ok" | "error";
rateLimited?: boolean;
isTimeout?: boolean;
error?: string;
}
>;
} = await fetch("/api/models/test-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: providerId,
connectionId: selectedConnection?.id,
modelIds: [fullModel],
}),
}).then((r) => r.json());
const entry = result.results?.[fullModel];
if (entry?.status === "ok") {
ok++;
} else {
error++;
if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) {
await handleToggleModelHidden(providerStorageAlias, modelId, true);
hiddenCount++;
}
}
} catch (e) {
error++;
}
setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null));
})
);
}
notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }));
if (hiddenCount > 0) {
notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }));
}
setTestingAll(false);
setTestProgress(null);
};
return {
compatSavingModelId,
togglingModelId,
bulkVisibilityAction,
clearingModels,
modelFilter,
testingModelId,
modelTestStatus,
testingAll,
testProgress,
autoHideFailed,
visibilityFilter,
providerAliasEntries,
setModelFilter,
setAutoHideFailed,
setVisibilityFilter,
saveModelCompatFlags,
handleToggleModelHidden,
handleBulkToggleModelHidden,
handleClearAllModels,
onTestModel,
handleTestAll,
};
}

View File

@@ -0,0 +1,63 @@
// Phase 1t.4 extraction — Issue #3501
// Encapsulates handleUpdateNode and handleUpdateConnection async handlers.
import type { ProviderMessageTranslator } from "../providerPageHelpers";
interface UseProviderNodeActionsParams {
providerId: string;
fetchConnections: () => Promise<void>;
selectedConnection: { id: string } | null;
setProviderNode: (node: any) => void;
setShowEditNodeModal: (open: boolean) => void;
setShowEditModal: (open: boolean) => void;
t: ProviderMessageTranslator;
}
export function useProviderNodeActions({
providerId,
fetchConnections,
selectedConnection,
setProviderNode,
setShowEditNodeModal,
setShowEditModal,
t,
}: UseProviderNodeActionsParams) {
const handleUpdateNode = async (formData: any) => {
try {
const res = await fetch(`/api/provider-nodes/${providerId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await res.json();
if (res.ok) {
setProviderNode(data.node);
await fetchConnections();
setShowEditNodeModal(false);
}
} catch (error) {
console.log("Error updating provider node:", error);
}
};
const handleUpdateConnection = async (formData: any) => {
try {
const res = await fetch(`/api/providers/${selectedConnection?.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
return null;
}
const data = await res.json().catch(() => ({}));
return data.error?.message || data.error || t("failedSaveConnection");
} catch (error) {
console.log("Error updating connection:", error);
return t("failedSaveConnectionRetry");
}
};
return { handleUpdateNode, handleUpdateConnection };
}

View File

@@ -16,6 +16,7 @@ import {
} from "@/lib/providers/requestDefaults";
import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
import { type WebSessionCredentialRequirement } from "./webSessionCredentials";
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants";
// ---------------------------------------------------------------------------
// Types shared between page + modals
@@ -819,3 +820,77 @@ export function formatTimeAgo(dateStr: string): string {
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
// ---------------------------------------------------------------------------
// Provider-detail page pure helpers (Phase 1s — extracted from god-component)
// ---------------------------------------------------------------------------
export function getApiLabel(
t: ProviderMessageTranslator,
isAnthropicProtocolCompatible: boolean,
apiType: string | undefined
): string {
if (isAnthropicProtocolCompatible) return t("messagesApi");
switch (apiType) {
case "responses":
return t("responsesApi");
case "embeddings":
return t("embeddings");
case "audio-transcriptions":
return t("audioTranscriptions");
case "audio-speech":
return t("audioSpeech");
case "images-generations":
return t("imagesGenerations");
default:
return t("chatCompletions");
}
}
export function getApiDefaultPath(
isCcCompatible: boolean,
isAnthropicCompatible: boolean,
apiType: string | undefined
): string {
if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH;
if (isAnthropicCompatible) return "/messages";
switch (apiType) {
case "responses":
return "/responses";
case "embeddings":
return "/embeddings";
case "audio-transcriptions":
return "/audio/transcriptions";
case "audio-speech":
return "/audio/speech";
case "images-generations":
return "/images/generations";
default:
return "/chat/completions";
}
}
export function getApiPath(
isCcCompatible: boolean,
isAnthropicCompatible: boolean,
apiType: string | undefined,
chatPath: string | undefined
): string {
const defaultPath = getApiDefaultPath(isCcCompatible, isAnthropicCompatible, apiType);
return (chatPath || defaultPath).replace(/^\//, "");
}
export function getHeaderIconProviderId(
isOpenAICompatible: boolean,
isAnthropicProtocolCompatible: boolean,
providerInfoId: string,
providerInfoApiType: string | undefined
): string {
if (isOpenAICompatible && providerInfoApiType) {
return providerInfoApiType === "responses" ? "oai-r" : "oai-cc";
}
if (isAnthropicProtocolCompatible) {
return "anthropic-m";
}
return providerInfoId;
}

View File

@@ -91,6 +91,7 @@ export default function ComboDefaultsTab() {
retryDelayMs: 2000,
maxComboDepth: 3,
trackMetrics: true,
reasoningTokenBufferEnabled: true,
handoffThreshold: 0.85,
handoffModel: "",
maxMessagesForSummary: 30,
@@ -556,6 +557,29 @@ export default function ComboDefaultsTab() {
}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-medium text-sm">
{translateOrFallback(t, "reasoningTokenBuffer", "Reasoning token buffer")}
</p>
<p className="text-xs text-text-muted">
{translateOrFallback(
t,
"reasoningTokenBufferDesc",
"Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap."
)}
</p>
</div>
<Toggle
checked={comboDefaults.reasoningTokenBufferEnabled !== false}
onChange={() =>
setComboDefaults((prev) => ({
...prev,
reasoningTokenBufferEnabled: prev.reasoningTokenBufferEnabled === false,
}))
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">

View File

@@ -0,0 +1,511 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Card, Toggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
type ModelLockoutSettings = {
enabled: boolean;
errorCodes: number[];
baseCooldownMs: number;
maxCooldownMs: number;
maxBackoffSteps: number;
useExponentialBackoff: boolean;
};
const DEFAULTS: ModelLockoutSettings = {
enabled: false,
errorCodes: [403, 404, 429, 502, 503, 504],
baseCooldownMs: 120_000,
maxCooldownMs: 1_800_000,
maxBackoffSteps: 10,
useExponentialBackoff: true,
};
function NumberField({
label,
value,
suffix,
min = 0,
max,
hint,
onChange,
}: {
label: string;
value: number;
suffix?: string;
min?: number;
max?: number;
hint?: string;
onChange: (value: number) => void;
}) {
return (
<label className="flex flex-col gap-1">
<span className="flex items-center justify-between text-xs text-text-muted">
<span>{label}</span>
{hint ? <span className="text-text-soft">{hint}</span> : null}
</span>
<div className="flex items-center gap-2">
<input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => {
if (event.target.value === "") return;
const nextValue = Number(event.target.value);
if (Number.isFinite(nextValue)) {
onChange(nextValue);
}
}}
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm"
/>
{suffix ? <span className="text-xs text-text-muted">{suffix}</span> : null}
</div>
</label>
);
}
export default function ModelLockoutCard() {
const t = useTranslations("settings");
const tc = useTranslations("common");
const notify = useNotificationStore();
const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS);
const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS);
const [errorCodesInput, setErrorCodesInput] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let mounted = true;
const load = async () => {
try {
const res = await fetch("/api/settings", { cache: "no-store" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!mounted) return;
const raw = (json as Record<string, unknown>).modelLockout as
| Record<string, unknown>
| undefined;
const parsed: ModelLockoutSettings = {
enabled:
typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled,
errorCodes: Array.isArray(raw?.errorCodes)
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
: [...DEFAULTS.errorCodes].sort((a, b) => a - b),
baseCooldownMs:
typeof raw?.baseCooldownMs === "number"
? raw.baseCooldownMs
: DEFAULTS.baseCooldownMs,
maxCooldownMs:
typeof raw?.maxCooldownMs === "number"
? raw.maxCooldownMs
: DEFAULTS.maxCooldownMs,
maxBackoffSteps:
typeof raw?.maxBackoffSteps === "number"
? raw.maxBackoffSteps
: DEFAULTS.maxBackoffSteps,
useExponentialBackoff:
typeof raw?.useExponentialBackoff === "boolean"
? raw.useExponentialBackoff
: DEFAULTS.useExponentialBackoff,
};
setData(parsed);
setDraft(parsed);
setErrorCodesInput("");
} catch (error) {
notify.error(
error instanceof Error
? error.message
: "Failed to load model lockout settings"
);
} finally {
if (mounted) setLoading(false);
}
};
void load();
return () => {
mounted = false;
};
}, []);
const hasChanges =
draft.enabled !== data.enabled ||
JSON.stringify([...draft.errorCodes].sort((a, b) => a - b)) !==
JSON.stringify([...data.errorCodes].sort((a, b) => a - b)) ||
draft.baseCooldownMs !== data.baseCooldownMs ||
draft.maxCooldownMs !== data.maxCooldownMs ||
draft.useExponentialBackoff !== data.useExponentialBackoff ||
draft.maxBackoffSteps !== data.maxBackoffSteps;
function validateDraft(d: ModelLockoutSettings): string | null {
if (d.baseCooldownMs < 5000 || d.baseCooldownMs > 600000)
return `Base Cooldown must be between 5,000ms and 600,000ms`;
if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000)
return `Max Cooldown must be between 5,000ms and 3,600,000ms`;
if (d.maxCooldownMs < d.baseCooldownMs)
return `Max Cooldown must be ≥ Base Cooldown`;
if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20)
return `Max Backoff Steps must be between 0 and 20`;
return null;
}
const handleSave = async () => {
const validationError = validateDraft(draft);
if (validationError) {
notify.error(validationError);
return;
}
setSaving(true);
const saveDraft = { ...draft, errorCodes: draft.errorCodes };
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelLockout: saveDraft }),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
const issues = err?.error?.issues ?? err?.error?.details;
if (Array.isArray(issues) && issues.length > 0) {
const fieldLabels: Record<string, string> = {
"modelLockout.baseCooldownMs": "Base Cooldown",
"modelLockout.maxCooldownMs": "Max Cooldown",
"modelLockout.maxBackoffSteps": "Max Backoff Steps",
"modelLockout.errorCodes": "Error Codes",
};
const msg = issues
.map(
(d: { path?: (string | number)[]; message?: string }) =>
`${fieldLabels[String(d.path?.[0])] || String(d.path?.[0] || "")}: ${d.message}`
)
.filter(Boolean)
.join("\n");
if (msg) throw new Error(msg);
}
throw new Error(
err?.error?.message || `HTTP ${res.status}`
);
}
const json = await res.json();
const raw = (json as Record<string, unknown>).modelLockout as
| Record<string, unknown>
| undefined;
if (raw) {
setData({
enabled:
typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled,
errorCodes: Array.isArray(raw.errorCodes)
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
: [...saveDraft.errorCodes].sort((a, b) => a - b),
baseCooldownMs:
typeof raw.baseCooldownMs === "number"
? raw.baseCooldownMs
: saveDraft.baseCooldownMs,
maxCooldownMs:
typeof raw.maxCooldownMs === "number"
? raw.maxCooldownMs
: saveDraft.maxCooldownMs,
maxBackoffSteps:
typeof raw.maxBackoffSteps === "number"
? raw.maxBackoffSteps
: saveDraft.maxBackoffSteps,
useExponentialBackoff:
typeof raw.useExponentialBackoff === "boolean"
? raw.useExponentialBackoff
: saveDraft.useExponentialBackoff,
});
} else {
setData(saveDraft);
}
setErrorCodesInput("");
notify.success(t("savedSuccessfully") || "Settings saved successfully");
} catch (error) {
notify.error(
error instanceof Error
? error.message
: "Failed to save model lockout settings"
);
} finally {
setSaving(false);
}
};
const handleReset = () => {
setDraft(data);
setErrorCodesInput("");
};
const commitErrorCodes = (inputOverride?: string) => {
const raw = inputOverride ?? errorCodesInput;
const code = Number(raw);
if (!Number.isFinite(code) || code < 100 || code > 599) return;
if (draft.errorCodes.includes(code)) {
setErrorCodesInput("");
return;
}
setDraft((prev) => ({ ...prev, errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b) }));
setErrorCodesInput("");
};
const removeErrorCode = (code: number) => {
setDraft((prev) => ({ ...prev, errorCodes: prev.errorCodes.filter((c) => c !== code) }));
};
const handleResetDefaults = () => {
setDraft({
...DEFAULTS,
errorCodes: [...DEFAULTS.errorCodes].sort((a, b) => a - b),
});
setErrorCodesInput("");
};
const fmt = (ms: number) => {
if (ms >= 60_000) return `${ms / 1000 / 60}m`;
if (ms >= 1_000) return `${ms / 1000}s`;
return `${ms}ms`;
};
const notifyRef = useRef<HTMLAudioElement | null>(null);
const playNotify = useCallback(() => {
try {
if (notifyRef.current) {
notifyRef.current.pause();
notifyRef.current.currentTime = 0;
} else {
notifyRef.current = new Audio("/audio/ui-notify.mp3");
notifyRef.current.volume = 0.3;
}
void notifyRef.current.play();
} catch { /* audio not available */ }
}, []);
if (loading) {
return (
<Card className="p-6">
<div className="flex items-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">
progress_activity
</span>
Loading model lockout settings...
</div>
</Card>
);
}
return (
<Card className="p-6">
<div className="mb-4 flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-xl text-primary">
gpp_maybe
</span>
<h2 className="text-lg font-bold">
{t("modelLockout") || "Model Lockout"}
</h2>
</div>
<p className="text-sm text-text-muted">
{t("modelLockoutPageDescription")}
</p>
</div>
{hasChanges ? (
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" onClick={handleReset}>
{tc("cancel")}
</Button>
<Button
size="sm"
variant="primary"
icon="save"
onClick={handleSave}
disabled={saving}
>
{saving ? tc("saving") || "Saving..." : tc("save")}
</Button>
</div>
) : (
<Button
size="sm"
variant="secondary"
onClick={handleResetDefaults}
>
Reset defaults
</Button>
)}
</div>
<div className="space-y-5">
{/* Master toggle */}
<div className="rounded-lg border border-border bg-bg-subtle px-4 py-3">
<Toggle
checked={draft.enabled}
onChange={(checked) => {
setDraft((prev) => ({ ...prev, enabled: checked }));
playNotify();
}}
label={t("modelLockoutEnabled")}
description={t("modelLockoutEnabledDescription")}
/>
</div>
{/* Error codes — tag input */}
<div className="rounded-lg border border-border bg-bg-subtle p-4">
<label className="flex flex-col gap-1.5">
<span className="text-sm font-medium text-text-main">
{t("modelLockoutErrorCodes")}
</span>
<span className="text-xs text-text-muted">
{t("modelLockoutErrorCodesDescription")}
</span>
</label>
{/* Chips row */}
{draft.errorCodes.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{draft.errorCodes.map((code) => (
<span
key={code}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 text-primary border border-primary/20 px-2 py-1 text-sm font-medium"
>
{code}
<button
type="button"
onClick={() => removeErrorCode(code)}
className="inline-flex size-4 items-center justify-center rounded-sm hover:bg-primary/20 transition-colors"
aria-label={`Remove ${code}`}
>
<span className="material-symbols-outlined text-sm leading-none">close</span>
</button>
</span>
))}
</div>
)}
{/* Input row */}
<div className="mt-3 flex items-center gap-2">
<input
type="text"
inputMode="numeric"
value={errorCodesInput}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, "");
if (raw.length <= 3) setErrorCodesInput(raw);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitErrorCodes();
}
}}
placeholder="Add error code..."
className="w-32 rounded-lg border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-primary transition-colors placeholder:text-text-muted/50"
/>
<button
type="button"
onClick={() => commitErrorCodes()}
disabled={!errorCodesInput.trim()}
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-muted hover:text-text-main hover:border-primary/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
{/* Suggested common codes — chips as clickable suggestions */}
{draft.errorCodes.length === 0 && errorCodesInput === "" && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-text-muted mr-1">Suggestions:</span>
{[403, 404, 429, 502, 503, 504].map((code) => (
<button
key={code}
type="button"
onClick={() => commitErrorCodes(String(code))}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40 hover:border-primary/40 hover:text-primary transition-colors"
>
+{code}
</button>
))}
</div>
)}
</div>
{/* Cooldowns - grid */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-lg border border-border bg-bg-subtle p-4">
<NumberField
label={t("modelLockoutBaseCooldown")}
value={draft.baseCooldownMs}
min={5000}
max={600000}
suffix="ms"
hint="5,000ms — 600,000ms"
onChange={(baseCooldownMs) =>
setDraft((prev) => ({ ...prev, baseCooldownMs }))
}
/>
<p className="mt-1.5 text-xs text-text-muted">
{t("modelLockoutBaseCooldownDescription")}
</p>
</div>
<div className="rounded-lg border border-border bg-bg-subtle p-4">
<NumberField
label={t("modelLockoutMaxCooldown")}
value={draft.maxCooldownMs}
min={draft.baseCooldownMs}
max={3600000}
suffix="ms"
hint="≥ Base Cooldown — 3,600,000ms"
onChange={(maxCooldownMs) =>
setDraft((prev) => ({ ...prev, maxCooldownMs }))
}
/>
<p className="mt-1.5 text-xs text-text-muted">
{t("modelLockoutMaxCooldownDescription")}
</p>
</div>
</div>
{/* Exponential backoff */}
<div className="rounded-lg border border-border bg-bg-subtle px-4 py-3">
<Toggle
checked={draft.useExponentialBackoff}
onChange={(checked) => {
setDraft((prev) => ({
...prev,
useExponentialBackoff: checked,
}));
playNotify();
}}
label={t("modelLockoutExponentialBackoff")}
description={t("modelLockoutExponentialBackoffDescription")}
/>
</div>
{/* Max backoff steps */}
<div className="rounded-lg border border-border bg-bg-subtle p-4">
<NumberField
label={t("modelLockoutMaxBackoffSteps")}
value={draft.maxBackoffSteps}
min={0}
onChange={(maxBackoffSteps) =>
setDraft((prev) => ({ ...prev, maxBackoffSteps }))
}
/>
<p className="mt-1.5 text-xs text-text-muted">
{t("modelLockoutMaxBackoffStepsDescription")}
</p>
</div>
</div>
</Card>
);
}

View File

@@ -6,6 +6,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import AutoDisableCard from "./AutoDisableCard";
import ModelCooldownsCard from "./ModelCooldownsCard";
import ModelLockoutCard from "./ModelLockoutCard";
type RequestQueueSettings = {
autoEnableApiKeyProviders: boolean;
@@ -975,6 +976,7 @@ export default function ResilienceTab() {
saving={savingSection === "providerCooldown"}
onSave={(providerCooldown) => savePatch("providerCooldown", { providerCooldown })}
/>
<ModelLockoutCard />
</div>
);
}

View File

@@ -41,7 +41,8 @@ export default function DocumentationTab() {
<h3 className="font-semibold mb-2">SOCKS5</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationSocks5DescBefore")}{" "}
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=true</code> to enable.
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code> to disable
(ON by default).
</p>
</section>

View File

@@ -0,0 +1,82 @@
/**
* API Route: /api/intelligence/sync
*
* POST — Trigger a manual Arena ELO intelligence sync.
* GET — Get current intelligence sync status.
* DELETE — Clear all synced arena_elo intelligence data.
*/
import { NextRequest, NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { intelligenceSyncRequestSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
export async function POST(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(intelligenceSyncRequestSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { dryRun = false } = validation.data;
const { syncArenaElo } = await import("@/lib/arenaEloSync");
const result = await syncArenaElo(dryRun);
return NextResponse.json(result, { status: result.success ? 200 : 502 });
} catch (err) {
return NextResponse.json(
{ error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { getArenaEloSyncStatus } = await import("@/lib/arenaEloSync");
return NextResponse.json(getArenaEloSyncStatus());
} catch (err) {
return NextResponse.json(
{ error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { clearSyncedIntelligence } = await import("@/lib/arenaEloSync");
clearSyncedIntelligence();
return NextResponse.json({ success: true, message: "Synced intelligence data cleared" });
} catch (err) {
return NextResponse.json(
{ error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}

View File

@@ -77,6 +77,7 @@ import {
isAutoFetchModelsEnabled,
persistDiscoveredModels,
} from "@/lib/providerModels/modelDiscovery";
import { parseGeminiModelsList, type GeminiDiscoveryModel } from "@/lib/providerModels/geminiModelsParser";
import { getSyncedAvailableModels } from "@/lib/db/models";
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
@@ -399,50 +400,7 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
method: "GET",
headers: { "Content-Type": "application/json" },
authQuery: "key", // Use query param for API key
parseResponse: (data) => {
const METHOD_TO_ENDPOINT: Record<string, string> = {
generateContent: "chat",
embedContent: "embeddings",
predict: "images",
predictLongRunning: "images",
bidiGenerateContent: "audio",
generateAnswer: "chat",
};
const IGNORED_METHODS = new Set([
"countTokens",
"countTextTokens",
"createCachedContent",
"batchGenerateContent",
"asyncBatchEmbedContent",
]);
return (data.models || []).map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? m.supportedGenerationMethods
: [];
const endpoints = [
...new Set(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
),
];
if (endpoints.length === 0) endpoints.push("chat");
return {
...m,
id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""),
name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""),
supportedEndpoints: endpoints,
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number"
? { outputTokenLimit: m.outputTokenLimit }
: {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
};
});
},
parseResponse: (data) => parseGeminiModelsList(data),
},
// gemini-cli handled via retrieveUserQuota (see GET handler)
huggingface: {
@@ -2084,6 +2042,130 @@ export async function GET(
});
}
if (provider === "vertex" || provider === "vertex-partner") {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
// Vertex AI lists models from the Generative Language `v1beta/models` endpoint, which both
// Express-mode API keys (via ?key=) and Service Account JSON (via a minted OAuth Bearer
// token) can reach. This surfaces the full live catalog — including image models
// (imagen-*, gemini-*-image) absent from the static registry list.
const credential = (apiKey || "").trim();
let queryKey: string | null = null;
let bearerToken: string | null = null;
try {
const { parseSAFromApiKey, getAccessToken } = await import(
"@omniroute/open-sse/executors/vertex.ts"
);
if (accessToken) {
bearerToken = accessToken;
} else if (credential) {
// A Service Account credential is a JSON object; a Vertex AI Express-mode API key is an
// opaque (non-JSON) string. Detect locally so this branch has no dependency on optional
// executor helpers.
let isServiceAccountJson = false;
try {
const parsed = JSON.parse(credential);
isServiceAccountJson =
!!parsed && typeof parsed === "object" && !Array.isArray(parsed);
} catch {
isServiceAccountJson = false;
}
if (isServiceAccountJson) {
bearerToken = await getAccessToken(parseSAFromApiKey(credential));
} else {
queryKey = credential;
}
}
} catch (error) {
// Couldn't resolve a usable credential (e.g. malformed Service Account JSON).
const fallback = buildDiscoveryErrorFallbackResponse(error, {
cacheWarning: "Vertex credential unavailable — using cached catalog",
localWarning: "Vertex credential unavailable — using local catalog",
});
if (fallback) return fallback;
}
if (!queryKey && !bearerToken) {
const fallback = buildDiscoveryFallbackResponse({
cacheWarning: "No usable Vertex credential — using cached catalog",
localWarning: "No usable Vertex credential — using local catalog",
});
if (fallback) return fallback;
return NextResponse.json(
{ error: "No usable Vertex AI credential configured for model discovery." },
{ status: 400 }
);
}
const baseUrl = "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000";
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`;
const allModels: GeminiDiscoveryModel[] = [];
let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl;
let pageCount = 0;
const MAX_PAGES = 20;
const seenTokens = new Set<string>();
try {
while (pageUrl && pageCount < MAX_PAGES) {
pageCount++;
const response = await safeOutboundFetch(pageUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
method: "GET",
headers,
});
if (!response.ok) {
// Avoid logging the raw upstream body (may contain sensitive data); status is enough.
console.log("[models] Vertex model discovery failed", {
provider,
status: response.status,
});
const fallback = buildDiscoveryFallbackResponse();
if (fallback) return fallback;
return NextResponse.json(
{ error: `Failed to fetch Vertex models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
allModels.push(...parseGeminiModelsList(data));
const nextPageToken = data.nextPageToken;
if (!nextPageToken || seenTokens.has(nextPageToken)) break;
seenTokens.add(nextPageToken);
pageUrl = `${baseUrl}&pageToken=${encodeURIComponent(nextPageToken)}`;
if (queryKey) pageUrl += `&key=${encodeURIComponent(queryKey)}`;
}
} catch (error) {
const fallback = buildDiscoveryErrorFallbackResponse(error);
if (fallback) return fallback;
throw error;
}
if (allModels.length > 0) {
return buildApiDiscoveryResponse(allModels);
}
const fallback = buildDiscoveryFallbackResponse();
if (fallback) return fallback;
return buildResponse({
provider,
connectionId,
models: [],
source: "api",
});
}
if (isAnthropicCompatibleProvider(provider)) {
const cachedResponse = maybeReturnCachedDiscovery();
if (cachedResponse) return cachedResponse;

View File

@@ -55,6 +55,7 @@ export async function GET(request: Request) {
maxMessagesForSummary: 30,
maxComboDepth: 3,
trackMetrics: true,
reasoningTokenBufferEnabled: true,
zeroLatencyOptimizationsEnabled: false,
},
providerOverrides,

View File

@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { diagnoseAllEgressIps, validateProxyPool } from "@/lib/proxyEgress";
/**
* GET /api/settings/proxies/egress — diagnose the egress IP of every OAuth
* connection: by which IP each account is entering (clientIp) and leaving
* (egressIp), plus warnings for same-rotation-group accounts sharing one
* egress IP (the codex anomaly-revocation trigger).
*
* POST /api/settings/proxies/egress — validate the whole proxy pool by probing
* each proxy's real egress IP and persisting status=active/error, so dead
* proxies are taken out of rotation automatically.
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const diagnostic = await diagnoseAllEgressIps();
return NextResponse.json(diagnostic);
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to diagnose egress IPs");
}
}
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const report = await validateProxyPool();
const dead = report.filter((r) => !r.alive);
return NextResponse.json({
validated: report.length,
alive: report.length - dead.length,
dead: dead.length,
report,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to validate proxy pool");
}
}

View File

@@ -42,7 +42,10 @@ export async function GET(request: Request) {
return Response.json({
items: proxies,
total: proxies.length,
socks5Enabled: process.env.ENABLE_SOCKS5_PROXY === "true",
// Default ON (opt-out): only an explicit falsey value disables SOCKS5.
socks5Enabled: !["false", "0", "no", "off"].includes(
(process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
),
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to load proxies");

View File

@@ -31,7 +31,9 @@ const PROXY_LEVEL_TO_REGISTRY_SCOPE = {
} as const;
function isSocks5Enabled() {
return process.env.ENABLE_SOCKS5_PROXY === "true";
// Default ON (opt-out): only an explicit falsey value disables SOCKS5.
const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase();
return !["false", "0", "no", "off"].includes(raw);
}
function getSupportedProxyTypes() {
@@ -106,7 +108,7 @@ function normalizeAndValidateProxy(
const type = String(proxy.type || "http").toLowerCase() as NonNullable<ProxyConfigInput["type"]>;
if (type === "socks5" && !isSocks5Enabled()) {
throw createInvalidProxyError(
"SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
"SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
);
}
if (type.startsWith("socks") && type !== "socks5") {

View File

@@ -4,6 +4,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
import {
validateProxyUrl,
upsertUpstreamProxyConfig,
@@ -220,6 +221,14 @@ export async function PATCH(request: Request) {
}
const body: typeof validation.data & { password?: string } = { ...validation.data };
// Sanitize model lockout settings: clamp values to valid bounds so that
// stale DB values or hand-crafted requests don't bypass range validation.
if (body.modelLockout) {
body.modelLockout = resolveModelLockoutSettings({
modelLockout: body.modelLockout as Record<string, unknown>,
}) as typeof body.modelLockout;
}
// Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the
// VALIDATED body so we never trip on stray unknown keys. If any security
// key is present, require currentPassword + verify against the stored

View File

@@ -983,6 +983,7 @@
"settingsAuthz": "Authz",
"settingsRouting": "Routing",
"settingsResilience": "Resilience",
"modelLockout": "Model Lockout",
"settingsAdvanced": "Advanced",
"omniProxySection": "OmniProxy",
"quotaTracker": "Provider Quota",
@@ -5871,7 +5872,21 @@
"vercelRelayProjectNameLabel": "Vercel Project Name",
"vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
"vercelRelayDeploying": "Deploying...",
"vercelRelayDeploy": "Deploy"
"vercelRelayDeploy": "Deploy",
"modelLockout": "Model Lockout",
"modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.",
"modelLockoutEnabled": "Enable Model Lockout",
"modelLockoutEnabledDescription": "When enabled, models that fail with configured error codes are temporarily locked to prevent retry loops.",
"modelLockoutErrorCodes": "Error Codes",
"modelLockoutErrorCodesDescription": "HTTP status codes that trigger model lockout. Type a code and click Add, or select from suggestions.",
"modelLockoutBaseCooldown": "Base Cooldown (ms)",
"modelLockoutBaseCooldownDescription": "Initial cooldown duration in milliseconds before a model can be retried.",
"modelLockoutMaxCooldown": "Max Cooldown (ms)",
"modelLockoutMaxCooldownDescription": "Maximum cooldown duration in milliseconds. Prevents excessively long lockouts.",
"modelLockoutExponentialBackoff": "Exponential Backoff",
"modelLockoutExponentialBackoffDescription": "When enabled, each consecutive failure increases the cooldown duration exponentially.",
"modelLockoutMaxBackoffSteps": "Max Backoff Steps",
"modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised."
},
"contextRtk": {
"title": "RTK Engine",

561
src/lib/arenaEloSync.ts Normal file
View File

@@ -0,0 +1,561 @@
/**
* arenaEloSync.ts — Arena AI leaderboard ELO sync engine.
*
* Fetches model intelligence data from the Arena AI leaderboard API
* (https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard) and stores
* normalised task-fit scores in the `model_intelligence` DB table.
*
* Resolution order: user overrides > synced arena ELO > defaults
*
* Opt-in via ARENA_ELO_SYNC_ENABLED=true (default: false).
*/
import { backupDbFile } from "./db/backup";
import {
bulkUpsertModelIntelligence,
deleteExpiredIntelligence,
deleteModelIntelligenceBySource,
type ModelIntelligenceEntry,
} from "./db/modelIntelligence";
// ─── Types ───────────────────────────────────────────────
/**
* A single model entry from the Arena AI leaderboard.
*/
export interface ArenaModelEntry {
/** Leaderboard rank (1-based). */
rank: number;
/** Model identifier (may include vendor prefix like "anthropic/claude-opus"). */
model: string;
/** Vendor / provider name (e.g. "Anthropic", "OpenAI"). */
vendor: string;
/** ELO score (higher = better). */
score: number;
/** Confidence interval half-width. */
ci: number;
/** Total number of human preference votes. */
votes: number;
/** License type (e.g. "proprietary", "open"). */
license: string;
}
/**
* Metadata + models for a single leaderboard category.
*/
export interface ArenaLeaderboardData {
/** Leaderboard metadata. */
meta: {
/** Leaderboard category name (e.g. "text", "code"). */
leaderboard: string;
/** Total number of models in this leaderboard. */
model_count: number;
};
/** Ranked model entries. */
models: ArenaModelEntry[];
}
/**
* Map of leaderboard category → leaderboard data.
*/
export interface ArenaLeaderboardMap {
[category: string]: ArenaLeaderboardData;
}
/**
* Result of a sync operation.
*/
export interface SyncResult {
/** Whether the sync completed successfully. */
success: boolean;
/** Number of model intelligence entries stored. */
modelCount: number;
/** Source identifier (always "arena_elo"). */
source: string;
/** Error message if sync failed. */
error?: string;
}
/**
* Current status of the Arena ELO sync subsystem.
*/
export interface SyncStatus {
/** Whether periodic sync is enabled via env var. */
enabled: boolean;
/** ISO timestamp of last successful sync, or null. */
lastSync: string | null;
/** Number of models stored in last successful sync. */
lastSyncModelCount: number;
/** ISO timestamp of next scheduled sync, or null. */
nextSync: string | null;
/** Configured sync interval in milliseconds. */
intervalMs: number;
/** Active data sources. */
sources: string[];
}
// ─── Configuration ───────────────────────────────────────
const ARENA_ELO_API_BASE =
"https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard";
/** Leaderboard categories to fetch from the Arena API. */
const FETCH_CATEGORIES = ["text", "code"] as const;
/**
* Maps Arena leaderboard categories to OmniRoute task-type categories.
*
* - "text" leaderboard → default, review, documentation, debugging
* - "code" leaderboard → coding
* - "vision" leaderboard is intentionally skipped (not relevant for text fitness)
*/
const CATEGORY_TASK_MAP: Record<string, string[]> = {
text: ["default", "review", "documentation", "debugging"],
code: ["coding"],
};
/**
* Known vendor prefixes to strip from model names.
* E.g. "anthropic/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
*/
const VENDOR_PREFIXES = [
"anthropic/",
"openai/",
"google/",
"meta/",
"mistral/",
"deepseek/",
"xai/",
"cohere/",
"qwen/",
"alibaba/",
"nvidia/",
"01-ai/",
"phind/",
"zerox/",
"together/",
"fireworks/",
"perplexity/",
"ai21/",
] as const;
/**
* OmniRoute model aliases: canonical name → known aliases.
* Creates additional DB entries for each alias so that models
* are findable under any name OmniRoute uses internally.
*/
const MODEL_ALIAS_MAP: Record<string, string[]> = {
"claude-opus-4-6-thinking": ["claude-opus-4", "anthropic/claude-opus-4"],
"claude-sonnet-4-5": ["claude-sonnet-4.5", "anthropic/claude-sonnet-4.5"],
"gpt-5.5": ["openai/gpt-5.5", "gpt-5"],
"gemini-3-flash": ["google/gemini-3-flash", "gemini-flash"],
"deepseek-r1": ["deepseek/deepseek-r1", "if/deepseek-r1"],
"kimi-k2-thinking": ["moonshot/kimi-k2", "qw/kimi-k2"],
"qwen3-coder-plus": ["qw/qwen3-coder-plus", "alibaba/qwen3-coder"],
"llama-4": ["meta/llama-4", "llama4"],
};
/** Votes threshold for "high" confidence. */
const HIGH_CONFIDENCE_VOTES = 5000;
/** Votes threshold for "medium" confidence. */
const MEDIUM_CONFIDENCE_VOTES = 1000;
/** Intelligence entry expiration: 7 days after sync. */
const EXPIRY_DAYS = 7;
const parsedInterval = parseInt(process.env.ARENA_ELO_SYNC_INTERVAL || "86400", 10);
const SYNC_INTERVAL_MS =
Number.isFinite(parsedInterval) && parsedInterval > 0
? parsedInterval * 1000
: 86400 * 1000;
// ─── Periodic sync state ─────────────────────────────────
let syncTimer: ReturnType<typeof setInterval> | null = null;
let lastSyncTime: string | null = null;
let lastSyncModelCount = 0;
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
let firstSyncDone = false;
let syncInProgress = false;
// ─── Model name normalization ────────────────────────────
/**
* Normalize a model name from the Arena leaderboard.
*
* Lowercases the name and strips known vendor prefixes
* (e.g. "anthropic/claude-opus-4" → "claude-opus-4").
*
* @param rawName - The raw model name from the API response.
* @returns The cleaned, lowercase model name.
*/
export function normalizeModelName(rawName: string): string {
let name = rawName.toLowerCase();
for (const prefix of VENDOR_PREFIXES) {
if (name.startsWith(prefix)) {
name = name.slice(prefix.length);
break;
}
}
return name;
}
// ─── Core: Fetch ─────────────────────────────────────────
/**
* Fetch leaderboards from the Arena AI API for all configured categories.
*
* Fetches "text" and "code" leaderboards concurrently and returns
* a map of category → leaderboard data.
*
* @returns Map of leaderboard category to its data.
* @throws If all category fetches fail (individual failures are logged and skipped).
*/
export async function fetchArenaLeaderboards(): Promise<ArenaLeaderboardMap> {
const result: ArenaLeaderboardMap = {};
const errors: string[] = [];
const fetches = FETCH_CATEGORIES.map(async (category) => {
const url = `${ARENA_ELO_API_BASE}?name=${category}`;
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(
`Arena API fetch failed for "${category}" [${response.status}]: ${response.statusText}`
);
}
const text = await response.text();
try {
result[category] = JSON.parse(text) as ArenaLeaderboardData;
} catch {
throw new Error(
`Arena API returned invalid JSON for "${category}" (${text.slice(0, 100)}...)`
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(`[ARENA_ELO_SYNC] Failed to fetch "${category}" leaderboard: ${message}`);
errors.push(message);
}
});
await Promise.all(fetches);
if (Object.keys(result).length === 0) {
throw new Error(
`All Arena leaderboard fetches failed: ${errors.join("; ")}`
);
}
return result;
}
// ─── Core: Transform ─────────────────────────────────────
/**
* Compute confidence level based on vote count.
*
* @param votes - Number of human preference votes.
* @returns "high" (≥5000), "medium" (≥1000), or "low" (<1000).
*/
function computeConfidence(votes: number): "high" | "medium" | "low" {
if (votes >= HIGH_CONFIDENCE_VOTES) return "high";
if (votes >= MEDIUM_CONFIDENCE_VOTES) return "medium";
return "low";
}
/**
* Transform raw Arena leaderboard data into model intelligence entries.
*
* For each leaderboard category, normalizes ELO scores into task-fit values
* in the range [0.4, 0.98] using the formula:
*
* taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo || 1))
*
* This ensures scores never reach 0 or 1, leaving room for user overrides.
* Models with fewer than 100 votes are marked as confidence="low".
*
* Leaderboard categories are mapped to OmniRoute task types:
* - "text" → default, review, documentation, debugging
* - "code" → coding
*
* Known OmniRoute model aliases are also expanded into additional entries.
*
* @param data - Map of leaderboard category → Arena leaderboard data.
* @returns Array of model intelligence entries ready for DB upsert.
*/
export function transformToModelIntelligence(
data: ArenaLeaderboardMap
): Array<Omit<ModelIntelligenceEntry, "syncedAt">> {
const entries: Array<Omit<ModelIntelligenceEntry, "syncedAt">> = [];
const expiresAt = new Date(
Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000
).toISOString();
for (const [category, leaderboard] of Object.entries(data)) {
const taskCategories = CATEGORY_TASK_MAP[category];
if (!taskCategories) continue;
const models = Array.isArray(leaderboard.models) ? leaderboard.models : [];
if (models.length === 0) continue;
// Compute ELO range for normalization
const eloScores = models.map((m) => m.score);
const minElo = Math.min(...eloScores);
const maxElo = Math.max(...eloScores);
const eloRange = maxElo - minElo || 1;
for (const model of models) {
const normalizedModel = normalizeModelName(model.model);
const confidence = computeConfidence(model.votes);
const taskFit = 0.4 + 0.58 * ((model.score - minElo) / eloRange);
for (const taskCategory of taskCategories) {
const entry: Omit<ModelIntelligenceEntry, "syncedAt"> = {
model: normalizedModel,
category: taskCategory,
source: "arena_elo",
score: Math.round(taskFit * 10000) / 10000,
eloRaw: model.score,
confidence,
expiresAt,
};
entries.push(entry);
// Expand known aliases
const aliases = MODEL_ALIAS_MAP[normalizedModel];
if (aliases) {
for (const alias of aliases) {
entries.push({
...entry,
model: alias,
});
}
}
}
}
}
return entries;
}
// ─── Main sync function ──────────────────────────────────
/**
* Fetch, transform, and store Arena ELO intelligence data.
*
* Pipeline: delete expired → fetch leaderboards → transform → bulk upsert.
* All errors are caught and logged — sync is never fatal.
*
* @param dryRun - If true, fetches and transforms but does not write to DB.
* @returns Sync result with model count and success status.
*/
export async function syncArenaElo(dryRun = false): Promise<SyncResult> {
if (syncInProgress) {
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: "Sync already in progress",
};
}
syncInProgress = true;
try {
// Backup DB before first sync (same pattern as pricingSync)
if (!firstSyncDone && !dryRun) {
backupDbFile("pre-arena-elo-sync");
firstSyncDone = true;
}
// Clean up stale entries before writing new ones
if (!dryRun) {
try {
deleteExpiredIntelligence();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`[ARENA_ELO_SYNC] Failed to delete expired intelligence: ${message}`
);
}
}
const leaderboards = await fetchArenaLeaderboards();
const entries = transformToModelIntelligence(leaderboards);
if (!dryRun && entries.length > 0) {
try {
bulkUpsertModelIntelligence(entries);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn(
`[ARENA_ELO_SYNC] Failed to bulk upsert intelligence: ${message}`
);
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: message,
};
}
}
if (!dryRun) {
lastSyncTime = new Date().toISOString();
lastSyncModelCount = entries.length;
}
const countLabel = dryRun ? "would sync" : "synced";
console.log(
`[ARENA_ELO_SYNC] ${countLabel} ${entries.length} model intelligence entries from Arena leaderboards`
);
return {
success: true,
modelCount: entries.length,
source: "arena_elo",
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn("[ARENA_ELO_SYNC] Sync failed:", message);
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: message,
};
} finally {
syncInProgress = false;
}
}
// ─── Clear synced data ───────────────────────────────────
/**
* Clear all synced intelligence data (arena_elo source).
*
* Iterates through all arena_elo entries and deletes them one by one,
* since the DB module provides per-key deletion. This is used by the
* DELETE /api/intelligence/sync endpoint.
*/
export function clearSyncedIntelligence(): void {
const deleted = deleteModelIntelligenceBySource("arena_elo");
console.log(`[ARENA_ELO_SYNC] Cleared ${deleted} arena_elo intelligence entries`);
}
// ─── Periodic sync ───────────────────────────────────────
/**
* Start periodic Arena ELO sync (non-blocking).
*
* Performs an initial sync immediately, then schedules periodic syncs
* at the configured interval. The timer is unref'd so it won't prevent
* the Node.js process from exiting.
*
* @param intervalMs - Override interval in milliseconds (defaults to env or 86400s).
*/
function startPeriodicSync(intervalMs?: number): void {
if (syncTimer) return; // Already running
const interval = intervalMs ?? SYNC_INTERVAL_MS;
activeSyncIntervalMs = interval;
console.log(
`[ARENA_ELO_SYNC] Starting periodic sync every ${interval / 1000}s`
);
// Initial sync (non-blocking)
syncArenaElo()
.then((result) => {
if (result.success) {
console.log(
`[ARENA_ELO_SYNC] Initial sync complete: ${result.modelCount} model intelligence entries`
);
}
})
.catch((err) => {
console.warn(
"[ARENA_ELO_SYNC] Initial sync error:",
err instanceof Error ? err.message : err
);
});
syncTimer = setInterval(() => {
syncArenaElo()
.then((result) => {
if (result.success) {
console.log(
`[ARENA_ELO_SYNC] Periodic sync complete: ${result.modelCount} entries`
);
}
})
.catch((err) => {
console.warn(
"[ARENA_ELO_SYNC] Periodic sync error:",
err instanceof Error ? err.message : err
);
});
}, interval);
// Prevent the timer from keeping the process alive
if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) {
(syncTimer as { unref?: () => void }).unref?.();
}
}
/**
* Stop periodic Arena ELO sync and clean up the timer.
*/
export function stopArenaEloSync(): void {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
console.log("[ARENA_ELO_SYNC] Periodic sync stopped");
}
}
/**
* Get the current Arena ELO sync status.
*
* @returns Sync status including enabled flag, last sync time, model count,
* next scheduled sync time, interval, and active sources.
*/
export function getArenaEloSyncStatus(): SyncStatus {
const enabled = process.env.ARENA_ELO_SYNC_ENABLED === "true";
return {
enabled,
lastSync: lastSyncTime,
lastSyncModelCount,
nextSync:
syncTimer && lastSyncTime
? new Date(
new Date(lastSyncTime).getTime() + activeSyncIntervalMs
).toISOString()
: null,
intervalMs: activeSyncIntervalMs,
sources: ["arena_elo"],
};
}
// ─── Init (called from server-init.ts) ───────────────────
/**
* Initialize Arena ELO sync if enabled via environment variable.
*
* Reads `ARENA_ELO_SYNC_ENABLED` (default: false). When enabled,
* starts periodic sync with the interval from `ARENA_ELO_SYNC_INTERVAL`
* (default: 86400 seconds / daily).
*
* All errors during initialization or the initial sync are caught and logged
* — initialization is never fatal.
*/
export async function initArenaEloSync(): Promise<void> {
if (process.env.ARENA_ELO_SYNC_ENABLED !== "true") {
console.log(
"[ARENA_ELO_SYNC] Disabled (set ARENA_ELO_SYNC_ENABLED=true to enable)"
);
return;
}
startPeriodicSync();
}

View File

@@ -0,0 +1,25 @@
-- 097_model_intelligence.sql
-- Model intelligence scores: per-model task-fitness from arena ELO, models.dev
-- tier rankings, and user overrides. Supports resolution chain (user_override
-- > arena_elo > models_dev_tier) and auto-expiry for stale synced data.
CREATE TABLE IF NOT EXISTS model_intelligence (
model TEXT NOT NULL,
source TEXT NOT NULL, -- 'arena_elo' | 'models_dev_tier' | 'user_override'
category TEXT NOT NULL, -- 'coding' | 'review' | 'planning' | 'analysis' | 'debugging' | 'documentation' | 'default'
score REAL NOT NULL, -- [0..1] normalized fitness score
elo_raw INTEGER, -- original ELO if source='arena_elo'
confidence TEXT, -- 'high' | 'medium' | 'low' or CI string like '+10/-8'
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT, -- TTL for auto-invalidated scores (NULL = never expires)
PRIMARY KEY (model, source, category)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_mi_model_category
ON model_intelligence (model, category);
CREATE INDEX IF NOT EXISTS idx_mi_source
ON model_intelligence (source);
CREATE INDEX IF NOT EXISTS idx_mi_expires
ON model_intelligence (expires_at) WHERE expires_at IS NOT NULL;

View File

@@ -0,0 +1,232 @@
/**
* modelIntelligence.ts — DB domain module for model task-fitness scores.
*
* Persists per-model intelligence from arena ELO, models.dev tier rankings,
* and user overrides. Resolution chain: user_override → arena_elo → models_dev_tier.
*
* @see Migration 097_model_intelligence.sql
*/
import { getDbInstance, rowToCamel } from "./core";
// ──────────────── Types ────────────────
export interface ModelIntelligenceEntry {
model: string;
source: string;
category: string;
score: number;
eloRaw: number | null;
confidence: string | null;
syncedAt: string;
expiresAt: string | null;
votes?: number;
rank?: number;
}
// ──────────────── Helpers ────────────────
function rowToEntry(row: Record<string, unknown>): ModelIntelligenceEntry {
const camel = rowToCamel(row) ?? {};
return {
model: String(camel.model ?? ""),
source: String(camel.source ?? ""),
category: String(camel.category ?? ""),
score: typeof camel.score === "number" ? camel.score : 0,
eloRaw: typeof camel.eloRaw === "number" ? camel.eloRaw : null,
confidence: typeof camel.confidence === "string" ? camel.confidence : null,
syncedAt: String(camel.syncedAt ?? ""),
expiresAt: typeof camel.expiresAt === "string" ? camel.expiresAt : null,
};
}
// ──────────────── CRUD ────────────────
export function getModelIntelligence(model: string, category: string): ModelIntelligenceEntry | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT * FROM model_intelligence
WHERE model = ? AND category = ?
AND source IN ('user_override', 'arena_elo', 'models_dev_tier')
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
ORDER BY CASE source
WHEN 'user_override' THEN 1
WHEN 'arena_elo' THEN 2
WHEN 'models_dev_tier' THEN 3
END
LIMIT 1`
)
.get(model, category) as Record<string, unknown> | undefined;
return row ? rowToEntry(row) : null;
}
export function getModelIntelligenceBySource(
model: string,
source: string,
category: string
): ModelIntelligenceEntry | null {
const db = getDbInstance();
const row = db
.prepare(
`SELECT * FROM model_intelligence
WHERE model = ? AND source = ? AND category = ?
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))`
)
.get(model, source, category) as Record<string, unknown> | undefined;
return row ? rowToEntry(row) : null;
}
export function upsertModelIntelligence(entry: Omit<ModelIntelligenceEntry, "syncedAt">): void {
const db = getDbInstance();
db.prepare(
`INSERT OR REPLACE INTO model_intelligence
(model, source, category, score, elo_raw, confidence, synced_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
).run(
entry.model,
entry.source,
entry.category,
entry.score,
entry.eloRaw ?? null,
entry.confidence ?? null,
entry.expiresAt ?? null
);
}
export function deleteModelIntelligence(model: string, source: string, category: string): boolean {
const db = getDbInstance();
const result = db
.prepare(
`DELETE FROM model_intelligence
WHERE model = ? AND source = ? AND category = ?`
)
.run(model, source, category);
return (result.changes ?? 0) > 0;
}
export function deleteExpiredIntelligence(source?: string): number {
const db = getDbInstance();
const conditions = ["expires_at IS NOT NULL", "datetime(expires_at) < datetime('now')"];
const params: unknown[] = [];
if (source) {
conditions.push("source = ?");
params.push(source);
}
const where = conditions.join(" AND ");
const result = db
.prepare(`DELETE FROM model_intelligence WHERE ${where}`)
.run(...params);
return result.changes ?? 0;
}
export function deleteModelIntelligenceBySource(source: string): number {
const db = getDbInstance();
const result = db
.prepare(`DELETE FROM model_intelligence WHERE source = ?`)
.run(source);
return result.changes ?? 0;
}
export function listModelIntelligence(filters?: {
source?: string;
category?: string;
}): ModelIntelligenceEntry[] {
const db = getDbInstance();
const conditions: string[] = [];
const params: unknown[] = [];
if (filters?.source) {
conditions.push("source = ?");
params.push(filters.source);
}
if (filters?.category) {
conditions.push("category = ?");
params.push(filters.category);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `SELECT * FROM model_intelligence ${where} ORDER BY model ASC, source ASC, category ASC`;
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
return rows.map(rowToEntry);
}
export function bulkUpsertModelIntelligence(entries: Array<Omit<ModelIntelligenceEntry, "syncedAt">>): number {
if (entries.length === 0) return 0;
const db = getDbInstance();
const stmt = db.prepare(
`INSERT OR REPLACE INTO model_intelligence
(model, source, category, score, elo_raw, confidence, synced_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
);
const upsertAll = db.transaction(() => {
let count = 0;
for (const entry of entries) {
stmt.run(
entry.model,
entry.source,
entry.category,
entry.score,
entry.eloRaw ?? null,
entry.confidence ?? null,
entry.expiresAt ?? null
);
count++;
}
return count;
});
return upsertAll();
}
export function getResolvedTaskFitness(model: string, category: string): number | null {
const entry = getModelIntelligence(model, category);
return entry ? entry.score : null;
}
/**
* Write a user_override entry for a model × category combination.
* Used by taskFitness.ts resolution chain as Layer 1 (highest priority).
*
* @param model - Model identifier
* @param category - Task category
* @param score - Fitness score [0..1]
*/
export function setUserFitnessOverrideEntry(
model: string,
category: string,
score: number,
): void {
upsertModelIntelligence({
model: model.toLowerCase(),
source: "user_override",
category: category.toLowerCase(),
score: Math.max(0, Math.min(1, score)),
eloRaw: null,
confidence: null,
expiresAt: null,
});
}
/**
* Delete a user_override entry for a model × category combination.
*
* @param model - Model identifier
* @param category - Task category
* @returns true if an entry was deleted
*/
export function deleteUserFitnessOverrideEntry(
model: string,
category: string,
): boolean {
return deleteModelIntelligence(model.toLowerCase(), "user_override", category.toLowerCase());
}

View File

@@ -694,13 +694,21 @@ export async function deleteProxyById(id: string, options?: { force?: boolean })
return result.changes > 0;
}
// A proxy is "alive" for resolution unless it has been explicitly marked dead
// (by an operator or a health check). Conservative: active/null/unknown stay
// usable so a working proxy is never stranded; only known-dead states are
// excluded so a dead proxy stops being handed out (every request would
// otherwise pay the timeout or leak out the host IP).
const PROXY_ALIVE_PREDICATE =
"(p.status IS NULL OR LOWER(p.status) NOT IN ('inactive','error','disabled','dead','down'))";
export async function resolveProxyForConnectionFromRegistry(connectionId: string) {
try {
const db = getDbInstance();
const accountAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1"
`SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1`
)
.get(connectionId);
if (accountAssignment) {
@@ -728,7 +736,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
if (connection?.provider) {
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
`SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1`
)
.get(connection.provider);
if (providerAssignment) {
@@ -752,7 +760,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
`SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1`
)
.get();
if (globalAssignment) {
@@ -789,7 +797,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?:
if (normalizedScope === "global") {
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
`SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1`
)
.get();
return globalAssignment ? toRegistryProxyResolution(globalAssignment, "global", null) : null;
@@ -800,7 +808,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?:
const assignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? LIMIT 1"
`SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1`
)
.get(normalizedScope, normalizedScopeId);

View File

@@ -636,6 +636,23 @@ export type { ApiKeyContextSource } from "./db/apiKeyContextSources";
export { sumUsageTokensThisMonth } from "./db/usageSummary";
export {
// Model Intelligence (task-fitness scores)
getModelIntelligence,
getModelIntelligenceBySource,
upsertModelIntelligence,
deleteModelIntelligence,
deleteExpiredIntelligence,
deleteModelIntelligenceBySource,
listModelIntelligence,
bulkUpsertModelIntelligence,
getResolvedTaskFitness,
setUserFitnessOverrideEntry,
deleteUserFitnessOverrideEntry,
} from "./db/modelIntelligence";
export type { ModelIntelligenceEntry } from "./db/modelIntelligence";
export {
getProviderMetrics,
getSearchProviderStats,

View File

@@ -176,6 +176,40 @@ function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSp
return undefined;
}
function getStaticSpecCanonicalModelId(modelId: string | null, rawModel: string | null) {
const candidates = [modelId, rawModel].filter(
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0
);
for (const candidate of candidates) {
const lower = candidate.toLowerCase();
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
if (canonical === "__default__") continue;
if (canonical.toLowerCase() === lower) return canonical;
if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return canonical;
}
}
return null;
}
function getSyncedCapabilityForResolved(
provider: string | null,
model: string | null,
rawModel: string | null
): SyncedCapabilities {
if (!provider || !model) return null;
const direct = getSyncedCapability(provider, model);
if (direct) return direct;
if (rawModel && rawModel !== model) {
const raw = getSyncedCapability(provider, rawModel);
if (raw) return raw;
}
const canonical = getStaticSpecCanonicalModelId(model, rawModel);
return canonical && canonical !== model ? getSyncedCapability(provider, canonical) : null;
}
function resolveVisionCapability(
spec: ModelSpec | undefined,
registryModel: { supportsVision?: boolean } | null,
@@ -209,10 +243,11 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
const resolved = resolveCapabilityInput(input);
const spec = getStaticSpec(resolved.model, resolved.rawModel);
const registryModel = getRegistryModel(resolved.provider, resolved.model);
const synced =
resolved.provider && resolved.model
? getSyncedCapability(resolved.provider, resolved.model)
: null;
const synced = getSyncedCapabilityForResolved(
resolved.provider,
resolved.model,
resolved.rawModel
);
const modalitiesInput = parseModalities(synced?.modalities_input);
const modalitiesOutput = parseModalities(synced?.modalities_output);
@@ -283,9 +318,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo
modalitiesOutput,
interleavedField:
synced?.interleaved_field ??
(typeof registryModel?.interleavedField === "string"
? registryModel.interleavedField
: null),
(typeof registryModel?.interleavedField === "string" ? registryModel.interleavedField : null),
};
}

View File

@@ -0,0 +1,68 @@
/**
* Parses the Google Generative Language `v1beta/models` listing into discovery models.
*
* Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints:
* - generateContent / generateAnswer → "chat"
* - predict / predictLongRunning → "images" (Imagen models)
* - embedContent → "embeddings"
* - bidiGenerateContent → "audio"
*
* This is shared by the `gemini` discovery config and the `vertex` discovery branch: Vertex AI
* Express keys (and Service Account JSON via a minted OAuth token) list models from the same
* endpoint, so image models (imagen-*, gemini-*-image) surface dynamically instead of being
* limited to the small static registry list.
*/
const METHOD_TO_ENDPOINT: Record<string, string> = {
generateContent: "chat",
embedContent: "embeddings",
predict: "images",
predictLongRunning: "images",
bidiGenerateContent: "audio",
generateAnswer: "chat",
};
const IGNORED_METHODS = new Set([
"countTokens",
"countTextTokens",
"createCachedContent",
"batchGenerateContent",
"asyncBatchEmbedContent",
]);
export interface GeminiDiscoveryModel {
id: string;
name: string;
supportedEndpoints: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
[key: string]: unknown;
}
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
return (data?.models || []).map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = [
...new Set(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
),
];
if (endpoints.length === 0) endpoints.push("chat");
return {
...m,
id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""),
name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""),
supportedEndpoints: endpoints,
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
});
}

View File

@@ -3954,8 +3954,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
},
vertex: async ({ apiKey }: any) => {
try {
const { parseSAFromApiKey, getAccessToken } =
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
await import("@omniroute/open-sse/executors/vertex.ts");
// Express-mode API keys are opaque strings sent directly as the ?key= query param — there is
// no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it).
if (isExpressApiKey(apiKey)) {
return { valid: true, error: null };
}
const sa = parseSAFromApiKey(apiKey);
// Validates credentials by successfully successfully exchanging them for a JWT from Google Identity
await getAccessToken(sa);
@@ -3966,8 +3971,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
},
"vertex-partner": async ({ apiKey }: any) => {
try {
const { parseSAFromApiKey, getAccessToken } =
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
await import("@omniroute/open-sse/executors/vertex.ts");
if (isExpressApiKey(apiKey)) {
return { valid: true, error: null };
}
const sa = parseSAFromApiKey(apiKey);
await getAccessToken(sa);
return { valid: true, error: null };

388
src/lib/proxyEgress.ts Normal file
View File

@@ -0,0 +1,388 @@
/**
* Proxy Egress IP visibility.
*
* The proxy logs already capture the INBOUND client IP (x-forwarded-for), but
* NOT the OUTBOUND/egress IP — the address the upstream actually sees. For
* rotating providers (codex/openai) this is critical: when several accounts
* egress through the SAME IP at high volume, the provider flags it as anomaly
* and revokes the tokens ("Your authentication token has been invalidated").
*
* This module resolves the real egress IP (via an echo-IP service through the
* resolved proxy/dispatcher) and detects same-rotation-group accounts sharing
* an egress IP, so the operator can confirm exactly which IP each account is
* entering and leaving by.
*/
import { request as undiciRequest } from "undici";
import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts";
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
const EGRESS_ECHO_URL = "https://api64.ipify.org?format=json";
const EGRESS_PROBE_TIMEOUT_MS = 6000;
const EGRESS_CACHE_TTL_MS = 5 * 60 * 1000;
export interface EgressProbeResult {
ip: string | null;
latencyMs: number;
error?: string;
}
export type EgressProbe = (proxyUrl: string | null) => Promise<EgressProbeResult>;
const egressCache = new Map<string, { ip: string | null; at: number }>();
async function defaultEgressProbe(proxyUrl: string | null): Promise<EgressProbeResult> {
const start = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), EGRESS_PROBE_TIMEOUT_MS);
try {
const dispatcher = proxyUrl ? createProxyDispatcher(proxyUrl) : undefined;
const res = await undiciRequest(EGRESS_ECHO_URL, {
method: "GET",
dispatcher,
signal: controller.signal,
headersTimeout: EGRESS_PROBE_TIMEOUT_MS,
bodyTimeout: EGRESS_PROBE_TIMEOUT_MS,
});
const text = await res.body.text();
let ip: string | null = null;
try {
ip = (JSON.parse(text) as { ip?: string }).ip ?? null;
} catch {
// non-JSON body — leave ip null
}
return { ip, latencyMs: Date.now() - start };
} catch (error) {
return {
ip: null,
latencyMs: Date.now() - start,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timeout);
}
}
let probe: EgressProbe = defaultEgressProbe;
/** Test seam: override the network probe. */
export function _setEgressProbeForTests(fn: EgressProbe | null): void {
probe = fn ?? defaultEgressProbe;
}
export function clearEgressCache(): void {
egressCache.clear();
}
/**
* Synchronous read of the cached egress IP for a proxy URL (null = direct).
* Non-blocking — used by the request hot path to log the egress IP without an
* echo-IP round-trip. Returns null if not yet probed.
*/
export function getCachedEgressIp(proxyUrl: string | null): string | null {
const cached = egressCache.get(proxyUrl ?? "__direct__");
if (!cached) return null;
if (Date.now() - cached.at >= EGRESS_CACHE_TTL_MS) return null;
return cached.ip;
}
const warmingInFlight = new Set<string>();
/**
* Fire-and-forget: populate the egress cache for a proxy URL in the background
* so subsequent proxy log lines carry the real egress IP. Deduped per URL.
*/
export function warmEgressIp(proxyUrl: string | null): void {
const key = proxyUrl ?? "__direct__";
if (warmingInFlight.has(key) || getCachedEgressIp(proxyUrl) !== null) return;
warmingInFlight.add(key);
void resolveEgressIp(proxyUrl)
.catch(() => undefined)
.finally(() => warmingInFlight.delete(key));
}
/**
* Resolve the egress IP for a given proxy URL (null = direct/host IP).
* Cached per proxyUrl to avoid an echo-IP round-trip on every call.
*/
export async function resolveEgressIp(
proxyUrl: string | null,
opts: { cacheTtlMs?: number; force?: boolean } = {}
): Promise<EgressProbeResult & { cached: boolean }> {
const key = proxyUrl ?? "__direct__";
const ttl = opts.cacheTtlMs ?? EGRESS_CACHE_TTL_MS;
const cached = egressCache.get(key);
if (!opts.force && cached && Date.now() - cached.at < ttl) {
return { ip: cached.ip, latencyMs: 0, cached: true };
}
const result = await probe(proxyUrl);
egressCache.set(key, { ip: result.ip, at: Date.now() });
return { ...result, cached: false };
}
export interface ConnectionEgress {
connectionId: string;
provider: string;
account: string | null;
proxyLevel: string;
proxyHost: string | null;
egressIp: string | null;
error?: string;
}
export interface EgressSharingWarning {
egressIp: string;
rotationGroup: string;
connections: string[]; // connectionId/account labels sharing this IP within one rotation group
}
export interface EgressDiagnostic {
connections: ConnectionEgress[];
byEgressIp: Record<string, string[]>;
sharedWithinRotationGroup: EgressSharingWarning[];
}
/**
* PURE: group egress results by IP and flag IPs shared by ≥2 accounts of the
* SAME rotation group (codex+openai share one Auth0 family — the exact
* condition that triggers anomaly revocation). Direct/unknown IPs are reported
* but only same-group sharing is a warning.
*/
export function analyzeEgressSharing(connections: ConnectionEgress[]): {
byEgressIp: Record<string, string[]>;
sharedWithinRotationGroup: EgressSharingWarning[];
} {
const byEgressIp: Record<string, string[]> = {};
// ip -> rotationGroup -> labels
const byIpGroup = new Map<string, Map<string, string[]>>();
for (const c of connections) {
if (!c.egressIp) continue;
const label = c.account || c.connectionId;
(byEgressIp[c.egressIp] ??= []).push(label);
const group = rotationGroupFor(c.provider) || `provider:${c.provider}`;
let groups = byIpGroup.get(c.egressIp);
if (!groups) {
groups = new Map();
byIpGroup.set(c.egressIp, groups);
}
const list = groups.get(group) ?? [];
list.push(label);
groups.set(group, list);
}
const sharedWithinRotationGroup: EgressSharingWarning[] = [];
for (const [egressIp, groups] of byIpGroup) {
for (const [rotationGroup, labels] of groups) {
if (labels.length >= 2) {
sharedWithinRotationGroup.push({ egressIp, rotationGroup, connections: labels });
}
}
}
return { byEgressIp, sharedWithinRotationGroup };
}
/**
* Diagnose egress IPs for every OAuth connection: resolve each connection's
* proxy, probe the real egress IP, and flag same-rotation-group IP sharing.
*/
export async function diagnoseAllEgressIps(deps?: {
getConnections?: () => Promise<
Array<{ id: string; provider: string; name?: string; email?: string; authType?: string }>
>;
resolveProxy?: (
connectionId: string
) => Promise<{ proxy?: unknown; level?: string } | null>;
}): Promise<EgressDiagnostic> {
const getConnections =
deps?.getConnections ??
(async () => {
const { getProviderConnections } = await import("./localDb");
return (await getProviderConnections({ authType: "oauth" })) as Array<{
id: string;
provider: string;
name?: string;
email?: string;
}>;
});
const resolveProxy =
deps?.resolveProxy ??
(async (connectionId: string) => {
const { resolveProxyForConnection } = await import("./db/settings");
return resolveProxyForConnection(connectionId);
});
const conns = await getConnections();
const results: ConnectionEgress[] = [];
for (const c of conns) {
const resolved = await resolveProxy(c.id);
const proxyObj = (resolved?.proxy ?? null) as {
type?: string;
host?: string;
port?: number | string;
} | null;
const proxyUrl = proxyObj ? proxyConfigToUrl(proxyObj) : null;
const egress = await resolveEgressIp(proxyUrl);
results.push({
connectionId: c.id,
provider: c.provider,
account: c.email || c.name || c.id.slice(0, 8),
proxyLevel: resolved?.level || "direct",
proxyHost: proxyObj?.host ?? null,
egressIp: egress.ip,
...(egress.error ? { error: egress.error } : {}),
});
}
const { byEgressIp, sharedWithinRotationGroup } = analyzeEgressSharing(results);
return { connections: results, byEgressIp, sharedWithinRotationGroup };
}
export interface ProxyValidationResult {
proxyId: string;
host: string;
port: number | string;
alive: boolean;
egressIp: string | null;
latencyMs: number;
previousStatus: string | null;
newStatus: "active" | "error";
}
/**
* Validate every proxy in the registry by probing its real egress IP, and
* persist the result to `proxy_registry.status` (active/error). Combined with
* PROXY_ALIVE_PREDICATE in resolution, a dead proxy is automatically taken out
* of rotation — fixing the "all proxies marked active but actually dead" state
* that left codex accounts falling back to the shared host /64 IP.
*
* Deps are injectable for tests.
*/
export async function validateProxyPool(deps?: {
listProxies?: () => Promise<
Array<{ id: string; type: string; host: string; port: number | string; username?: string | null; password?: string | null; status?: string | null }>
>;
markStatus?: (id: string, status: string, meta: { latencyMs: number; egressIp: string | null }) => Promise<void>;
}): Promise<ProxyValidationResult[]> {
const listProxies =
deps?.listProxies ??
(async () => {
const { listProxies: real } = await import("./db/proxies");
return (await real({ includeSecrets: true })) as Array<{
id: string;
type: string;
host: string;
port: number | string;
username?: string | null;
password?: string | null;
status?: string | null;
}>;
});
const markStatus =
deps?.markStatus ??
(async (id: string, status: string) => {
const { updateProxy } = await import("./db/proxies");
await updateProxy(id, { status });
});
const proxies = await listProxies();
const report: ProxyValidationResult[] = [];
for (const p of proxies) {
const url = proxyConfigToUrl({
type: p.type,
host: p.host,
port: p.port,
username: p.username ?? undefined,
password: p.password ?? undefined,
});
const probe = await resolveEgressIp(url, { force: true });
const alive = !!probe.ip && !probe.error;
const newStatus: "active" | "error" = alive ? "active" : "error";
await markStatus(p.id, newStatus, { latencyMs: probe.latencyMs, egressIp: probe.ip });
report.push({
proxyId: p.id,
host: p.host,
port: p.port,
alive,
egressIp: probe.ip,
latencyMs: probe.latencyMs,
previousStatus: p.status ?? null,
newStatus,
});
}
return report;
}
export interface DistributionPlan {
assignments: Array<{ connectionId: string; account: string; proxyId: string }>;
unassigned: Array<{ connectionId: string; account: string }>;
sharingRisk: boolean;
note: string;
}
/**
* PURE: plan a 1-proxy-per-connection assignment so no two accounts of the same
* rotation group share an egress IP (the codex anomaly trigger). Default is
* strict 1:1 — extras are left UNASSIGNED (better unrouted than sharing an IP).
* allowSharing=true round-robins instead, flagging sharingRisk.
*/
export function planProxyDistribution(
connections: Array<{ id: string; account?: string }>,
liveProxyIds: string[],
opts: { allowSharing?: boolean } = {}
): DistributionPlan {
const assignments: DistributionPlan["assignments"] = [];
const unassigned: DistributionPlan["unassigned"] = [];
let sharingRisk = false;
connections.forEach((c, i) => {
const account = c.account || c.id.slice(0, 8);
if (liveProxyIds.length === 0) {
unassigned.push({ connectionId: c.id, account });
return;
}
if (opts.allowSharing) {
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] });
} else if (i < liveProxyIds.length) {
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] });
} else {
unassigned.push({ connectionId: c.id, account });
}
});
if (opts.allowSharing && liveProxyIds.length < connections.length) sharingRisk = true;
const note =
liveProxyIds.length === 0
? "No live proxies available — add working proxies before distributing."
: liveProxyIds.length < connections.length && !opts.allowSharing
? `Only ${liveProxyIds.length} live proxies for ${connections.length} accounts — ${unassigned.length} left unassigned (avoid shared-IP anomaly).`
: "1 distinct proxy per account.";
return { assignments, unassigned, sharingRisk, note };
}
/**
* Apply a distribution plan: assign each proxy to its connection (account scope).
*/
export async function applyProxyDistribution(
plan: DistributionPlan,
deps?: { assign?: (connectionId: string, proxyId: string) => Promise<void> }
): Promise<{ applied: number }> {
const assign =
deps?.assign ??
(async (connectionId: string, proxyId: string) => {
const { assignProxyToScope } = await import("./db/proxies");
await assignProxyToScope("account", connectionId, proxyId);
});
let applied = 0;
for (const a of plan.assignments) {
await assign(a.connectionId, a.proxyId);
applied++;
}
return { applied };
}

View File

@@ -29,6 +29,10 @@ interface ProxyLogEntry {
provider: string | null;
targetUrl: string | null;
clientIp: string | null;
/** Outbound/egress IP the upstream actually saw (null until probed). The
* historical clientIp is the INBOUND IP (x-forwarded-for); egressIp answers
* "by which IP is this account leaving" — critical for rotating providers. */
egressIp: string | null;
latencyMs: number;
error: string | null;
connectionId: string | null;
@@ -77,6 +81,7 @@ function loadFromDb() {
provider: row.provider || null,
targetUrl: row.target_url || null,
clientIp: row.public_ip || null,
egressIp: row.egress_ip || null,
latencyMs: row.latency_ms || 0,
error: row.error || null,
connectionId: row.connection_id || null,
@@ -109,6 +114,7 @@ export function logProxyEvent(entry: ProxyLogInput) {
provider: entry.provider || null,
targetUrl: entry.targetUrl || null,
clientIp: entry.clientIp ?? entry.publicIp ?? null,
egressIp: entry.egressIp ?? null,
latencyMs: entry.latencyMs || 0,
error: entry.error || null,
connectionId: entry.connectionId || null,
@@ -117,6 +123,16 @@ export function logProxyEvent(entry: ProxyLogInput) {
tlsFingerprint: entry.tlsFingerprint || false,
};
// Structured egress line so the operator can confirm, in the proxy logs, which
// IP each account is entering (clientIp) and leaving (egressIp) by.
if (log.proxy || log.egressIp) {
console.log(
`[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` +
`in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` +
`proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}`
);
}
// 1. In-memory ring buffer (newest first)
proxyLogs.unshift(log);
if (proxyLogs.length > MAX_IN_MEMORY_ENTRIES) {

View File

@@ -0,0 +1,95 @@
export interface ModelLockoutSettings {
enabled: boolean;
errorCodes: number[];
baseCooldownMs: number;
maxCooldownMs: number;
maxBackoffSteps: number;
useExponentialBackoff: boolean;
}
export const DEFAULT_MODEL_LOCKOUT_SETTINGS: ModelLockoutSettings = {
enabled: false,
errorCodes: [403, 404, 429, 502, 503, 504],
baseCooldownMs: 120_000,
maxCooldownMs: 1_800_000,
maxBackoffSteps: 10,
useExponentialBackoff: true,
};
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toInteger(
value: unknown,
fallback: number,
options: { min?: number; max?: number } = {}
): number {
const min = options.min ?? 0;
const max = options.max ?? Number.MAX_SAFE_INTEGER;
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number(value)
: fallback;
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback;
}
function toBoolean(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") return value === "true" || value === "1";
return fallback;
}
function toNumberArray(value: unknown, fallback: number[]): number[] {
if (Array.isArray(value)) {
const nums = value
.map((v) => (typeof v === "number" ? v : Number(v)))
.filter((n) => Number.isFinite(n) && n >= 100 && n <= 599);
return nums.length > 0 ? nums : fallback;
}
return fallback;
}
export function resolveModelLockoutSettings(
settings: Record<string, unknown> | null | undefined
): ModelLockoutSettings {
const record = asRecord(settings);
const raw = asRecord(record.modelLockout);
const isTest =
process.env.NODE_ENV === "test" ||
process.execArgv.includes("--test") ||
process.argv.some((arg) => typeof arg === "string" && arg.includes("test"));
const baseCooldownMs = toInteger(raw.baseCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.baseCooldownMs, {
min: isTest ? 0 : 5_000,
max: 600_000,
});
const maxCooldownMs = Math.max(
toInteger(raw.maxCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs, {
min: isTest ? 0 : 5_000,
max: 3_600_000,
}),
baseCooldownMs // cap must be >= base or exponential backoff is meaningless
);
return {
enabled: toBoolean(raw.enabled, DEFAULT_MODEL_LOCKOUT_SETTINGS.enabled),
errorCodes: toNumberArray(raw.errorCodes, DEFAULT_MODEL_LOCKOUT_SETTINGS.errorCodes),
baseCooldownMs,
maxCooldownMs,
maxBackoffSteps: toInteger(
raw.maxBackoffSteps,
DEFAULT_MODEL_LOCKOUT_SETTINGS.maxBackoffSteps,
{ min: 0, max: 20 }
),
useExponentialBackoff: toBoolean(
raw.useExponentialBackoff,
DEFAULT_MODEL_LOCKOUT_SETTINGS.useExponentialBackoff
),
};
}

View File

@@ -76,10 +76,38 @@ function getEffectiveTokenExpiryMs(conn: any): number {
return Number.isFinite(expiryMs) ? expiryMs : 0;
}
// ── Refresh circuit breaker ───────────────────────────────────────────────
// A refresh that returns null (network blip, dead proxy, unclassified error)
// leaves the connection active, so the next 60s sweep retries immediately —
// the production refresh loop (claude/aa5dd5cf 1352×, kimi 270×). We track
// consecutive failures and back off exponentially so a stuck connection stops
// hammering the upstream (and stops flooding the logs) instead of looping.
const REFRESH_CIRCUIT_BASE_MIN = 5;
const REFRESH_CIRCUIT_MAX_MIN = 240; // cap at 4h
export function getRefreshBackoffUntil(streak: number, now: string): string {
const steps = Math.max(0, streak - 1);
const backoffMin = Math.min(REFRESH_CIRCUIT_BASE_MIN * 2 ** steps, REFRESH_CIRCUIT_MAX_MIN);
return new Date(new Date(now).getTime() + backoffMin * 60 * 1000).toISOString();
}
export function isInRefreshBackoff(conn: any, nowMs: number): boolean {
const until = conn?.providerSpecificData?.refreshCircuit?.until;
if (typeof until !== "string") return false;
const untilMs = new Date(until).getTime();
return Number.isFinite(untilMs) && untilMs > nowMs;
}
export function buildRefreshFailureUpdate(conn: any, now: string) {
const wasExpired = conn.testStatus === "expired";
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
// Circuit breaker: increment the consecutive-failure streak and set an
// exponential backoff window so the next sweep skips this connection instead
// of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit).
const prevStreak = conn.providerSpecificData?.refreshCircuit?.streak ?? 0;
const streak = prevStreak + 1;
return {
lastHealthCheckAt: now,
// A failed background refresh should not evict otherwise healthy accounts
@@ -91,10 +119,28 @@ export function buildRefreshFailureUpdate(conn: any, now: string) {
lastErrorType: "token_refresh_failed",
lastErrorSource: "oauth",
errorCode: "refresh_failed",
providerSpecificData: {
...(conn.providerSpecificData || {}),
refreshCircuit: { streak, until: getRefreshBackoffUntil(streak, now), lastFailAt: now },
},
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
};
}
/**
* Strip the refresh circuit breaker state from providerSpecificData after a
* successful refresh, so the streak/backoff resets cleanly.
*/
export function clearRefreshCircuit(
providerSpecificData: Record<string, unknown> | null | undefined
): Record<string, unknown> | undefined {
if (!providerSpecificData || typeof providerSpecificData !== "object") return undefined;
if (!("refreshCircuit" in providerSpecificData)) return undefined;
const next = { ...providerSpecificData };
delete next.refreshCircuit;
return next;
}
function isEnvFlagEnabled(name: string): boolean {
const value = process.env[name];
if (!value) return false;
@@ -355,6 +401,14 @@ export async function checkConnection(conn) {
if (!isAboutToExpire && !shouldRefreshByInterval) return;
// Circuit breaker: if recent refreshes for this connection failed, wait out
// the exponential backoff window instead of retrying every 60s tick. This is
// what stops the refresh loop when getAccessToken keeps returning null
// (dead proxy / network blip / unclassified upstream error).
if (isInRefreshBackoff(conn, Date.now())) {
return;
}
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
@@ -427,11 +481,17 @@ export async function checkConnection(conn) {
updateData.expiresAt = expiresAt;
updateData.tokenExpiresAt = expiresAt;
}
if (refreshResult.providerSpecificData) {
updateData.providerSpecificData = {
...(conn.providerSpecificData || {}),
...refreshResult.providerSpecificData,
};
// Merge new providerSpecificData and ALWAYS clear the refresh circuit
// breaker streak on a successful refresh.
const mergedProviderData = {
...(conn.providerSpecificData || {}),
...(refreshResult.providerSpecificData || {}),
};
const clearedProviderData = clearRefreshCircuit(mergedProviderData);
if (clearedProviderData !== undefined) {
updateData.providerSpecificData = clearedProviderData;
} else if (refreshResult.providerSpecificData) {
updateData.providerSpecificData = mergedProviderData;
}
await updateProviderConnection(conn.id, updateData);
persistedResult = refreshResult;

View File

@@ -131,6 +131,16 @@ async function startServer() {
startupLog.warn({ error: getErrorMessage(err) }, "Pricing sync could not initialize");
}
}
// Arena ELO sync: opt-in model intelligence from leaderboard data (non-blocking, never fatal)
if (process.env.ARENA_ELO_SYNC_ENABLED === "true") {
try {
const { initArenaEloSync } = await import("./lib/arenaEloSync");
await initArenaEloSync();
} catch (err) {
startupLog.warn({ error: getErrorMessage(err) }, "Arena ELO sync could not initialize");
}
}
}
// Start the server initialization

View File

@@ -12,7 +12,10 @@ const ALL_PROXY_TYPES = [
];
// 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";
// Default ON (opt-out) to match the server: only an explicit falsey value hides SOCKS5.
const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes(
(process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
);
export function buildProxyTypes(socks5Enabled: boolean) {
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
}

View File

@@ -643,6 +643,7 @@ const comboRuntimeConfigSchema = z
maxMessagesForSummary: z.coerce.number().int().min(5).max(100).optional(),
maxComboDepth: z.coerce.number().int().min(1).max(10).optional(),
trackMetrics: z.boolean().optional(),
reasoningTokenBufferEnabled: z.boolean().optional(),
compressionMode: compressionModeSchema.optional(),
failoverBeforeRetry: z.boolean().optional(),
maxSetRetries: z.coerce.number().int().min(0).max(10).optional(),
@@ -1232,6 +1233,12 @@ export const pricingSyncRequestSchema = z
})
.strict();
export const intelligenceSyncRequestSchema = z
.object({
dryRun: z.boolean().optional(),
})
.strict();
const taskRoutingModelMapSchema = z
.object({
coding: z.string().max(200).optional(),

View File

@@ -304,6 +304,32 @@ export const updateSettingsSchema = z.object({
cliproxyapi_fallback_codes: z.string().max(200).optional(),
// CLIProxyAPI model mapping (Record<string, string>)
cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(),
// Model lockout settings
modelLockout: z
.object({
enabled: z.boolean().optional(),
errorCodes: z.array(z.number().int().min(100).max(599)).min(0).max(20).optional(),
baseCooldownMs: z
.number()
.int()
.min(5000, "Must be at least 5,000ms")
.max(600000, "Must be at most 600,000ms (10 min)")
.optional(),
maxCooldownMs: z
.number()
.int()
.min(5000, "Must be at least 5,000ms")
.max(3600000, "Must be at most 3,600,000ms (1 h)")
.optional(),
maxBackoffSteps: z
.number()
.int()
.min(0, "Must be at least 0")
.max(20, "Must be at most 20")
.optional(),
useExponentialBackoff: z.boolean().optional(),
})
.optional(),
});
export const databaseSettingsSchema = z.object(

View File

@@ -40,6 +40,7 @@ import {
getCombosCacheVersion,
getSessionAccountAffinity,
} from "@/lib/localDb";
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
import {
ensureOpenAIStoreSessionFallback,
isOpenAIResponsesStoreEnabled,
@@ -1108,7 +1109,8 @@ async function handleSingleModelChat(
result.error || result.errorCode || "Antigravity stream ended before useful content",
provider,
model,
providerProfile
providerProfile,
{ isCombo }
);
if (shouldFallback && !hasForcedConnection) {
@@ -1157,7 +1159,8 @@ async function handleSingleModelChat(
result.error || ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
provider,
model,
providerProfile
providerProfile,
{ isCombo }
);
if (shouldFallback && !hasForcedConnection) {
@@ -1293,26 +1296,30 @@ async function handleSingleModelChat(
const match = errorStr.match(/today's quota for model ([^,]+)/);
const limitedModel = match ? match[1].trim() : model;
// Lock this model on this connection until tomorrow 00:00
const lockResult = recordModelLockoutFailure(
provider,
credentials.connectionId,
limitedModel,
"quota_exhausted",
result.status,
0,
providerProfile
);
const mlSettings = resolveModelLockoutSettings(runtimeOptions.cachedSettings);
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
// Lock this model on this connection until tomorrow 00:00
const lockResult = recordModelLockoutFailure(
provider,
credentials.connectionId,
limitedModel,
"quota_exhausted",
result.status,
0,
providerProfile,
{ maxCooldownMs: mlSettings.maxCooldownMs }
);
log.info(
"MODEL_DAILY_QUOTA",
JSON.stringify({
connection: credentials.connectionId.slice(0, 8),
model: limitedModel,
cooldownMs: lockResult.cooldownMs,
failureCount: lockResult.failureCount,
})
);
log.info(
"MODEL_DAILY_QUOTA",
JSON.stringify({
connection: credentials.connectionId.slice(0, 8),
model: limitedModel,
cooldownMs: lockResult.cooldownMs,
failureCount: lockResult.failureCount,
})
);
}
dailyQuotaExhausted = true;
}
@@ -1352,6 +1359,7 @@ async function handleSingleModelChat(
{
persistUnavailableState:
!(isCombo && result.status === 429 && (failureKind === "rate_limit" || failureKind === "transient")),
isCombo,
}
);

View File

@@ -430,7 +430,8 @@ export async function executeChatWithBreaker({
String(failure?.message || failure?.code || "stream failure"),
provider,
model,
providerProfile
providerProfile,
{ isCombo }
);
},
})
@@ -617,6 +618,20 @@ export function safeLogEvents({
const rawIpValue = Array.isArray(rawIp) ? rawIp[0] : rawIp;
const clientIp = typeof rawIpValue === "string" ? rawIpValue.split(",")[0].trim() : null;
// Resolve the egress IP (the IP the upstream actually saw) from cache — never
// blocking the request. Warm it in the background for next time. null until
// the first warm completes; direct (no proxy) is also tracked.
let egressIp: string | null = null;
try {
const { getCachedEgressIp, warmEgressIp } = await import("../../lib/proxyEgress");
const { proxyConfigToUrl } = await import("@omniroute/open-sse/utils/proxyDispatcher.ts");
const proxyUrl = proxyInfo?.proxy ? proxyConfigToUrl(proxyInfo.proxy) : null;
egressIp = getCachedEgressIp(proxyUrl);
warmEgressIp(proxyUrl);
} catch {
// egress visibility is best-effort; never break the request path
}
logProxyEvent({
status: result.success
? "success"
@@ -629,6 +644,7 @@ export function safeLogEvents({
provider,
targetUrl: `${provider}/${model}`,
clientIp,
egressIp,
latencyMs: proxyLatency,
error: result.success ? null : result.error || null,
connectionId: credentials.connectionId,

View File

@@ -31,7 +31,7 @@ import {
recordModelLockoutFailure,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts";
import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts";
import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts";
import {
preflightQuota,
isQuotaPreflightEnabled,
@@ -42,7 +42,7 @@ import {
classifyProviderError,
PROVIDER_ERROR_TYPES,
} from "@omniroute/open-sse/services/errorClassifier.ts";
import { looksLikeQuotaExhausted } from "@/shared/utils/classify429";
import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts";
import {
getProviderById,
@@ -1707,6 +1707,8 @@ export async function markAccountUnavailable(
providerProfile = null,
options: {
persistUnavailableState?: boolean;
/** Caller is the combo engine — it records its own model-level lockouts. */
isCombo?: boolean;
} = {}
) {
const currentMutex = markMutexes.get(connectionId) || Promise.resolve();
@@ -1799,7 +1801,7 @@ export async function markAccountUnavailable(
const reason =
status === 404
? "not_found"
: status === 429 && looksLikeQuotaExhausted(errorText)
: status === 429 && fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED
? "quota_exhausted"
: status === 429
? "rate_limited"
@@ -1986,6 +1988,13 @@ export async function markAccountUnavailable(
const persistUnavailableState = options.persistUnavailableState !== false;
if (!persistUnavailableState) {
// Combo-managed transient failure (e.g. 429): keep the connection clean in
// the DB, but record an in-memory model lockout so credential selection
// skips this exact provider+connection+model while it cools down — other
// models on the same connection stay usable.
if (provider && model && cooldownMs > 0) {
lockModel(provider, connectionId, model, reason || "unknown", cooldownMs);
}
await updateProviderConnection(connectionId, {
...baseUpdate,
});

View File

@@ -56,6 +56,7 @@ export interface ComboDefaults {
fallbackDelayMs?: number;
maxComboDepth: number;
trackMetrics: boolean;
reasoningTokenBufferEnabled?: boolean;
concurrencyPerModel?: number;
queueTimeoutMs?: number;
handoffThreshold?: number;

View File

@@ -0,0 +1,152 @@
/**
* Gemini 429 classification integration tests.
*
* Tests the end-to-end classification path for Gemini rate-limit errors
* through OmniRoute. Sends bursts of requests to try to trigger published
* RPM/RPD limits, then verifies the classification is correct.
*
* The tests are "best effort" — if rate limits aren't triggered (Gemini
* may be more generous in practice), the test logs a warning and passes
* rather than failing. The unit tests in account-fallback-service.test.ts
* provide the definitive coverage of classification logic.
*
* Env vars:
* OMNIROUTE_URL — base URL (default http://localhost:20128)
* OMNIROUTE_API_KEY — API key for auth (REQUIRED)
* TEST_GEMINI_RPM_MODEL — RPM model (default gemini/gemma-4-31b-it)
* TEST_GEMINI_RPD_MODEL — RPD model (default gemini/gemini-2.5-flash)
*/
import test from "node:test";
import assert from "node:assert/strict";
const API_KEY = process.env.OMNIROUTE_API_KEY;
const BASE_URL = process.env.OMNIROUTE_URL || "http://localhost:20128";
const RPM_MODEL = process.env.TEST_GEMINI_RPM_MODEL || "gemini/gemma-4-31b-it";
const RPD_MODEL = process.env.TEST_GEMINI_RPD_MODEL || "gemini/gemini-2.5-flash";
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
async function chat(model: string, content: string) {
const res = await fetch(`${BASE_URL}/api/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
body: JSON.stringify({ model, stream: false, messages: [{ role: "user", content }] }),
});
return { status: res.status, body: await res.text() };
}
// ── Test 1: RPM burst ────────────────────────────────────────────────────────
test(
"Gemma 4 RPM burst: try to hit 15 RPM, verify 429 classification if triggered",
{ skip },
async () => {
const BURST = 30;
console.error(`\n[RPM] Sending ${BURST} concurrent requests to ${RPM_MODEL} (15 RPM)...`);
const fetches = Array.from({ length: BURST }, (_, i) =>
chat(RPM_MODEL, `Count to 3. Only numbers. Request ${i}.`)
);
const results = await Promise.all(fetches);
const statuses = results.map((r) => r.status);
const successes = results.filter((r) => r.status === 200);
const rateLimited = results.filter((r) => r.status === 429);
console.error(
`[RPM] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})`
);
assert.ok(successes.length > 0, "expected at least one successful request");
if (rateLimited.length > 0) {
for (const r of rateLimited) {
assert.equal(
r.body.includes("quota_exhausted"),
false,
`RPM 429 should NOT be quota_exhausted: ${r.body.slice(0, 300)}`
);
assert.ok(
r.body.includes("cooling down") || r.body.includes("rate_limit"),
`RPM 429 should mention cooldown: ${r.body.slice(0, 300)}`
);
}
} else {
console.error("[RPM] No 429s received (Gemini may have higher effective RPM for this key)");
console.error(
"[RPM] Classification logic verified by unit tests in account-fallback-service.test.ts"
);
}
}
);
test("Gemma 4 RPM recovery: after 65s, requests should succeed again", { skip }, async () => {
// First send a burst to ensure any cooldown from the previous test has cleared
const warmup = await chat(RPM_MODEL, "ping");
if (warmup.status === 429) {
console.error("[RPM recovery] Previous test left model in cooldown, waiting 65s...");
await new Promise((r) => setTimeout(r, 65_000));
} else {
console.error("[RPM recovery] Model is healthy, skipping wait");
}
const results: Array<{ status: number }> = [];
for (let i = 0; i < 3; i++) {
results.push(await chat(RPM_MODEL, `Hello ${i}.`));
await new Promise((r) => setTimeout(r, 500));
}
const successes = results.filter((r) => r.status === 200);
console.error(`[RPM recovery] ${successes.length}/3 success`);
assert.ok(
successes.length >= 1,
`expected at least 1 recovery, got: ${results.map((r) => r.status).join(",")}`
);
});
// ── Test 2: RPD burst ────────────────────────────────────────────────────────
test(
"Gemini 2.5 Flash RPD burst: try to hit 20 RPD, verify quota_exhausted if triggered",
{ skip },
async () => {
const BURST = 30;
console.error(`\n[RPD] Sending ${BURST} concurrent requests to ${RPD_MODEL} (20 RPD)...`);
const fetches = Array.from({ length: BURST }, (_, i) =>
chat(RPD_MODEL, `Count to 5. Only numbers. Request ${i}.`)
);
const results = await Promise.all(fetches);
const statuses = results.map((r) => r.status);
const successes = results.filter((r) => r.status === 200);
const rateLimited = results.filter((r) => r.status === 429);
console.error(
`[RPD] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})`
);
assert.ok(successes.length > 0, "expected at least one successful request");
const quotaExhausted = rateLimited.filter((r) =>
r.body.toLowerCase().includes("quota_exhausted")
);
if (quotaExhausted.length > 0) {
console.error(`[RPD] ${quotaExhausted.length} quota_exhausted responses ✓`);
} else if (rateLimited.length > 0) {
// Check that non-quota-exhausted 429s are still rate_limit, not some other error
for (const r of rateLimited) {
assert.equal(
r.body.includes("quota_exhausted"),
false,
`RPM 429 should not be quota_exhausted: ${r.body.slice(0, 200)}`
);
}
console.error("[RPD] 429s present but none are quota_exhausted (RPD not yet hit)");
} else {
console.error(
"[RPD] No 429s received (daily quota may not have been reached, or limits are higher)"
);
console.error("[RPD] Classification logic verified by unit tests");
}
}
);

View File

@@ -0,0 +1,253 @@
/**
* Gemini rate-limit classification integration tests.
*
* Tests the full integration between geminiRateLimitTracker (in-memory
* daily/minute counters) and accountFallback.checkFallbackError (429
* classification). No live Gemini API key needed — the tracker counters
* are incremented directly and a synthetic 429 error is passed to
* checkFallbackError.
*
* This validates that the whole pipeline works:
* incrementRequestCount → isRpdExhausted / isRpmExhausted → checkFallbackError
*
* Covers three classification outcomes:
* - RPM exhausted → RATE_LIMIT_EXCEEDED (exponential backoff)
* - RPD exhausted → QUOTA_EXHAUSTED (midnight lockout)
* - Neither exhausted → falls through to generic 429 (RATE_LIMIT_EXCEEDED)
*/
import test from "node:test";
import assert from "node:assert/strict";
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
const {
incrementRequestCount,
getDailyRequestCount,
getMinuteRequestCount,
isRpdExhausted,
isRpmExhausted,
resetCounters,
} = await import("../../open-sse/services/geminiRateLimitTracker.ts");
const PROFILE = {
baseCooldownMs: 125,
useUpstreamRetryHints: false,
maxBackoffSteps: 3,
failureThreshold: 60,
degradationThreshold: 40,
resetTimeoutMs: 5000,
transientCooldown: 125,
rateLimitCooldown: 125,
maxBackoffLevel: 3,
circuitBreakerThreshold: 60,
circuitBreakerReset: 5000,
providerFailureThreshold: 5,
providerFailureWindowMs: 300000,
providerCooldownMs: 60000,
};
const GEMINI_429_BODY = "Resource has been exhausted (e.g. check quota).";
test.beforeEach(() => {
resetCounters();
});
// ── Scenario 1: RPM exhausted, RPD not exhausted → RATE_LIMIT_EXCEEDED ────────
test("Gemini 2.5 Flash 5 RPM hit: 429 classifies as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => {
// gemini-2.5-flash: RPM=5, RPD=20
for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"gemini",
null,
PROFILE
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
});
// ── Scenario 2: RPD exhausted → QUOTA_EXHAUSTED ───────────────────────────────
test("Gemini 2.5 Flash 20 RPD hit: 429 classifies as QUOTA_EXHAUSTED", () => {
for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"gemini",
null,
PROFILE
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
});
// ── Scenario 3: Neither RPM nor RPD exhausted → falls through to generic 429 ──
test("Gemini 2.5 Flash 3 requests (below both): 429 falls through to generic RATE_LIMIT_EXCEEDED", () => {
for (let i = 0; i < 3; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpmExhausted("gemini-2.5-flash"), false);
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"gemini",
null,
PROFILE
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
});
// ── Scenario 4: Both limits exhausted → RPD takes priority → QUOTA_EXHAUSTED ──
test("Gemini 2.5 Flash both RPM and RPD hit: RPD check runs first → QUOTA_EXHAUSTED", () => {
for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"gemini",
null,
PROFILE
);
// RPD check is first in the if-chain, so it takes priority
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
});
// ── Scenario 5: Gemma 4 — 15 RPM hit, 1500 RPD not hit → RATE_LIMIT_EXCEEDED ─
test("Gemma 4 15 RPM hit (RPD=1500 untouched): 429 classifies as RATE_LIMIT_EXCEEDED", () => {
for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it");
assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true);
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini/gemma-4-31b-it",
"gemini",
null,
PROFILE
);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
// ── Scenario 6: Non-Gemini provider bypasses the Gemini-specific check ─────────
test("Non-Gemini provider: tracker state is irrelevant, 429 goes through generic path", () => {
for (let i = 0; i < 30; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
// Provider is "openai" — Gemini-specific check is skipped
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"openai",
null,
PROFILE
);
// Falls through to generic 429 handling → RATE_LIMIT_EXCEEDED
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
// ── Scenario 7: Reset clears state → no longer exhausted ──────────────────────
test("resetCounters clears both RPM and RPD exhaustion", () => {
for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash");
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
resetCounters();
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0);
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0);
assert.equal(isRpmExhausted("gemini-2.5-flash"), false);
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
// Generic 429 path (no model-specific early return)
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini-2.5-flash",
"gemini",
null,
PROFILE
);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});
// ── Scenario 8: RPD exhaustion with Gemma 4 (high RPD, never hit with 15 RPM) ─
test("Gemma 4 1500 RPD exhaustion overrides RPM classification", () => {
// Pump 1500 daily requests to exhaust RPD
for (let i = 0; i < 1500; i++) incrementRequestCount("gemini/gemma-4-31b-it");
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true);
assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true); // 1500 >> 15 RPM
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini/gemma-4-31b-it",
"gemini",
null,
PROFILE
);
// RPD check runs first
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
});
// ── Scenario 9: Unknown model (no RPM/RPD in JSON) → generic 429 path ─────────
test("Unknown Gemini model without published limits falls through to generic 429", () => {
incrementRequestCount("gemini/unknown-model");
assert.equal(isRpmExhausted("gemini/unknown-model"), false);
assert.equal(isRpdExhausted("gemini/unknown-model"), false);
const result = checkFallbackError(
429,
GEMINI_429_BODY,
0,
"gemini/unknown-model",
"gemini",
null,
PROFILE
);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
});

View File

@@ -526,7 +526,7 @@ describe("Page Integration — combos page empty state", () => {
describe("Page Integration — provider test results privacy", () => {
const providersSrc = readProjectFile("src/app/(dashboard)/dashboard/providers/page.tsx");
const providerDetailSrc = readProjectFile(
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx"
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx"
);
it("should mask provider test batch names with the global email privacy toggle", () => {
@@ -541,7 +541,7 @@ describe("Page Integration — provider test results privacy", () => {
it("should mask provider detail test result names with the global email privacy toggle", () => {
assert.ok(
providerDetailSrc,
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist"
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist"
);
assert.match(providerDetailSrc, /const emailsVisible = useEmailPrivacyStore/);
assert.match(
@@ -553,7 +553,7 @@ describe("Page Integration — provider test results privacy", () => {
it("should resolve provider detail metadata through the shared dashboard catalog", () => {
assert.ok(
providerDetailSrc,
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist"
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist"
);
assert.match(providerDetailSrc, /resolveDashboardProviderInfo/);
});
@@ -561,7 +561,7 @@ describe("Page Integration — provider test results privacy", () => {
it("should treat upstream proxy entries as a dedicated management surface", () => {
assert.ok(
providerDetailSrc,
"src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist"
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist"
);
assert.match(providerDetailSrc, /isUpstreamProxyProvider/);
assert.match(providerDetailSrc, /Managed via Upstream Proxy Settings/);

View File

@@ -558,6 +558,7 @@ test("resilience API only exposes configuration, not runtime breaker state", asy
"connectionCooldown",
"legacy",
"providerBreaker",
"providerCooldown",
"requestQueue",
"waitForCooldown",
]);

View File

@@ -30,6 +30,8 @@ const {
isProviderFailureCode,
getProvidersInCooldown,
getProviderBreakerState,
isCreditsExhausted,
CREDITS_EXHAUSTED_SIGNALS,
} = accountFallback;
const { selectAccount } = accountSelector;
@@ -41,7 +43,6 @@ function makeProfile(overrides: Record<string, unknown> = {}): any {
useUpstreamRetryHints: false,
maxBackoffSteps: 3,
failureThreshold: 60,
degradationThreshold: 40,
resetTimeoutMs: 5000,
transientCooldown: 125,
rateLimitCooldown: 125,
@@ -108,7 +109,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset
429,
message,
0,
"gemini-3.5-flash-high",
"gemini-3-flash-agent",
"antigravity",
null,
makeProfile()
@@ -124,7 +125,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset
test("recordModelLockoutFailure honors a multi-day exactCooldownMs (under 30-day cap)", () => {
const provider = "antigravity";
const connectionId = "conn-quota-window";
const model = "gemini-3.5-flash-high";
const model = "gemini-3-flash-agent";
const exactCooldownMs = (164 * 3600 + 27 * 60 + 24) * 1000;
clearModelLock(provider, connectionId, model);
@@ -384,7 +385,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => {
assert.equal(oauthProfile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset);
assert.equal(oauthProfile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
assert.equal(oauthProfile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
assert.equal(oauthProfile.degradationThreshold, PROVIDER_PROFILES.oauth.degradationThreshold);
assert.equal(oauthProfile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset);
const apiKeyProfile = getProviderProfile("openai");
@@ -401,7 +401,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => {
assert.equal(apiKeyProfile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset);
assert.equal(apiKeyProfile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown);
assert.equal(apiKeyProfile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
assert.equal(apiKeyProfile.degradationThreshold, PROVIDER_PROFILES.apikey.degradationThreshold);
assert.equal(apiKeyProfile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset);
});
@@ -610,7 +609,6 @@ test("recordProviderFailure honors runtime provider breaker profile", () => {
try {
const runtimeProfile = {
failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold + 7,
degradationThreshold: PROVIDER_PROFILES.apikey.degradationThreshold + 3,
resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset + 45_000,
};
@@ -618,13 +616,11 @@ test("recordProviderFailure honors runtime provider breaker profile", () => {
const breaker = getCircuitBreaker(provider);
assert.equal(breaker.failureThreshold, runtimeProfile.failureThreshold);
assert.equal(breaker.degradationThreshold, runtimeProfile.degradationThreshold);
assert.equal(breaker.resetTimeout, runtimeProfile.resetTimeoutMs);
assert.equal(isProviderInCooldown(provider), false);
const breakerAfterStatusCheck = getCircuitBreaker(provider);
assert.equal(breakerAfterStatusCheck.failureThreshold, runtimeProfile.failureThreshold);
assert.equal(breakerAfterStatusCheck.degradationThreshold, runtimeProfile.degradationThreshold);
assert.equal(breakerAfterStatusCheck.resetTimeout, runtimeProfile.resetTimeoutMs);
} finally {
clearProviderFailure(provider);
@@ -1115,6 +1111,182 @@ test("checkFallbackError ignores structured error with unrelated code on 400", (
assert.equal(result.shouldFallback, false);
});
// ─── Gemini RPM 429 Classification (CREDITS_EXHAUSTED_SIGNALS fix) ─────
test("isCreditsExhausted returns false for Gemini RPM 429 body text", () => {
const geminiRpmText = "Resource has been exhausted (e.g. check quota).";
assert.equal(isCreditsExhausted(geminiRpmText), false);
});
test("isCreditsExhausted returns true for actual credits-exhausted signals", () => {
assert.equal(isCreditsExhausted("insufficient_quota"), true);
assert.equal(isCreditsExhausted("credits exhausted"), true);
assert.equal(isCreditsExhausted("payment required"), true);
assert.equal(isCreditsExhausted("free tier of the model has been exhausted"), true);
assert.equal(isCreditsExhausted("exceeded your current usage quota"), true);
});
test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => {
// These patterns were removed because they falsely matched Gemini RPM 429 errors
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource has been exhausted"), false);
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource_exhausted"), false);
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("check quota"), false);
});
test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => {
// provider=null → preserveQuota429=true → text quota checks run
// isCreditsExhausted must NOT match Gemini's "Resource has been exhausted"
const result = checkFallbackError(
429,
"Resource has been exhausted (e.g. check quota).",
0,
null,
null,
null,
makeProfile()
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
assert.equal(result.creditsExhausted, undefined);
assert.equal(result.dailyQuotaExhausted, undefined);
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
});
test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED for API-key provider", () => {
// provider="gemini" → preserveQuota429=false → status-based rule applies
const result = checkFallbackError(
429,
"Resource has been exhausted (e.g. check quota).",
0,
null,
"gemini",
null,
makeProfile()
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
assert.equal(result.cooldownMs, 125); // makeProfile().baseCooldownMs
});
test("checkFallbackError still classifies genuine OAuth quota-exhausted text as QUOTA_EXHAUSTED", () => {
// Regression: OAuth providers must still get QUOTA_EXHAUSTED for actual quota messages
const result = checkFallbackError(
429,
"Coding Plan hour quota has been exceeded",
0,
null,
"codex",
null,
makeProfile()
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
});
test("checkFallbackError preserves daily-quota exhaustion for non-429 status codes", () => {
// Non-429 status codes with daily quota text must still be QUOTA_EXHAUSTED
const result = checkFallbackError(
402,
"You have exceeded today's quota, please try again tomorrow"
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(result.dailyQuotaExhausted, true);
});
// ─── Gemini 429 → Model Lockout: rate_limited (not quota_exhausted) ────
test("Gemini RPM 429: recordModelLockoutFailure uses exponential backoff for rate_limited reason", () => {
const originalNow = Date.now;
const now = 1_700_000_000_000;
Date.now = () => now;
const provider = "gemini";
const connectionId = "test-conn-gemini-rpm";
const model = "gemini/gemma-4-31b-it";
try {
clearModelLock(provider, connectionId, model);
const profile = makeProfile({
baseCooldownMs: 5000,
transientCooldown: 5000,
rateLimitCooldown: 5000,
});
// auth.ts flow: 429 + fallbackResult.reason=RATE_LIMIT_EXCEEDED
// → reason="rate_limited" → recordModelLockoutFailure
const first = recordModelLockoutFailure(
provider,
connectionId,
model,
"rate_limited",
429,
0,
profile
);
assert.equal(first.failureCount, 1);
assert.equal(first.cooldownMs, 5000, "first failure: 5s base cooldown");
const second = recordModelLockoutFailure(
provider,
connectionId,
model,
"rate_limited",
429,
0,
profile
);
assert.equal(second.failureCount, 2);
assert.equal(second.cooldownMs, 10000, "second failure: 10s exponential backoff");
assert.equal(isModelLocked(provider, connectionId, model), true);
clearModelLock(provider, connectionId, model);
} finally {
Date.now = originalNow;
clearModelLock("gemini", "test-conn-gemini-rpm", "gemini/gemma-4-31b-it");
}
});
test("Gemini RPD (quota_exhausted) still triggers midnight lockout in recordModelLockoutFailure", () => {
// Regression: real daily quota exhaustion must still produce midnight reset
const originalNow = Date.now;
const testDate = new Date();
testDate.setHours(12, 0, 0, 0);
const now = testDate.getTime();
Date.now = () => now;
const provider = "gemini";
const connectionId = "test-conn-gemini-rpd";
const model = "gemini/gemma-4-31b-it";
try {
clearModelLock(provider, connectionId, model);
const profile = makeProfile();
const result = recordModelLockoutFailure(
provider,
connectionId,
model,
"quota_exhausted",
429,
0,
profile
);
// Must lock until midnight, NOT exponential backoff
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const expected = tomorrow.getTime() - now;
assert.ok(
Math.abs(result.cooldownMs - expected) <= 300_000,
`cooldown should be until tomorrow (expected ~${expected}, got ${result.cooldownMs})`
);
clearModelLock(provider, connectionId, model);
} finally {
Date.now = originalNow;
clearModelLock("gemini", "test-conn-gemini-rpd", "gemini/gemma-4-31b-it");
}
});
// ─── G-02: X-Omni-Fallback-Hint: connection_cooldown ─────────────────────────
// When 9router executor signals a supervisor-not-running 503, checkFallbackError
// must return 5s cooldown with skipProviderBreaker:true — not trip the circuit breaker.
@@ -1193,6 +1365,122 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui
clearProviderFailure("9router"); // cleanup
});
test("recordModelLockoutFailure caps cooldown at BACKOFF_CONFIG.max to prevent absurdly long lockouts", () => {
const originalNow = Date.now;
let now = 1_700_000_000_000;
Date.now = () => now;
try {
const provider = "openai";
const connectionId = "conn-capped";
const model = "gpt-5-trillium";
clearModelLock(provider, connectionId, model);
// Fire 9 consecutive failures so the backoff exceeds the 120s cap
// baseCooldownMs=1000 (getQuotaCooldown(0)), failure 9: 1000*2^8=256000 > 120000
let lastResult;
for (let i = 0; i < 9; i++) {
lastResult = recordModelLockoutFailure(
provider,
connectionId,
model,
"rate_limited",
429,
0,
null
);
now += 50; // each failure within the reset window
}
assert.ok(
lastResult.cooldownMs <= 120_000,
`cooldown ${lastResult.cooldownMs}ms should not exceed BACKOFF_CONFIG.max (120000ms)`
);
assert.equal(lastResult.cooldownMs, 120_000);
assert.equal(lastResult.failureCount, 9);
clearModelLock(provider, connectionId, model);
} finally {
Date.now = originalNow;
}
});
test("recordModelLockoutFailure groups provider aliases under canonical provider", () => {
const providerAlias = "cx";
const providerCanonical = "codex";
const connectionId = "conn-alias-test";
const model = "gpt-5.5";
clearModelLock(providerCanonical, connectionId, model);
clearModelLock(providerAlias, connectionId, model);
const result1 = recordModelLockoutFailure(
providerAlias,
connectionId,
model,
"rate_limited",
429,
1000,
null
);
assert.equal(isModelLocked(providerAlias, connectionId, model), true);
assert.equal(isModelLocked(providerCanonical, connectionId, model), true);
clearModelLock(providerCanonical, connectionId, model);
});
test("recordModelLockoutFailure escalates backoff correctly after cooldown expiration (long interval)", () => {
const originalNow = Date.now;
let now = 1_700_000_000_000;
Date.now = () => now;
try {
const provider = "openai";
const connectionId = "conn-long-interval";
const model = "gpt-5-escalate";
clearModelLock(provider, connectionId, model);
const profile = makeProfile({
baseCooldownMs: 120000,
resetTimeoutMs: 30000,
});
const first = recordModelLockoutFailure(
provider,
connectionId,
model,
"rate_limited",
429,
120000,
profile,
{ maxCooldownMs: 1800000 }
);
assert.equal(first.failureCount, 1);
assert.equal(first.cooldownMs, 120000);
now += 130000;
const second = recordModelLockoutFailure(
provider,
connectionId,
model,
"rate_limited",
429,
120000,
profile,
{ maxCooldownMs: 1800000 }
);
assert.equal(second.failureCount, 2);
assert.equal(second.cooldownMs, 240000);
clearModelLock(provider, connectionId, model);
} finally {
Date.now = originalNow;
}
});
// ── Custom banned signals (PR #3454) ──────────────────────────────────────────
// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific
// permanent-ban phrasing via Settings → Security. These persist in the

View File

@@ -0,0 +1,41 @@
/**
* #3696 — antigravity/agy gemini-3.1-pro-high / gemini-3.1-pro-low were being collapsed
* to the bare upstream id `gemini-3.1-pro`, losing the tier distinction.
*
* Wire evidence (captured by maintainer via `agy --model gemini-3.1-pro-high --log-file`):
* - `gemini-3.1-pro-high` sent literally to `/v1internal:streamGenerateContent` → 200 OK
* - `gemini-3.1-pro-low` sent literally → 200 OK
*
* CONCLUSION: the upstream ACCEPTS the suffixed ids directly. The old assumption in #3229
* ("upstream rejects the suffix for gemini-3.x") was refuted by this wire capture.
* The collapse aliases must be removed so the tier-specific ids reach the upstream.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
ANTIGRAVITY_PUBLIC_MODELS,
resolveAntigravityModelId,
} from "../../open-sse/config/antigravityModelAliases.ts";
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-high through unchanged", () => {
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high");
});
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchanged", () => {
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low");
});
test("(#3696) no two ANTIGRAVITY_PUBLIC_MODELS entries resolve to the same upstream id", () => {
const seen = new Map<string, string>();
const collisions: string[] = [];
for (const model of ANTIGRAVITY_PUBLIC_MODELS) {
const upstream = resolveAntigravityModelId(model.id);
if (seen.has(upstream)) {
collisions.push(`${model.id} and ${seen.get(upstream)} both resolve to "${upstream}"`);
} else {
seen.set(upstream, model.id);
}
}
assert.deepEqual(collisions, [], `upstream-id collisions: ${collisions.join("; ")}`);
});

View File

@@ -3,11 +3,14 @@
* `chat.completion` envelope.
*
* Two parts:
* (a) the budget-suffix ids had no alias, so `resolveAntigravityModelId` sent them
* verbatim to upstream (which rejects -high/-low for gemini-3.x) → alias to plain.
* (a) ORIGINAL FIX (#3229): aliased -high/-low to plain `gemini-3.1-pro` (upstream
* was believed to reject the suffix). SUPERSEDED BY #3696: wire capture via
* `agy --model gemini-3.1-pro-high --log-file` confirmed upstream returns 200 OK
* with the suffixed id; the collapse aliases are now removed so the tier-specific
* id passes through verbatim.
* (b) the non-stream branch fed the 4xx response into the SSE collector, producing a
* synthetic `{"object":"chat.completion","content":""}` instead of a real error →
* build a proper sanitized error body for non-ok upstream responses.
* build a proper sanitized error body for non-ok upstream responses. (Still valid.)
*/
import test from "node:test";
import assert from "node:assert/strict";
@@ -15,9 +18,11 @@ import assert from "node:assert/strict";
import { resolveAntigravityModelId } from "../../open-sse/config/antigravityModelAliases.ts";
import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts";
test("(a) agy gemini-3.1-pro -high/-low budget suffixes alias to the plain upstream id", () => {
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro");
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro");
test("(a) agy gemini-3.1-pro -high/-low budget suffixes pass through to upstream unchanged (#3696)", () => {
// #3696: wire capture confirmed upstream accepts the suffixed ids verbatim.
// The old #3229 collapse aliases ("gemini-3.1-pro-high" → "gemini-3.1-pro") were removed.
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high");
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low");
// plain id stays plain
assert.equal(resolveAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro");
});

View File

@@ -0,0 +1,827 @@
/**
* Unit tests for src/lib/arenaEloSync.ts
*
* Uses Node.js native test runner. All external fetch calls are mocked.
* DB functions use a real in-memory SQLite instance via node:sqlite (DatabaseSync),
* injected through the core module's globalThis.__omnirouteDb singleton.
* backupDbFile is called during first sync but safely no-ops (no file on disk).
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-arena-elo-test-"),
);
process.env.DATA_DIR = TEST_DATA_DIR;
const MIGRATION_SQL = fs.readFileSync(
path.resolve(
import.meta.dirname ?? __dirname,
"../../src/lib/db/migrations/097_model_intelligence.sql",
),
"utf8",
);
import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory";
import type { SqliteAdapter } from "../../src/lib/db/adapters/types";
const core = await import("../../src/lib/db/core.ts");
const {
normalizeModelName,
transformToModelIntelligence,
fetchArenaLeaderboards,
syncArenaElo,
getArenaEloSyncStatus,
stopArenaEloSync,
} = await import("../../src/lib/arenaEloSync.ts");
import type {
ArenaLeaderboardData,
ArenaLeaderboardMap,
ArenaModelEntry,
} from "../../src/lib/arenaEloSync.ts";
const originalFetch = globalThis.fetch;
function mockFetch(
impl: (url: string, opts?: RequestInit) => Promise<Response>,
): void {
globalThis.fetch = impl as typeof fetch;
}
function restoreFetch(): void {
globalThis.fetch = originalFetch;
}
function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
function makeModelEntry(overrides: Partial<ArenaModelEntry> = {}): ArenaModelEntry {
return {
rank: 1,
model: "anthropic/claude-sonnet",
vendor: "Anthropic",
score: 1350,
ci: 10,
votes: 5000,
license: "proprietary",
...overrides,
};
}
function makeLeaderboardData(
models: ArenaModelEntry[] = [],
category = "text",
): ArenaLeaderboardData {
return {
meta: { leaderboard: category, model_count: models.length },
models,
};
}
function makeLeaderboardMap(
categories: Partial<Record<string, ArenaModelEntry[]>>,
): ArenaLeaderboardMap {
const map: ArenaLeaderboardMap = {};
for (const [cat, models] of Object.entries(categories)) {
map[cat] = makeLeaderboardData(models ?? [], cat);
}
return map;
}
let testAdapter: SqliteAdapter;
function createTestAdapter(): SqliteAdapter {
const patchedSql = MIGRATION_SQL.replace(
/\n\s*synced_at TEXT NOT NULL DEFAULT \(datetime\('now'\)\)/,
"\n synced_at TEXT NOT NULL",
);
const adapter = tryOpenSync(":memory:")!;
adapter.exec(patchedSql);
return adapter;
}
function countArenaEloEntries(): number {
const row = testAdapter
.prepare("SELECT COUNT(*) as cnt FROM model_intelligence WHERE source = 'arena_elo'")
.get() as Record<string, unknown> | undefined;
return Number(row?.cnt ?? 0);
}
function getAllEntries(): Array<Record<string, unknown>> {
return testAdapter
.prepare("SELECT * FROM model_intelligence WHERE source = 'arena_elo' ORDER BY model, category")
.all() as Array<Record<string, unknown>>;
}
beforeEach(() => {
core.resetDbInstance();
testAdapter = createTestAdapter();
globalThis.__omnirouteDb = testAdapter as never;
stopArenaEloSync();
});
afterEach(() => {
restoreFetch();
stopArenaEloSync();
delete globalThis.__omnirouteDb;
});
// ═══════════════════════════════════════════════════════════
// 1. normalizeModelName()
// ═══════════════════════════════════════════════════════════
describe("normalizeModelName()", () => {
it("strips 'anthropic/' vendor prefix", () => {
assert.strictEqual(
normalizeModelName("anthropic/claude-opus-4-6-thinking"),
"claude-opus-4-6-thinking",
);
});
it("strips 'openai/' vendor prefix", () => {
assert.strictEqual(normalizeModelName("openai/gpt-5.5"), "gpt-5.5");
});
it("strips 'google/' vendor prefix", () => {
assert.strictEqual(normalizeModelName("google/gemini-3-flash"), "gemini-3-flash");
});
it("strips 'meta/' vendor prefix", () => {
assert.strictEqual(normalizeModelName("meta/llama-4"), "llama-4");
});
it("strips 'deepseek/' vendor prefix", () => {
assert.strictEqual(normalizeModelName("deepseek/deepseek-r1"), "deepseek-r1");
});
it("strips 'xai/' vendor prefix", () => {
assert.strictEqual(normalizeModelName("xai/grok-4"), "grok-4");
});
it("lowercases the model name", () => {
assert.strictEqual(normalizeModelName("Claude-Sonnet-4"), "claude-sonnet-4");
});
it("lowercases vendor prefix before matching", () => {
assert.strictEqual(normalizeModelName("OpenAI/GPT-5.5"), "gpt-5.5");
});
it("returns name unchanged when no vendor prefix matches", () => {
assert.strictEqual(normalizeModelName("my-custom-model"), "my-custom-model");
});
});
// ═══════════════════════════════════════════════════════════
// 2. transformToModelIntelligence()
// ═══════════════════════════════════════════════════════════
describe("transformToModelIntelligence()", () => {
it("ELO normalization: 1500 ELO (max) with range 1000-1500 → score ≈ 0.98", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }),
makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }),
],
});
const entries = transformToModelIntelligence(data);
const topEntry = entries.find(
(e) => e.model === "top-model" && e.category === "default",
);
assert.ok(topEntry);
// taskFit = 0.4 + 0.58 * ((1500-1000) / 500) = 0.98
assert.ok(Math.abs(topEntry.score - 0.98) < 0.001, `got ${topEntry.score}`);
});
it("ELO normalization: 1000 ELO (min) with range 1000-1500 → score ≈ 0.40", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }),
makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }),
],
});
const entries = transformToModelIntelligence(data);
const lowEntry = entries.find(
(e) => e.model === "low-model" && e.category === "default",
);
assert.ok(lowEntry);
// taskFit = 0.4 + 0.58 * ((1000-1000) / 500) = 0.4
assert.ok(Math.abs(lowEntry.score - 0.4) < 0.001, `got ${lowEntry.score}`);
});
it("votes < 100 → confidence='low'", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "sparse-model", score: 1200, votes: 50, rank: 5 }),
makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }),
],
});
const entries = transformToModelIntelligence(data);
const entry = entries.find(
(e) => e.model === "sparse-model" && e.category === "default",
);
assert.ok(entry);
assert.strictEqual(entry.confidence, "low");
});
it("votes >= 1000 → confidence='medium'", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "mid-model", score: 1200, votes: 1500, rank: 3 }),
makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }),
],
});
const entries = transformToModelIntelligence(data);
const entry = entries.find(
(e) => e.model === "mid-model" && e.category === "default",
);
assert.ok(entry);
assert.strictEqual(entry.confidence, "medium");
});
it("votes >= 5000 → confidence='high'", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "popular-model", score: 1300, votes: 8000, rank: 1 }),
makeModelEntry({ model: "baseline", score: 1100, votes: 3000, rank: 5 }),
],
});
const entries = transformToModelIntelligence(data);
const entry = entries.find(
(e) => e.model === "popular-model" && e.category === "default",
);
assert.ok(entry);
assert.strictEqual(entry.confidence, "high");
});
it("category mapping: 'text' → [default, review, documentation, debugging]", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
const categories = entries
.filter((e) => e.model === "text-model")
.map((e) => e.category)
.sort();
assert.deepStrictEqual(categories, [
"debugging",
"default",
"documentation",
"review",
]);
});
it("category mapping: 'code' → [coding]", () => {
const data = makeLeaderboardMap({
code: [
makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
const categories = entries
.filter((e) => e.model === "code-model")
.map((e) => e.category);
assert.deepStrictEqual(categories, ["coding"]);
});
it("expires_at is set to ~7 days in the future", () => {
const before = Date.now();
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
const after = Date.now();
const entry = entries.find(
(e) => e.model === "test-model" && e.category === "default",
);
assert.ok(entry);
assert.ok(entry.expiresAt);
const expiresMs = new Date(entry.expiresAt).getTime();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
assert.ok(
expiresMs >= before + sevenDaysMs - 2000,
`expiresAt too early: ${entry.expiresAt}`,
);
assert.ok(
expiresMs <= after + sevenDaysMs + 2000,
`expiresAt too late: ${entry.expiresAt}`,
);
});
it("source is 'arena_elo' for all entries", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
for (const entry of entries) {
assert.strictEqual(entry.source, "arena_elo");
}
});
it("expands model aliases for known models", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({
model: "anthropic/claude-opus-4-6-thinking",
score: 1400,
votes: 5000,
rank: 1,
}),
],
});
const entries = transformToModelIntelligence(data);
const models = entries.map((e) => e.model);
assert.ok(models.includes("claude-opus-4-6-thinking"));
assert.ok(models.includes("claude-opus-4"));
assert.ok(models.includes("anthropic/claude-opus-4"));
});
it("empty leaderboard → no entries", () => {
const data = makeLeaderboardMap({ text: [] });
const entries = transformToModelIntelligence(data);
assert.strictEqual(entries.length, 0);
});
it("skips unknown leaderboard categories (e.g. vision)", () => {
const data = makeLeaderboardMap({
vision: [
makeModelEntry({ model: "vision-model", score: 1300, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
assert.strictEqual(entries.length, 0);
});
it("preserves eloRaw from the leaderboard", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "test-model", score: 1337, votes: 5000, rank: 1 }),
makeModelEntry({ model: "other-model", score: 1100, votes: 3000, rank: 2 }),
],
});
const entries = transformToModelIntelligence(data);
const entry = entries.find(
(e) => e.model === "test-model" && e.category === "default",
);
assert.ok(entry);
assert.strictEqual(entry.eloRaw, 1337);
});
it("handles single-model leaderboard (eloRange = 1, avoids division by zero)", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "only-model", score: 1200, votes: 5000, rank: 1 }),
],
});
const entries = transformToModelIntelligence(data);
assert.ok(entries.length > 0);
const entry = entries.find(
(e) => e.model === "only-model" && e.category === "default",
);
assert.ok(entry);
assert.ok(Math.abs(entry.score - 0.4) < 0.001);
});
it("rounds score to 4 decimal places", () => {
const data = makeLeaderboardMap({
text: [
makeModelEntry({ model: "model-a", score: 1300, votes: 200, rank: 1 }),
makeModelEntry({ model: "model-b", score: 1000, votes: 200, rank: 2 }),
],
});
const entries = transformToModelIntelligence(data);
for (const entry of entries) {
const str = entry.score.toString();
const dot = str.indexOf(".");
if (dot !== -1) {
assert.ok(str.length - dot - 1 <= 4, `score ${entry.score} > 4 decimals`);
}
}
});
});
// ═══════════════════════════════════════════════════════════
// 3. fetchArenaLeaderboards()
// ═══════════════════════════════════════════════════════════
describe("fetchArenaLeaderboards()", () => {
it("successful fetch with valid JSON returns both leaderboards", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
const codeData = makeLeaderboardData(
[makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 })],
"code",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code")) return jsonResponse(codeData);
return new Response("Not found", { status: 404 });
});
const result = await fetchArenaLeaderboards();
assert.ok(result.text);
assert.ok(result.code);
assert.strictEqual(result.text.models.length, 1);
assert.strictEqual(result.code.models.length, 1);
});
it("failed fetch (non-200 status) throws with descriptive message", async () => {
mockFetch(async () => {
return new Response("Internal Server Error", { status: 500 });
});
await assert.rejects(
() => fetchArenaLeaderboards(),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
return true;
},
);
});
it("all fetches fail (network error) → throws", async () => {
mockFetch(async () => {
throw new Error("Network error: ECONNREFUSED");
});
await assert.rejects(
() => fetchArenaLeaderboards(),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
return true;
},
);
});
it("succeeds when one category fails but another succeeds", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return new Response("Error", { status: 500 });
return new Response("Not found", { status: 404 });
});
const result = await fetchArenaLeaderboards();
assert.ok(result.text);
assert.strictEqual(result.code, undefined);
});
it("invalid JSON in response → throws when all responses are invalid", async () => {
mockFetch(async () => {
return new Response("not-json", {
status: 200,
headers: { "Content-Type": "text/plain" },
});
});
await assert.rejects(
() => fetchArenaLeaderboards(),
(err: unknown) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
return true;
},
);
});
});
// ═══════════════════════════════════════════════════════════
// 4. syncArenaElo()
// ═══════════════════════════════════════════════════════════
describe("syncArenaElo()", () => {
it("happy path: returns success=true with correct modelCount", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "gpt-5.5", score: 1200, votes: 5000, rank: 1 })],
"text",
);
const codeData = makeLeaderboardData(
[makeModelEntry({ model: "deepseek-r1", score: 1300, votes: 5000, rank: 1 })],
"code",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code")) return jsonResponse(codeData);
return new Response("Not found", { status: 404 });
});
const result = await syncArenaElo();
assert.strictEqual(result.success, true);
assert.strictEqual(result.source, "arena_elo");
assert.ok(result.modelCount > 0);
const dbCount = countArenaEloEntries();
assert.ok(dbCount > 0);
assert.strictEqual(dbCount, result.modelCount);
});
it("happy path: entries in DB have correct source and categories", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "unique-test-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
await syncArenaElo();
const entries = getAllEntries().filter((e) => String(e.model) === "unique-test-model");
const categories = entries.map((e) => String(e.category)).sort();
assert.deepStrictEqual(categories, [
"debugging",
"default",
"documentation",
"review",
]);
for (const entry of entries) {
assert.strictEqual(entry.source, "arena_elo");
}
});
it("dryRun=true → does not call bulkUpsertModelIntelligence", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
const result = await syncArenaElo(true);
assert.strictEqual(result.success, true);
assert.ok(result.modelCount > 0);
const dbCount = countArenaEloEntries();
assert.strictEqual(dbCount, 0, "dryRun should not write to DB");
});
it("dryRun=true does not update lastSyncTime", async () => {
// Reset module-level state by observing that before this test's dryRun,
// lastSync should remain unchanged from whatever it was.
// Since we can't reset module-level vars, we verify that a dryRun
// after a non-dryRun doesn't overwrite the model count.
const statusBefore = getArenaEloSyncStatus();
const textData = makeLeaderboardData(
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
await syncArenaElo(true);
const statusAfter = getArenaEloSyncStatus();
// dryRun should not change the modelCount
assert.strictEqual(statusAfter.lastSyncModelCount, statusBefore.lastSyncModelCount);
});
it("API failure → returns success=false with error", async () => {
mockFetch(async () => {
throw new Error("Network down");
});
const result = await syncArenaElo();
assert.strictEqual(result.success, false);
assert.strictEqual(result.source, "arena_elo");
assert.strictEqual(result.modelCount, 0);
assert.ok(result.error);
assert.ok(
result.error!.includes("All Arena leaderboard fetches failed"),
`unexpected: ${result.error}`,
);
});
it("calls deleteExpiredIntelligence before writing new entries", async () => {
testAdapter.prepare(
"INSERT INTO model_intelligence (model, source, category, score, elo_raw, confidence, synced_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
).run("old-model", "arena_elo", "default", 0.5, 1000, "low", "2025-01-01T00:00:00Z", "2020-01-01T00:00:00Z");
assert.strictEqual(countArenaEloEntries(), 1);
const textData = makeLeaderboardData(
[makeModelEntry({ model: "new-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
const syncResult = await syncArenaElo();
assert.strictEqual(syncResult.success, true);
const expiredEntry = testAdapter
.prepare("SELECT * FROM model_intelligence WHERE model = 'old-model'")
.get();
assert.strictEqual(expiredEntry, undefined);
});
it("handles empty leaderboard gracefully (0 entries, no DB write)", async () => {
mockFetch(async (url: string) => {
if (url.includes("name=text"))
return jsonResponse(makeLeaderboardData([], "text"));
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
const result = await syncArenaElo();
assert.strictEqual(result.success, true);
assert.strictEqual(result.modelCount, 0);
assert.strictEqual(countArenaEloEntries(), 0);
});
it("updates lastSyncTime after successful sync", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
await syncArenaElo();
const status = getArenaEloSyncStatus();
assert.ok(status.lastSync);
assert.ok(status.lastSyncModelCount > 0);
});
it("model aliases are stored in DB alongside canonical names", async () => {
const textData = makeLeaderboardData(
[makeModelEntry({
model: "anthropic/claude-opus-4-6-thinking",
score: 1400,
votes: 5000,
rank: 1,
})],
"text",
);
mockFetch(async (url: string) => {
if (url.includes("name=text")) return jsonResponse(textData);
if (url.includes("name=code"))
return jsonResponse(makeLeaderboardData([], "code"));
return new Response("Not found", { status: 404 });
});
await syncArenaElo();
const entries = getAllEntries();
const models = entries.map((e) => String(e.model));
assert.ok(models.includes("claude-opus-4-6-thinking"));
assert.ok(models.includes("claude-opus-4"));
});
});
// ═══════════════════════════════════════════════════════════
// 5. getArenaEloSyncStatus()
// ═══════════════════════════════════════════════════════════
describe("getArenaEloSyncStatus()", () => {
it("returns correct structure with all expected keys", () => {
const status = getArenaEloSyncStatus();
assert.ok("enabled" in status);
assert.ok("lastSync" in status);
assert.ok("lastSyncModelCount" in status);
assert.ok("nextSync" in status);
assert.ok("intervalMs" in status);
assert.ok("sources" in status);
});
it("returns sources containing 'arena_elo'", () => {
const status = getArenaEloSyncStatus();
assert.deepStrictEqual(status.sources, ["arena_elo"]);
});
it("returns intervalMs as a positive number", () => {
const status = getArenaEloSyncStatus();
assert.ok(typeof status.intervalMs === "number");
assert.ok(status.intervalMs > 0);
});
it("returns lastSyncModelCount as 0 before any non-dryRun sync completes in this suite context", () => {
// Module-level lastSyncTime may leak from earlier tests in the suite,
// but the structural fields are always present.
const status = getArenaEloSyncStatus();
assert.ok(typeof status.lastSync === "string" || status.lastSync === null);
assert.ok(typeof status.lastSyncModelCount === "number");
assert.ok(typeof status.nextSync === "string" || status.nextSync === null);
});
it("reflects ARENA_ELO_SYNC_ENABLED env var", () => {
const original = process.env.ARENA_ELO_SYNC_ENABLED;
process.env.ARENA_ELO_SYNC_ENABLED = "true";
const enabledStatus = getArenaEloSyncStatus();
assert.strictEqual(enabledStatus.enabled, true);
process.env.ARENA_ELO_SYNC_ENABLED = "false";
const disabledStatus = getArenaEloSyncStatus();
assert.strictEqual(disabledStatus.enabled, false);
if (original !== undefined) {
process.env.ARENA_ELO_SYNC_ENABLED = original;
} else {
delete process.env.ARENA_ELO_SYNC_ENABLED;
}
});
});
// ═══════════════════════════════════════════════════════════
// 6. stopArenaEloSync()
// ═══════════════════════════════════════════════════════════
describe("stopArenaEloSync()", () => {
it("does not throw when no timer is running", () => {
assert.doesNotThrow(() => stopArenaEloSync());
});
it("can be called multiple times without error", () => {
stopArenaEloSync();
assert.doesNotThrow(() => stopArenaEloSync());
assert.doesNotThrow(() => stopArenaEloSync());
});
});

View File

@@ -0,0 +1,278 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// #3685 — When a Claude stream completes with lifecycle events (message_start /
// message_delta / message_stop) but zero content_block events, the router was
// injecting a synthetic success message instead of failing over. Fix: emit a
// real SSE error event and call controller.error() so the combo layer can retry.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3685-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { getPendingRequests, clearPendingRequests } =
await import("../../src/lib/usage/usageHistory.ts");
const enc = new TextEncoder();
async function readTransformed(chunks: string[], options: Record<string, unknown>) {
const source = new ReadableStream<Uint8Array>({
start(c) {
for (const chunk of chunks) c.enqueue(enc.encode(chunk));
c.close();
},
});
return new Response(source.pipeThrough(createSSEStream(options as any))).text();
}
test.after(() => {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
for (const entry of fs.readdirSync(TEST_DATA_DIR)) {
fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true });
}
}
});
// --- Golden path: empty content-block stream (the bug case) should now error ---
test("#3685 passthrough: empty Claude SSE (no content_block) rejects the stream", async () => {
let failurePayload: Record<string, unknown> | null = null;
await assert.rejects(
readTransformed(
[
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_3685",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 5, output_tokens: 0 },
},
})}\n\n`,
`event: message_delta\ndata: ${JSON.stringify({
type: "message_delta",
delta: { stop_reason: "content_filter", stop_sequence: null },
usage: { output_tokens: 1 },
})}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.CLAUDE,
provider: "anthropic",
model: "claude-sonnet-4-6",
body: { messages: [{ role: "user", content: "hello" }] },
onFailure(p: Record<string, unknown>) {
failurePayload = p;
},
}
),
/empty response/i,
"stream should reject with empty-response error"
);
assert.ok(failurePayload, "onFailure callback must be invoked");
assert.equal((failurePayload as any).status, 502);
assert.match((failurePayload as any).message as string, /empty response/i);
});
test("#3685 passthrough: empty Claude SSE emits event: error SSE line before aborting", async () => {
const collected: string[] = [];
const source = new ReadableStream<Uint8Array>({
start(c) {
const chunks = [
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_3685b",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [],
stop_reason: null,
usage: { input_tokens: 5, output_tokens: 0 },
},
})}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
];
for (const chunk of chunks) c.enqueue(enc.encode(chunk));
c.close();
},
});
const transformed = source.pipeThrough(
createSSEStream({
mode: "passthrough",
sourceFormat: FORMATS.CLAUDE,
provider: "anthropic",
model: "claude-sonnet-4-6",
body: { messages: [{ role: "user", content: "hello" }] },
} as any)
);
const reader = transformed.getReader();
const dec = new TextDecoder();
let gotError = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
collected.push(dec.decode(value));
}
} catch {
gotError = true;
}
assert.ok(gotError, "stream reader should throw on error");
const full = collected.join("");
assert.match(full, /event: error/, "SSE error event must be emitted before abort");
assert.doesNotMatch(
full,
/event: content_block_start/,
"no synthetic content_block must be emitted"
);
});
// --- Regression guards: excluded cases must NOT be turned into errors ---
test("#3685 regression: stream with content_block events is NOT turned into an error", async () => {
// A max_tokens:1 ping returns exactly 1 token → content_block events exist.
// hasContentBlock = true → shouldInjectClaudeEmptyResponseOnFlush = false → no error.
const text = await readTransformed(
[
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_ping",
type: "message",
role: "assistant",
model: "claude-haiku-4-5",
content: [],
stop_reason: null,
usage: { input_tokens: 3, output_tokens: 0 },
},
})}\n\n`,
`event: content_block_start\ndata: ${JSON.stringify({
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
})}\n\n`,
`event: content_block_delta\ndata: ${JSON.stringify({
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "Hi" },
})}\n\n`,
`event: content_block_stop\ndata: ${JSON.stringify({
type: "content_block_stop",
index: 0,
})}\n\n`,
`event: message_delta\ndata: ${JSON.stringify({
type: "message_delta",
delta: { stop_reason: "max_tokens", stop_sequence: null },
usage: { output_tokens: 1 },
})}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.CLAUDE,
provider: "anthropic",
model: "claude-haiku-4-5",
body: { messages: [{ role: "user", content: "ping" }] },
}
);
assert.match(text, /Hi/, "content must pass through untouched");
assert.doesNotMatch(text, /event: error/, "must NOT emit an error event");
});
test("#3685 pending request counter is decremented when empty-stream error fires", async () => {
// Regression guard for the bug caught by Cursor/Codex: emitClaudeEmptyStreamErrorAndAbort
// was marking the error with PENDING_REQUEST_CLEARED_MARKER but never calling
// trackPendingRequest(..., false). streamHandler.clearPendingRequest() trusts the marker
// and skips its own decrement, leaving the counter permanently inflated.
clearPendingRequests();
const { trackPendingRequest } = await import("../../src/lib/usage/usageHistory.ts");
// Simulate the stream engine incrementing the counter at request start.
trackPendingRequest("claude-sonnet-4-6", "anthropic", "conn-test", true);
assert.equal(
getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"],
1,
"pending count should start at 1 after request begins"
);
await assert.rejects(
readTransformed(
[
`event: message_start\ndata: ${JSON.stringify({
type: "message_start",
message: {
id: "msg_pending_test",
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [],
stop_reason: null,
usage: { input_tokens: 3, output_tokens: 0 },
},
})}\n\n`,
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.CLAUDE,
provider: "anthropic",
model: "claude-sonnet-4-6",
connectionId: "conn-test",
body: { messages: [{ role: "user", content: "hello" }] },
}
),
/empty response/i
);
// emitClaudeEmptyStreamErrorAndAbort must call trackPendingRequest(..., false) so the
// counter is back to 0 after the stream terminates.
assert.equal(
getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"],
0,
"pending count must be 0 after empty-stream error — not left inflated"
);
});
test("#3685 regression: upstream error event sets hasError=true and does NOT trigger empty-stream path", () => {
// If Claude itself emits type:error, lifecycle.hasError=true.
// shouldInjectClaudeEmptyResponseBeforeCurrentEvent / shouldInjectClaudeEmptyResponseOnFlush
// both check !lifecycle.hasError first — so neither our new error path nor the old synthetic
// path is triggered. Verified by inspecting the guard functions directly.
const lifecycle = {
hasMessageStart: true,
hasContentBlock: false,
hasMessageDelta: false,
hasMessageStop: false,
hasError: false,
syntheticContentInjected: false,
warningLogged: false,
};
// Simulate receiving an error event: sets hasError = true.
const lifecycleWithError = { ...lifecycle, hasError: true };
// shouldInjectClaudeEmptyResponseOnFlush equivalent: hasError blocks it
const wouldInjectOnFlush =
!lifecycleWithError.hasError &&
!lifecycleWithError.hasContentBlock &&
(lifecycleWithError.hasMessageStart ||
lifecycleWithError.hasMessageDelta ||
lifecycleWithError.hasMessageStop);
assert.equal(
wouldInjectOnFlush,
false,
"hasError=true must prevent the empty-stream error path from firing"
);
});

View File

@@ -0,0 +1,125 @@
/**
* tests/unit/cli-setup-opencode.test.ts
*
* `omniroute setup opencode` wires the bundled @omniroute/opencode-plugin into a
* local OpenCode install: copies the built plugin into `<config>/plugins/omniroute/`
* and registers a tuple entry in `opencode.json` (idempotent, replacing the legacy
* `opencode-omniroute-auth` entry from issue #3711).
*
* The command resolves the bundled plugin at module load, so the
* OMNIROUTE_OPENCODE_PLUGIN_DIR fixture override MUST be set before the import.
* `opts.configDir` keeps the test off the real OpenCode config dir on every platform.
*/
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oc-setup-"));
const FAKE_PLUGIN_DIR = path.join(FIXTURE_ROOT, "plugin");
const CONFIG_DIR = path.join(FIXTURE_ROOT, "opencode-config");
// Must be set before the module under test is imported (resolved at load time).
process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR = FAKE_PLUGIN_DIR;
const { runSetupOpenCodeCommand } = await import("../../bin/cli/commands/setup-open-code.mjs");
function makeFakePluginDist() {
fs.mkdirSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true });
fs.writeFileSync(
path.join(FAKE_PLUGIN_DIR, "package.json"),
JSON.stringify({ name: "@omniroute/opencode-plugin", version: "0.0.0-test" })
);
fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.js"), "export {};\n");
fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.cjs"), "module.exports = {};\n");
}
function readConfig() {
return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "opencode.json"), "utf8"));
}
describe("omniroute setup opencode", () => {
before(() => {
makeFakePluginDist();
});
after(() => {
try {
fs.rmSync(FIXTURE_ROOT, { recursive: true, force: true });
} catch {
// best-effort temp cleanup
}
});
it("installs the plugin and registers a tuple entry honouring --base-url (camelCased by Commander)", async () => {
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
// Commander turns `--base-url` into `baseUrl` — the runner must accept it.
baseUrl: "http://10.0.0.5:20128",
nonInteractive: true,
});
assert.equal(r.exitCode, 0);
// dist copied into the OpenCode plugins dir
assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "dist", "index.js")));
assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "package.json")));
const cfg = readConfig();
assert.ok(Array.isArray(cfg.plugin));
assert.equal(cfg.plugin.length, 1);
const [modulePath, options] = cfg.plugin[0];
assert.equal(modulePath, "./plugins/omniroute/dist/index.js");
assert.equal(options.providerId, "omniroute");
assert.equal(options.baseURL, "http://10.0.0.5:20128", "--base-url flag must reach the registered entry");
});
it("is idempotent: re-running updates the entry in place instead of duplicating it", async () => {
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
baseUrl: "http://10.0.0.9:20128",
nonInteractive: true,
});
assert.equal(r.exitCode, 0);
const cfg = readConfig();
const omniEntries = cfg.plugin.filter(
(p: unknown) => Array.isArray(p) && (p[1] as { providerId?: string })?.providerId === "omniroute"
);
assert.equal(omniEntries.length, 1, "re-run must not duplicate the entry");
assert.equal(omniEntries[0][1].baseURL, "http://10.0.0.9:20128", "re-run updates baseURL in place");
});
it("removes the legacy opencode-omniroute-auth entry (#3711) and preserves unrelated plugins", async () => {
const cfgPath = path.join(CONFIG_DIR, "opencode.json");
fs.writeFileSync(
cfgPath,
JSON.stringify({
plugin: [
"opencode-omniroute-auth",
["./plugins/other/dist/index.js", { providerId: "other" }],
],
})
);
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
assert.equal(r.exitCode, 0);
const cfg = readConfig();
const flat = JSON.stringify(cfg.plugin);
assert.ok(!flat.includes("opencode-omniroute-auth"), "legacy entry must be dropped");
assert.ok(flat.includes('"providerId":"other"'), "unrelated plugin entries must survive");
assert.equal(cfg.plugin.length, 2, "other + omniroute");
});
it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => {
fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true });
try {
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
assert.equal(r.exitCode, 1);
} finally {
makeFakePluginDist();
}
});
});

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