diff --git a/.env.example b/.env.example index df5703dd70..f98c656048 100644 --- a/.env.example +++ b/.env.example @@ -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) # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..1bfb96a016 --- /dev/null +++ b/.github/FUNDING.yml @@ -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"] diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index f5d52c511d..55329dec8a 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -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", diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 8ad06edfe6..6c8c92540b 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -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 {}; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 185e19e3cd..06332d5347 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` + 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) --- diff --git a/README.md b/README.md index 50e484d2b6..467e920a1e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs new file mode 100644 index 0000000000..b23103f51e --- /dev/null +++ b/bin/cli/commands/setup-open-code.mjs @@ -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 `/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 `/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 `/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 `. 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 ", + "OpenCode provider id to register (default: omniroute)", + "omniroute" + ) + .option( + "--base-url ", + "OmniRoute base URL the plugin should talk to (default: http://localhost:20128)", + "http://localhost:20128" + ) + .option("--display-name ", "Display name in the OpenCode UI (optional)") + .option( + "--auth", + "Run `opencode auth login --provider ` 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); + }); +} diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index 690c4d9e08..cda32573b2 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -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 = {}) { diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 857e18b65a..bcbc016701 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -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", diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 6256824de5..dcc10ce3f9 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -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 diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 333bc4b697..49aebe2736 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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 | diff --git a/file-size-baseline.json b/file-size-baseline.json index fbd4cd6e9b..d2ba7c8977 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -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.)." } diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 0376d7396d..0878f5ab7a 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -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 diff --git a/open-sse/config/geminiRateLimits.json b/open-sse/config/geminiRateLimits.json new file mode 100644 index 0000000000..a1ae15dc7f --- /dev/null +++ b/open-sse/config/geminiRateLimits.json @@ -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 } +} diff --git a/open-sse/executors/vertex.ts b/open-sse/executors/vertex.ts index 7412fa24b9..02994026d0 100644 --- a/open-sse/executors/vertex.ts +++ b/open-sse/executors/vertex.ts @@ -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 { 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"; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0db1b9d605..c5f5188619 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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 = { - ...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) { diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index ad0b2a344e..729ee8b433 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -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(); const modelFailureState = new Map(); +// 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(); +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) diff --git a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts index 7d1f9beec8..e2636f6e52 100644 --- a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts +++ b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts @@ -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); + }); +}); diff --git a/open-sse/services/autoCombo/taskFitness.ts b/open-sse/services/autoCombo/taskFitness.ts index 123d479b1c..404a476089 100644 --- a/open-sse/services/autoCombo/taskFitness.ts +++ b/open-sse/services/autoCombo/taskFitness.ts @@ -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> = { 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> = { + 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(); + +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 | 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 | 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 = {}; + + 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(); +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 5095207bcc..492abe2886 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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; + 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).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 + ): Response { + // Snapshot the prefix so mutations after this point don't affect it. + const prefix = bufferedChunks.slice(); + let prefixIdx = 0; + const stream = new ReadableStream({ + 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; - 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).max_tokens) || 0; - if (currentMaxTokens > 0) { - const bufferedMaxTokens = Math.max( - currentMaxTokens + 1000, - Math.ceil(currentMaxTokens * 1.5) - ); + { + const bodyRecord = body as Record; + 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), + ...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}` ); } } diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index e8c9c33a52..5afe81f6aa 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -26,6 +26,7 @@ const DEFAULT_COMBO_CONFIG = { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + reasoningTokenBufferEnabled: true, manifestRouting: false, resetAwareSessionWeight: 0.35, resetAwareWeeklyWeight: 0.65, diff --git a/open-sse/services/geminiRateLimitTracker.ts b/open-sse/services/geminiRateLimitTracker.ts new file mode 100644 index 0000000000..7ede8a4f88 --- /dev/null +++ b/open-sse/services/geminiRateLimitTracker.ts @@ -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(); + +// ── RPM (sliding 60s window) state ─────────────────────────────────────────── + +const minuteWindows = new Map(); + +// ── 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>)[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(); +} diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts new file mode 100644 index 0000000000..23290de713 --- /dev/null +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -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; +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 4abb7acbee..0581a77bf6 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -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:""}` 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; + 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; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index d33cbcea4a..0493f7e65d 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 { + 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", diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2671d7a5e8..7db6062331 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -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) { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 8cf9954602..c1453aafef 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -816,7 +816,7 @@ register( FORMATS.GEMINI, (model, body, stream = false, credentials = null) => openaiToGeminiRequest(model, body, stream, credentials, { - signaturelessToolCallMode: "native", + signaturelessToolCallMode: "context", }), null ); diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json index 6a35a3d3e1..f64585be7e 100644 --- a/open-sse/tsconfig.json +++ b/open-sse/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": true, "noEmit": true, "allowImportingTsExtensions": true, + "resolveJsonModule": true, "skipLibCheck": true, "esModuleInterop": true, "strict": false, diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index acd399315d..4b49eaf770 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -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)" ); } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 4091d5cc38..48633fdc7c 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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 = { 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 @@ -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( diff --git a/public/audio/ui-notify.mp3 b/public/audio/ui-notify.mp3 new file mode 100644 index 0000000000..326d3fa533 Binary files /dev/null and b/public/audio/ui-notify.mp3 differ diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index b999c5c6f5..b5c2e14dfb 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -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); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 47aa3ffe05..61ecc980df 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -1,250 +1,55 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -// Phase 1f extractions — Issue #3501 -import { useProviderConnections } from "./hooks/useProviderConnections"; -import { useProviderSettings } from "./hooks/useProviderSettings"; -import { useProviderModels } from "./hooks/useProviderModels"; -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 { useNotificationStore } from "@/store/notificationStore"; -import { useParams, useRouter } from "next/navigation"; +// Issue #3501 strangler-fig decomposition — Phase 1t (final push) +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useParams } from "next/navigation"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import { Card, Button, CardSkeleton, NoAuthProviderCard, NoAuthAccountCard } from "@/shared/components"; import { - Card, - Button, - Badge, - Modal, - ConfirmModal, - CardSkeleton, - OAuthModal, - KiroOAuthWrapper, - CursorAuthModal, - TraeAuthModal, - Toggle, - Select, - ProxyConfigModal, - NoAuthProviderCard, - NoAuthAccountCard, -} from "@/shared/components"; -import { - LOCAL_PROVIDERS, NOAUTH_PROVIDERS, - AI_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isClaudeCodeCompatibleProvider, - isSelfHostedChatProvider, supportsApiKeyOnFreeProvider, - // providerAllowsOptionalApiKey + supportsBulkApiKey used by extracted AddApiKeyModal } from "@/shared/constants/providers"; -// antigravityClientProfile + parseBulkApiKeys used by extracted modals (AddApiKeyModal, EditConnectionModal) import { getModelsByProviderId } from "@/shared/constants/models"; -import { - compatibleProviderSupportsModelImport, - getCompatibleFallbackModels, -} from "@/lib/providers/managedAvailableModels"; -import { - matchesModelCatalogQuery, - normalizeModelCatalogSource, -} from "@/shared/utils/modelCatalogSearch"; +import { compatibleProviderSupportsModelImport, getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; +import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -import { pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; -import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; -import ProviderIcon from "@/shared/components/ProviderIcon"; -import { type CodexServiceTier } from "@/lib/providers/requestDefaults"; -import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; -// parseExtraApiKeys used by extracted EditConnectionModal -import { compareTr } from "@/shared/utils/turkishText"; -import RiskNoticeModal from "../components/RiskNoticeModal"; -import CodexCliGuideModal from "../components/CodexCliGuideModal"; -import { isRiskAcknowledged, useRiskAcknowledged } from "../hooks/useRiskAcknowledged"; +import { useNotificationStore } from "@/store/notificationStore"; import { resolveDashboardProviderInfo } from "../providerPageUtils"; -// webSessionCredentials used by extracted modals (AddApiKeyModal, EditConnectionModal) -import { - ImportCodexAuthModal, - ApplyCodexAuthModal, -} from "./components/modals/ImportCodexAuthModal"; -import { - ImportClaudeAuthModal, - ApplyClaudeAuthModal, -} from "./components/modals/ImportClaudeAuthModal"; -import { - ImportGeminiAuthModal, - ApplyGeminiAuthModal, -} from "./components/modals/ImportGeminiAuthModal"; - -import EditCompatibleNodeModal from "./components/modals/EditCompatibleNodeModal"; -import AddApiKeyModal from "./components/modals/AddApiKeyModal"; -import EditConnectionModal from "./components/modals/EditConnectionModal"; -import WebSessionCredentialGuide from "./components/WebSessionCredentialGuide"; -// Phase 1d extractions — Issue #3501 -import ConnectionRow, { - type ConnectionRowConnection, -} from "./components/ConnectionRow"; -import ModelCompatPopover from "./components/ModelCompatPopover"; -import SiliconFlowEndpointModal from "./components/SiliconFlowEndpointModal"; -import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; -import { - // CONFIGURABLE_BASE_URL_PROVIDERS, DEFAULT_PROVIDER_BASE_URLS, getLocalProviderMetadata, - // isBaseUrlConfigurableProvider, getProviderBaseUrlDefault, getProviderBaseUrlHint, - // getProviderBaseUrlPlaceholder, isGlmProvider, parseRoutingTagsInput, parseExcludedModelsInput, - // formatRoutingTagsInput, formatExcludedModelsInput, getWebSessionCredentialLabel, - // getWebSessionCredentialHint, getWebSessionCredentialCheckLabel, getAddCredentialModalTitle, - // CODEX_REASONING_STRENGTH_OPTIONS, CODEX_ACCOUNT_SERVICE_TIER_VALUES, getCodexRequestDefaults, - // getClaudeCodeCompatibleRequestDefaults, extractCommandCodeCredentialInput, - // normalizeAndValidateHttpBaseUrl, formatTimeAgo - // — all moved to extracted modals (AddApiKeyModal, EditConnectionModal, WebSessionCredentialGuide) - providerText, - providerCountText, - readBooleanToggle, - formatProviderModelsErrorResponse, - type ProviderMessageTranslator, - type LocalProviderMetadata, - type CommandCodeAuthFlowState, - type CompatByProtocolMap, - type CompatModelRow, - type CompatModelMap, -} from "./providerPageHelpers"; -// CODEX_GLOBAL_SERVICE_MODE_VALUES, getCodexServiceTierLabel, normalizeCodexLimitPolicy -// moved to hooks/useProviderSettings.ts + hooks/useProviderConnections.ts (Phase 1f) -// Phase 1e extractions — Issue #3501 +import { type ConnectionRowConnection } from "./components/ConnectionRow"; +import { useProviderConnections } from "./hooks/useProviderConnections"; +import { useProviderSettings } from "./hooks/useProviderSettings"; +import { useProviderModels } from "./hooks/useProviderModels"; +import { useCommandCodeAuth } from "./hooks/useCommandCodeAuth"; +import { useExternalLinkFlow } from "./hooks/useExternalLinkFlow"; +import { useAuthFileHandlers } from "./hooks/useAuthFileHandlers"; +import { useModelImportHandlers } from "./hooks/useModelImportHandlers"; +import { useApiKeySave } from "./hooks/useApiKeySave"; +import { useModelVisibilityHandlers } from "./hooks/useModelVisibilityHandlers"; import { useModelCompatState } from "./hooks/useModelCompatState"; -import ModelRow, { ModelVisibilityToolbar } from "./components/ModelRow"; -import PassthroughModelsSection from "./components/PassthroughModelsSection"; +import { useConnectionGate } from "./hooks/useConnectionGate"; +import { useProviderNodeActions } from "./hooks/useProviderNodeActions"; +import ProviderPlaygroundPanel from "./components/ProviderPlaygroundPanel"; +import ProviderModelsSection from "./components/ProviderModelsSection"; import CustomModelsSection from "./components/CustomModelsSection"; -import CompatibleModelsSection from "./components/CompatibleModelsSection"; -// recordToHeaderRows moved to components/ModelCompatPopover.tsx (Phase 1d) -// buildCompatMap, isModelHidden*, effectiveNormalize/Preserve*, anyNormalize/NoPreserveCompatBadge -// moved to providerPageHelpers.ts + hook useModelCompatState (Phase 1e) -// formatProviderModelsErrorResponse moved to providerPageHelpers.ts (Phase 1e) - -/** PATCH fields for provider model compat (matches API + `ModelCompatPerProtocol` shape). */ -type ModelCompatSavePatch = { - normalizeToolCallId?: boolean; - preserveOpenAIDeveloperRole?: boolean; - upstreamHeaders?: Record; - compatByProtocol?: CompatByProtocolMap; - isHidden?: boolean; -}; - -// MAX_BULK_IDS moved to hooks/useProviderConnections.ts (Phase 1f) -// ModelRowProps, PassthroughModelRowProps → components/ModelRow.tsx, PassthroughModelRow.tsx (Phase 1e) -// PassthroughModelsSectionProps → components/PassthroughModelsSection.tsx (Phase 1e) -// CustomModelsSectionProps → components/CustomModelsSection.tsx (Phase 1e) -// CompatibleModelsSectionProps → components/CompatibleModelsSection.tsx (Phase 1e) -// CooldownTimerProps moved to components/ConnectionRow.tsx (Phase 1d) - -// getModelSourceBadgeClass + ModelSourceBadge → components/ModelRow.tsx (Phase 1e) -// ConnectionRowConnection, ConnectionRowProps moved to components/ConnectionRow.tsx (Phase 1d) - -// ModelCompatPopover extracted to components/ModelCompatPopover.tsx (Phase 1d) - -// ──── ProviderPlaygroundPanel ──────────────────────────────────────────────── -// 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. - -const MEDIA_SERVICE_KINDS: ServiceKind[] = [ - "embedding", - "image", - "tts", - "stt", - "webSearch", - "webFetch", - "video", - "music", -]; - -function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Element | null { - switch (kind) { - case "llm": - return ; - case "embedding": - return ; - case "image": - return ; - case "tts": - return ; - case "stt": - return ; - case "webSearch": - return ; - case "webFetch": - return ; - case "video": - return ; - case "music": - return ; - default: - return null; - } -} - -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 & { 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(playgroundableKinds[0] ?? "llm"); - - if (playgroundableKinds.length === 0) return null; - - return ( -
-

Playground

- - {renderKindPanel(activeKind, providerId)} -
- ); -} +import ConnectionsListPanel from "./components/ConnectionsListPanel"; +import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar"; +import ZedImportCard from "./components/ZedImportCard"; +import ProviderPageHeader from "./components/ProviderPageHeader"; +import CompatibleNodeCard from "./components/CompatibleNodeCard"; +import ProviderModalsPanel from "./components/ProviderModalsPanel"; +import EmptyConnectionsPlaceholder from "./components/EmptyConnectionsPlaceholder"; +import UpstreamProxyCard from "./components/UpstreamProxyCard"; +import SearchProviderCard from "./components/SearchProviderCard"; +// providerText used by UpstreamProxyCard (Phase 1t.7) export default function ProviderDetailPageClient() { const params = useParams(); - const router = useRouter(); const providerId = params.id as string; // ── UI-only modal state (not owned by hooks) ───────────────────────────── @@ -253,79 +58,15 @@ export default function ProviderDetailPageClient() { const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false); const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState(); - const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false); - const [commandCodeAuthState, setCommandCodeAuthState] = useState({ - phase: "idle", - state: "", - authUrl: "", - callbackUrl: "", - expiresAt: null, - message: "", - }); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); const [showTutorialModal, setShowTutorialModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [proxyTarget, setProxyTarget] = useState(null); - const [importingModels, setImportingModels] = useState(false); - 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 [showImportModal, setShowImportModal] = useState(false); - const [importProgress, setImportProgress] = useState({ - current: 0, - total: 0, - phase: "idle" as "idle" | "fetching" | "importing" | "done" | "error", - status: "", - logs: [] as string[], - error: "", - importedCount: 0, - }); - const [compatSavingModelId, setCompatSavingModelId] = useState(null); - const [modelFilter, setModelFilter] = useState(""); - const [togglingModelId, setTogglingModelId] = useState(null); - const [testingModelId, setTestingModelId] = useState(null); - const [modelTestStatus, setModelTestStatus] = useState>({}); - 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 [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>( - null - ); - const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); - const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( - null - ); - const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); const [importCodexModalOpen, setImportCodexModalOpen] = useState(false); const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false); - // "Adicionar Externo": public shareable device-flow link state. - const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); - const [externalLinkUrl, setExternalLinkUrl] = useState(""); - const [externalLinkToken, setExternalLinkToken] = useState(null); - const [externalLinkLoading, setExternalLinkLoading] = useState(false); - const [externalLinkError, setExternalLinkError] = useState(null); - const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard(); - const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); - const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( - null - ); - const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); - const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); - const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( - null - ); - const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(null); const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false); - const commandCodeAuthWindowRef = useRef(null); - const commandCodeAuthTimerRef = useRef(null); - const pendingRiskActionRef = useRef<(() => void) | null>(null); - const { acknowledged: riskAcknowledged, acknowledge: acknowledgeRisk } = - useRiskAcknowledged(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); const isCommandCode = providerId === "command-code"; @@ -360,7 +101,6 @@ export default function ProviderDetailPageClient() { setSelectedIds, setBatchDeleteConfirmOpen, setBatchTestResults, - setConnections, setProviderNode, fetchConnections, fetchProxyConfig, @@ -420,6 +160,18 @@ export default function ProviderDetailPageClient() { const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const notify = useNotificationStore(); + // Phase 1i: external link flow — placed after notify/fetchConnections are defined + const { + externalLinkModalOpen, + setExternalLinkModalOpen, + externalLinkUrl, + externalLinkLoading, + externalLinkError, + externalLinkCopied, + externalLinkCopy, + openExternalLinkFlow, + } = useExternalLinkFlow({ providerId, notify, fetchConnections }); + const setShowOAuthModal = (show: boolean, connectionRow?: ConnectionRowConnection) => { _setShowOAuthModal(show); setReauthConnection(show && connectionRow ? connectionRow : null); @@ -436,6 +188,11 @@ export default function ProviderDetailPageClient() { const providerSupportsOAuth = providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; const subscriptionRisk = providerInfo?.subscriptionRisk === true; + + // ── Phase 1t.3: connection gate + risk-notice modal state ─────────────── + const { showRiskNoticeModal, gateConnectionFlow, handleConfirmRiskNotice, handleCancelRiskNotice } = + useConnectionGate({ providerId, subscriptionRisk }); + const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; const isFreeNoAuth = NOAUTH_PROVIDERS[providerId]?.noAuth === true; @@ -485,60 +242,34 @@ export default function ProviderDetailPageClient() { const providerStorageAlias = isCompatible ? providerId : providerAlias; const providerDisplayAlias = isCompatible ? providerNode?.prefix || providerId : providerAlias; - const getApiLabel = () => { - if (isAnthropicProtocolCompatible) return t("messagesApi"); - const type = providerNode?.apiType; - switch (type) { - 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"); - } - }; - - const getApiDefaultPath = () => { - if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH; - if (isAnthropicCompatible) return "/messages"; - const type = providerNode?.apiType; - switch (type) { - 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"; - } - }; - - const getApiPath = () => { - const defaultPath = getApiDefaultPath(); - return (providerNode?.chatPath || defaultPath).replace(/^\//, ""); - }; - - // fetchAliases, handleSetAlias, handleDeleteAlias → hooks/useProviderModels.ts (Phase 1f) - // fetchProviderModelMeta, fetchProxyConfig, fetchConnections → hooks/useProviderConnections.ts + useProviderModels.ts (Phase 1f) - // loadCodexSettings, loadClaudeRoutingSettings → hooks/useProviderSettings.ts (Phase 1f) - // loadConnProxies, handleRetestConnection, handleBatchTestAll, handleBatchRetest → hooks/useProviderConnections.ts (Phase 1f) - // handleDelete, handleBatchDeleteConfirm, handleBatchSetActive → hooks/useProviderConnections.ts (Phase 1f) - // handleUpdateConnectionStatus, handleToggleProxyEnabled, handleTogglePerKeyProxyEnabled → hooks/useProviderConnections.ts (Phase 1f) - // handleDistributeProxies, handleToggleRateLimit, handleToggleClaudeExtraUsage → hooks/useProviderConnections.ts (Phase 1f) - // handleToggleCliproxyapiMode, handleToggleCodexLimit, handleSwapPriority → hooks/useProviderConnections.ts (Phase 1f) - // handleToggleClaudeRoutingPreference, handleChangeCodexGlobalServiceMode → hooks/useProviderSettings.ts (Phase 1f) - // handleRefreshToken → hooks/useProviderConnections.ts (Phase 1f) + // ── Phase 1k: model import handlers ───────────────────────────────────── + const { + importingModels, + showImportModal, + importProgress, + togglingAutoSync, + canImportModels, + isAutoSyncEnabled, + setShowImportModal, + setImportProgress, + handleImportModels, + handleCompatibleImportWithProgress, + handleToggleAutoSync, + } = useModelImportHandlers({ + providerId, + models, + modelMeta, + modelAliases, + connections, + isFreeNoAuth, + handleSetAlias, + fetchAliases, + fetchProviderModelMeta, + fetchConnections, + notify, + t, + providerStorageAlias, + }); // ── model-related effects (loading gate) ──────────────────────────────── useEffect(() => { @@ -547,196 +278,6 @@ export default function ProviderDetailPageClient() { fetchAliases(); }, [loading, isSearchProvider, fetchProviderModelMeta, fetchAliases]); - 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 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]); - - // loadCodexSettings, loadClaudeRoutingSettings → hooks/useProviderSettings.ts (Phase 1f) - // loadConnProxies → hooks/useProviderConnections.ts (Phase 1f) - - const onTestModel = async (modelId: string, fullModel: string) => { - setTestingModelId(modelId); - setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined })); - 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" })); - if (handleToggleModelHidden) { - await handleToggleModelHidden(providerStorageAlias, modelId, true); - } - } - } catch (err) { - notify.error("Network error testing model"); - setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); - if (handleToggleModelHidden) { - await handleToggleModelHidden(providerStorageAlias, modelId, true); - } - } finally { - setTestingModelId(null); - } - }; - - const handleTestAll = async ( - targets: Array<{ modelId: string; fullModel: string }> - ): Promise => { - 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); - }; - - // handleToggleSelectOne/All, handleBatchDeleteOpenModal/Confirm, handleDelete, - // handleBatchSetActive → hooks/useProviderConnections.ts (Phase 1f) - const handleOAuthSuccess = useCallback(() => { fetchConnections(); setShowOAuthModal(false); @@ -758,1047 +299,72 @@ export default function ProviderDetailPageClient() { openApiKeyAddFlow(); }, [isOAuth, openApiKeyAddFlow]); - // "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]); - - 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); - }, []); - - 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]); - - 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, + // ── Phase 1h: commandCode auth flow ───────────────────────────────────── + const { + commandCodeAuthState, handleCloseAddApiKeyModal, - commandCodeAuthState.phase, + handleStartCommandCodeAuth, + handleOpenCommandCodeConnect, + } = useCommandCodeAuth({ + providerId, fetchConnections, - handleCommandCodeAuthApply, + setSiliconFlowInitialBaseUrl, + setShowAddApiKeyModal, notify, - ]); - - const handleOpenCommandCodeConnect = useCallback(() => { - setShowAddApiKeyModal(true); - void handleStartCommandCodeAuth(); - }, [handleStartCommandCodeAuth]); - - const handleSaveApiKey = async (formData) => { - 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"); - } - }; - - const handleUpdateConnection = async (formData) => { - 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"); - } - }; - - // handleUpdateConnectionStatus, handleToggleProxyEnabled, handleTogglePerKeyProxyEnabled, - // handleDistributeProxies, handleToggleRateLimit, handleToggleClaudeExtraUsage, - // handleToggleCliproxyapiMode, handleToggleCodexLimit, handleToggleClaudeRoutingPreference, - // handleChangeCodexGlobalServiceMode, handleRetestConnection, runBatchTest, - // handleBatchTestAll, handleBatchRetest, parseApiErrorMessage, getAttachmentFilename, - // handleRefreshToken → hooks/useProviderConnections.ts + useProviderSettings.ts (Phase 1f) - - // handleToggleProxyEnabled → useProviderConnections (Phase 1f) - - // handleTogglePerKeyProxyEnabled → useProviderConnections (Phase 1f) - - // handleDistributeProxies → useProviderConnections (Phase 1f) - - // handleToggleRateLimit → useProviderConnections (Phase 1f) - - // handleToggleClaudeExtraUsage → useProviderConnections (Phase 1f) - - // [cpaProviderEnabled] state + useEffect + handleToggleCliproxyapiMode → useProviderConnections (Phase 1f) - - // handleToggleCodexLimit → useProviderConnections (Phase 1f) - - // handleToggleClaudeRoutingPreference + handleChangeCodexGlobalServiceMode → useProviderSettings (Phase 1f) - - // handleRetestConnection, runBatchTest, handleBatchTestAll, handleBatchRetest, - // [refreshingId], parseApiErrorMessage, getAttachmentFilename, handleRefreshToken - // → useProviderConnections (Phase 1f) - - const handleApplyCodexAuthLocal = async (connectionId: string) => { - if (applyingCodexAuthId) return; - setApplyingCodexAuthId(connectionId); - - const defaultSuccess = - typeof t.has === "function" && t.has("codexAuthAppliedLocal") - ? t("codexAuthAppliedLocal") - : "Codex auth.json applied locally"; - const defaultError = - typeof t.has === "function" && t.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.has === "function" && t.has("codexAuthExported") - ? t("codexAuthExported") - : "Codex auth.json exported"; - const defaultError = - typeof t.has === "function" && t.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.has === "function" && t.has("claudeAuthAppliedLocal") - ? t("claudeAuthAppliedLocal") - : "Claude auth applied locally"; - const defaultError = - typeof t.has === "function" && t.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.has === "function" && t.has("claudeAuthExported") - ? t("claudeAuthExported") - : "Claude auth file exported"; - const defaultError = - typeof t.has === "function" && t.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.has === "function" && t.has("geminiAuthAppliedLocal") - ? t("geminiAuthAppliedLocal") - : "Gemini auth applied locally"; - const defaultError = - typeof t.has === "function" && t.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.has === "function" && t.has("geminiAuthExported") - ? t("geminiAuthExported") - : "Gemini auth file exported"; - const defaultError = - typeof t.has === "function" && t.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); - } - }; - - // handleSwapPriority → useProviderConnections (Phase 1f) - - const handleImportModels = async () => { - if (importingModels) return; - const activeConnection = connections.find((conn) => conn.isActive !== false); - // #3047 — no-auth providers (e.g. OpenCode Free) have no connection rows; - // fall back to the provider id so the models route can serve the public - // catalog instead of the button silently doing nothing. - 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 })], - })); - - // Save as imported (default) model in the DB - 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 } - : {}), - }), - }); - // Also create an alias for routing - 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, - })); - - // Auto-reload after success - 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); - } - }; - - // Shared import handler for CompatibleModelsSection - 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 canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); - - // Auto-sync toggle state: read from first active connection's providerSpecificData - const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false); - const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync; - const [togglingAutoSync, setTogglingAutoSync] = useState(false); - - 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); - } - }; - - const [clearingModels, setClearingModels] = useState(false); - const providerAliasEntries = useMemo( - () => - Object.entries(modelAliases).filter( - ([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) - ), - [modelAliases, providerStorageAlias] - ); - - 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); - } - }; - - // Phase 1e: compat-state derivations moved to useModelCompatState hook. + }); + + // Phase 1s: handleSaveApiKey extracted to hooks/useApiKeySave.ts + const { handleSaveApiKey } = useApiKeySave({ + providerId, + fetchConnections, + fetchProviderModelMeta, + setImportProgress, + setShowImportModal, + setShowAddApiKeyModal, + setSiliconFlowInitialBaseUrl, + notify, + t, + }); + + // ── Phase 1t.4: node/connection update handlers ────────────────────────── + const { handleUpdateNode, handleUpdateConnection } = useProviderNodeActions({ + providerId, + fetchConnections, + selectedConnection, + setProviderNode, + setShowEditNodeModal, + setShowEditModal, + t, + }); + + // Phase 1j: auth file handlers + const { + applyingCodexAuthId, + applyCodexModalConnectionId, + setApplyCodexModalConnectionId, + exportingCodexAuthId, + handleApplyCodexAuthLocal, + handleExportCodexAuthFile, + applyingClaudeAuthId, + applyClaudeModalConnectionId, + setApplyClaudeModalConnectionId, + exportingClaudeAuthId, + handleApplyClaudeAuthLocal, + handleExportClaudeAuthFile, + applyingGeminiAuthId, + applyGeminiModalConnectionId, + setApplyGeminiModalConnectionId, + exportingGeminiAuthId, + handleApplyGeminiAuthLocal, + handleExportGeminiAuthFile, + } = useAuthFileHandlers({ parseApiErrorMessage, getAttachmentFilename, notify, t }); + + // Phase 1e: compat-state derivations const compat = useModelCompatState( modelMeta.customModels, modelMeta.modelCompatOverrides ); - const { customMap, overrideMap } = compat; + const { customMap } = compat; const effectiveModelNormalize = compat.effectiveModelNormalize; const effectiveModelPreserveDeveloper = compat.effectiveModelPreserveDeveloper; const effectiveModelHidden = compat.isModelHidden; @@ -1809,429 +375,44 @@ export default function ProviderDetailPageClient() { [providerId, modelMeta.customModels] ); - const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { - setCompatSavingModelId(modelId); - try { - const c = customMap.get(modelId) as Record | undefined; - let body: Record; - const onlyCompatByProtocol = - patch.compatByProtocol && - patch.normalizeToolCallId === undefined && - patch.preserveOpenAIDeveloperRole === undefined && - !("upstreamHeaders" in patch); + // ── Phase 1l: model visibility handlers ───────────────────────────────── + const { + compatSavingModelId, + togglingModelId, + bulkVisibilityAction, + clearingModels, + modelFilter, + testingModelId, + modelTestStatus, + testingAll, + testProgress, + autoHideFailed, + visibilityFilter, + providerAliasEntries, + setModelFilter, + setAutoHideFailed, + setVisibilityFilter, + saveModelCompatFlags, + handleToggleModelHidden, + handleBulkToggleModelHidden, + handleClearAllModels, + onTestModel, + handleTestAll, + } = useModelVisibilityHandlers({ + providerId, + modelAliases, + customMap, + providerStorageAlias, + fetchProviderModelMeta, + fetchAliases, + notify, + t, + selectedConnection, + providerNode, + }); - 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 */ - } - }; + // renderModelsSection → components/ProviderModelsSection.tsx (Phase 1m) - const handleToggleModelHidden = async ( - providerKey: string, - modelId: string, - hidden: boolean - ): Promise => { - 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 => { - 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 renderModelsSection = () => { - const autoSyncToggle = compatibleSupportsModelImport && canImportModels && ( - - ); - - const clearAllButton = (modelMeta.customModels.length > 0 || - providerAliasEntries.length > 0) && ( - - ); - - 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 ( -
-
- {autoSyncToggle} - {clearAllButton} -
- - 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} - /> -
- ); - } - - 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 ( -
-
- - {autoSyncToggle} - {clearAllButton} - {!canImportModels && ( - {t("addConnectionToImport")} - )} -
- - 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} - /> -
- ); - } - - const importButton = ( -
- - {autoSyncToggle} - {!canImportModels && ( - {t("addConnectionToImport")} - )} -
- ); - - if (models.length === 0) { - return ( -
- {importButton} -

{t("noModelsConfigured")}

-
- ); - } - 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 ( -
- {importButton} - {modelsWithVisibility.length > 0 && ( - - 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} - /> - )} -
- {filteredModels.map((model) => { - return ( - 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 && ( -

- {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { - filter: modelFilter, - })} -

- )} -
-
- ); - }; if (loading) { return ( @@ -2253,226 +434,34 @@ export default function ProviderDetailPageClient() { ); } - // OpenAI/Anthropic compatible providers use their specialized pseudo-provider icons. - const getHeaderIconProviderId = () => { - if (isOpenAICompatible && providerInfo.apiType) { - return providerInfo.apiType === "responses" ? "oai-r" : "oai-cc"; - } - if (isAnthropicProtocolCompatible) { - return "anthropic-m"; - } - return providerInfo.id; - }; - return (
- {/* Header */} -
- - arrow_back - {t("backToProviders")} - -
-
- -
-
- {providerInfo.website ? ( - - {providerInfo.name} - open_in_new - - ) : ( -

{providerInfo.name}

- )} -
-

- {t("connectionCountLabel", { count: connections.length })} -

- - {providerId === "adapta-web" && ( - - )} -
-
-
-
+ {/* Header — Phase 1t.1: extracted to components/ProviderPageHeader.tsx */} + setShowTutorialModal(true)} + t={t} + /> - {providerId === "zed" && ( - <> - -
-
-

- download - Import from Zed Keychain -

-

- 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. -

-
- -
-
- -
- - {showZedManual && ( -
-

- Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the - API key that Zed stored under{" "} - ~/.config/zed/settings.json or copy - it from the Zed AI settings panel. -

-
- - setZedManualToken(e.target.value)} - /> - -
-
- )} -
-
- - )} + {providerId === "zed" && } + {/* CompatibleNodeCard — Phase 1t.2: extracted to components/CompatibleNodeCard.tsx */} {isCompatible && providerNode && ( - -
-
-

- {isCcCompatible - ? t("ccCompatibleDetailsTitle") - : isAnthropicCompatible - ? t("anthropicCompatibleDetails") - : t("openaiCompatibleDetails")} -

-

- {getApiLabel()} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/{getApiPath()} -

-
-
- - - -
-
- {isCcCompatible && ( -
-
- - warning - -

{t("ccCompatibleValidationHint")}

-
-
- )} -
+ setShowEditNodeModal(true)} + t={t} + /> )} {/* Connections */} @@ -2496,961 +485,197 @@ export default function ProviderDetailPageClient() { providerId !== "opencode" && } {!isUpstreamProxyProvider && !isFreeNoAuth && ( -
-
-

{t("connections")}

- {providerId === "claude" && ( -
- - alt_route - - - {providerText( - t, - "preferClaudeCodeForUnprefixedClaudeModelsLabel", - "Claude Code default" - )} - - - - {preferClaudeCodeForUnprefixedClaudeModels - ? providerText(t, "toggleOnShort", "On") - : providerText(t, "toggleOffShort", "Off")} - - {claudeRoutingSettingsLoadError ? ( - - ) : null} -
- )} - {providerId === "codex" && ( -
- - {providerText(t, "providerDetailServiceModeLabel", "Global service mode:")} - - - {codexSettingsLoadError ? ( - - ) : null} -
- )} - {/* Provider-level proxy indicator/button */} - -
-
- {connections.length > 0 && ( - - )} - {connections.length > 1 && ( - - )} - {!isCompatible ? ( - <> - {isCommandCode ? ( - <> - - - - ) : ( - <> - - {providerId === "qoder" && ( - - )} - {providerId === "codex" && ( - - )} - {providerId === "codex" && ( - - )} - {providerId === "codex" && ( - - )} - {providerId === "claude" && ( - - )} - {providerId === "gemini-cli" && ( - - )} - - )} - - ) : ( - connections.length === 0 && ( - - ) - )} -
-
+ setShowOAuthModal(true)} + onOpenCodexCliGuide={() => setCodexCliGuideOpen(true)} + onOpenImportCodex={() => setImportCodexModalOpen(true)} + onOpenImportClaude={() => setImportClaudeModalOpen(true)} + onOpenImportGemini={() => setImportGeminiModalOpen(true)} + t={t} + /> {connections.length === 0 ? ( -
-
- - {isOAuth ? "lock" : "key"} - -
-

{t("noConnectionsYet")}

-

{t("addFirstConnectionHint")}

- {!isCompatible && ( -
- {isCommandCode ? ( - <> - - - - ) : ( - <> - - {providerId === "qoder" && ( - - )} - {providerId === "codex" && ( - - )} - {providerId === "claude" && ( - - )} - {providerId === "gemini-cli" && ( - - )} - - )} -
- )} -
+ setShowOAuthModal(true)} + onOpenImportCodex={() => setImportCodexModalOpen(true)} + onOpenImportClaude={() => setImportClaudeModalOpen(true)} + onOpenImportGemini={() => setImportGeminiModalOpen(true)} + t={t} + /> ) : ( - (() => { - 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 && ( -
- - - - -
- ); - - 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 = ( -
- {STATUS_FILTER_OPTIONS.map((opt) => ( - - ))} -
- ); - - const paginationBar = - totalFilteredPages > 1 ? ( -
- - {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} - -
-
-
- ) : null; - - if (!hasAnyTag) { - const pageConnections = filtered.slice(pageStart, pageEnd); - const allSelected = - pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); - const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); - return ( - <> -
-
- - {filterPills} -
- - {bulkActions} -
-
- {pageConnections.length === 0 ? ( -
- {t("noFilteredConnections", "No connections match the current filter.")} -
- ) : ( - pageConnections.map((conn, index) => ( - 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={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" - ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) - : undefined - } - onRefreshToken={ - conn.authType === "oauth" - ? () => handleRefreshToken(conn.id) - : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => setApplyCodexModalConnectionId(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onApplyClaudeAuthLocal={ - providerId === "claude" - ? () => setApplyClaudeModalConnectionId(conn.id) - : undefined - } - isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} - onExportClaudeAuthFile={ - providerId === "claude" - ? () => handleExportClaudeAuthFile(conn.id) - : undefined - } - isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} - onApplyGeminiAuthLocal={ - providerId === "gemini-cli" - ? () => setApplyGeminiModalConnectionId(conn.id) - : undefined - } - isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} - onExportGeminiAuthFile={ - providerId === "gemini-cli" - ? () => handleExportGeminiAuthFile(conn.id) - : undefined - } - isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} - onProxy={() => - setProxyTarget({ - 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) - } - /> - )) - )} -
- {paginationBar} - - ); - } - - // Build ordered tag groups: untagged first, then alphabetically - const groupMap = new Map(); - 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 ? ( -
-
- - {filterPills} -
- -
- {/* 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} -
-
- ) : null} -
- {groupKeys.map((tag, gi) => { - const groupConns = groupMap.get(tag)!; - return ( -
0 - ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" - : "" - } - > - {tag && ( -
- - label - - - {tag} - -
- - - {groupConns.length} - -
- )} -
- {groupConns.map((conn, index) => ( - 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} - onToggleCodex5h={(enabled) => - handleToggleCodexLimit(conn.id, "use5h", enabled) - } - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" - ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) - : undefined - } - onRefreshToken={ - conn.authType === "oauth" - ? () => handleRefreshToken(conn.id) - : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => setApplyCodexModalConnectionId(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onApplyClaudeAuthLocal={ - providerId === "claude" - ? () => setApplyClaudeModalConnectionId(conn.id) - : undefined - } - isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} - onExportClaudeAuthFile={ - providerId === "claude" - ? () => handleExportClaudeAuthFile(conn.id) - : undefined - } - isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} - onApplyGeminiAuthLocal={ - providerId === "gemini-cli" - ? () => setApplyGeminiModalConnectionId(conn.id) - : undefined - } - isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} - onExportGeminiAuthFile={ - providerId === "gemini-cli" - ? () => handleExportGeminiAuthFile(conn.id) - : undefined - } - isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} - onProxy={() => - setProxyTarget({ - 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) - } - /> - ))} -
-
- ); - })} -
- - ); - })() + { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onOpenOAuth={(conn) => gateConnectionFlow(() => setShowOAuthModal(true, conn))} + onSetProxyTarget={setProxyTarget} + onOpenApplyCodexModal={setApplyCodexModalConnectionId} + onExportCodexAuthFile={handleExportCodexAuthFile} + onOpenApplyClaudeModal={setApplyClaudeModalConnectionId} + onExportClaudeAuthFile={handleExportClaudeAuthFile} + onOpenApplyGeminiModal={setApplyGeminiModalConnectionId} + onExportGeminiAuthFile={handleExportGeminiAuthFile} + gateConnectionFlow={gateConnectionFlow} + t={t} + /> )} )} - - {isUpstreamProxyProvider && ( - -
-
-

- {providerText( - t, - "upstreamProxyManagedTitle", - "Managed via Upstream Proxy Settings" - )} -

-

- {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." - )} -

-
-
- - terminal - {t("openCliTools")} - - - settings - {t("openSettings")} - -
-
-
- )} + {isUpstreamProxyProvider && } {/* Models — hidden for search providers (they don't have models) */} {!isSearchProvider && !isUpstreamProxyProvider && (

{t("availableModels")}

- {renderModelsSection()} + {/* Phase 1m: extracted to components/ProviderModelsSection.tsx */} + {/* Custom Models — available for all providers */} -

{t("searchProvider")}

-

{t("searchProviderDesc")}

- {providerId === "perplexity-search" && ( -
- link -

{t("perplexitySearchSharedKeyInfo")}

-
- )} - {providerId === "google-pse-search" && ( -
- tune -

{t("googlePseInfo")}

-
- )} - {providerId === "searxng-search" && ( -
- dns -

{t("searxngInfo")}

-
- )} -
- )} + {isSearchProvider && } {/* Playground panel — rendered for providers that declare serviceKinds */} - {/* Modals */} - {showRiskNoticeModal && subscriptionRisk && ( - - )} - {!isUpstreamProxyProvider && - (providerId === "kiro" || providerId === "amazon-q" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : providerId === "cursor" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : providerId === "trae" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : ( - { - setShowOAuthModal(false); - }} - /> - ))} - {providerId === "siliconflow" && ( - { - setSiliconFlowInitialBaseUrl(baseUrl); - setShowSiliconFlowEndpointModal(false); - setShowAddApiKeyModal(true); - }} - onClose={() => { - setShowSiliconFlowEndpointModal(false); - setSiliconFlowInitialBaseUrl(undefined); - }} - /> - )} - {!isUpstreamProxyProvider && ( - - )} - 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} + {/* Modals — Phase 1t.5: extracted to components/ProviderModalsPanel.tsx */} + - {providerId === "codex" && applyCodexModalConnectionId && ( - setApplyCodexModalConnectionId(null)} - /> - )} - {!isUpstreamProxyProvider && ( - setShowEditModal(false)} - /> - )} - {!isUpstreamProxyProvider && isCompatible && ( - setShowEditNodeModal(false)} - isAnthropic={isAnthropicProtocolCompatible} - isCcCompatible={isCcCompatible} - /> - )} - {/* Codex CLI Guide Modal */} - setCodexCliGuideOpen(false)} /> - {/* Codex Import Auth Modal */} - {providerId === "codex" && importCodexModalOpen && ( - setImportCodexModalOpen(false)} - onSuccess={() => { - setImportCodexModalOpen(false); - void fetchConnections(); - }} - /> - )} - {providerId === "codex" && externalLinkModalOpen && ( - setExternalLinkModalOpen(false)} - title="Adicionar Externo — link do Codex" - > -
-

- 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. -

- {externalLinkLoading ? ( -

Gerando link…

- ) : externalLinkError ? ( -

{externalLinkError}

- ) : externalLinkUrl ? ( - <> -
- {externalLinkUrl} -
-
- - -
-

- sync - Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. -

- - ) : null} -
-
- )} - {/* Claude Apply Auth Modal */} - {providerId === "claude" && applyClaudeModalConnectionId && ( - setApplyClaudeModalConnectionId(null)} - /> - )} - {/* Claude Import Auth Modal */} - {providerId === "claude" && importClaudeModalOpen && ( - setImportClaudeModalOpen(false)} - onSuccess={() => { - setImportClaudeModalOpen(false); - void fetchConnections(); - }} - /> - )} - {/* Gemini Apply Auth Modal */} - {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( - setApplyGeminiModalConnectionId(null)} - /> - )} - {/* Gemini Import Auth Modal */} - {providerId === "gemini-cli" && importGeminiModalOpen && ( - setImportGeminiModalOpen(false)} - onSuccess={() => { - setImportGeminiModalOpen(false); - void fetchConnections(); - }} - /> - )} - {/* Batch Test Results Modal */} - {batchTestResults && ( -
setBatchTestResults(null)} - > -
-
e.stopPropagation()} - > -
-

{t("testResults")}

- -
-
- {batchTestResults.error && - (!batchTestResults.results || batchTestResults.results.length === 0) ? ( -
- - error - -

{String(batchTestResults.error)}

-
- ) : ( -
- {batchTestResults.summary && ( -
- {providerInfo?.name || providerId} - - {t("passedCount", { count: batchTestResults.summary.passed })} - - {batchTestResults.summary.failed > 0 && ( - - {t("failedCount", { count: batchTestResults.summary.failed })} - - )} - - {t("testedCount", { count: batchTestResults.summary.total })} - -
- )} - {(batchTestResults.results || []).map((r: any, i: number) => ( -
- - {r.valid ? "check_circle" : "error"} - -
- - {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} - -
- {r.latencyMs !== undefined && ( - - {t("millisecondsAbbr", { value: r.latencyMs })} - - )} - - {r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")} - -
- ))} - {(!batchTestResults.results || batchTestResults.results.length === 0) && ( -
- {t("noActiveConnectionsInGroup")} -
- )} -
- )} -
-
-
- )} - {/* Proxy Config Modal */} - {proxyTarget && ( - setProxyTarget(null)} - level={proxyTarget.level} - levelId={proxyTarget.id} - levelLabel={proxyTarget.label} - onSaved={() => { - void fetchProxyConfig(); - void loadConnProxies(connections); - }} - /> - )} - {/* Import Progress Modal */} - { - if (importProgress.phase === "done" || importProgress.phase === "error") { - setShowImportModal(false); - } - }} - title={t("importingModelsTitle")} - size="md" - closeOnOverlay={false} - showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"} - > -
- {/* Status text */} -
- {importProgress.phase === "fetching" && ( - - progress_activity - - )} - {importProgress.phase === "importing" && ( - - progress_activity - - )} - {importProgress.phase === "done" && ( - check_circle - )} - {importProgress.phase === "error" && ( - error - )} - {importProgress.status} -
- - {/* Progress bar */} - {(importProgress.phase === "importing" || importProgress.phase === "done") && - importProgress.total > 0 && ( -
-
- - {importProgress.current} / {importProgress.total} - - - {Math.round((importProgress.current / importProgress.total) * 100)}% - -
-
-
-
-
- )} - - {/* Fetching indeterminate bar */} - {importProgress.phase === "fetching" && ( -
-
-
- )} - - {/* Error message */} - {importProgress.phase === "error" && importProgress.error && ( -
-

{importProgress.error}

-
- )} - - {/* Log list */} - {importProgress.logs.length > 0 && ( -
-
- {importProgress.logs.map((log, i) => ( -

- {log} -

- ))} -
-
- )} - - {/* Close button */} - {importProgress.phase === "done" && ( -
- -
- )} -
- - - {/* Adapta Web — Tutorial Modal */} - {providerId === "adapta-web" && ( - setShowTutorialModal(false)} - title="Como conectar o Adapta Web" - size="md" - > -
-

- O Adapta usa autenticação via Clerk. O token{" "} - __client é um JWT - de longa duração que permite renovar sessões automaticamente. -

- -
    -
  1. - - 1 - -
    -

    Acesse o chat do Adapta

    -

    - Abra{" "} - - agent.adapta.one/agentic-chat - {" "} - e faça login com sua conta Gold ou Business. -

    -
    -
  2. - -
  3. - - 2 - -
    -

    Abra o DevTools

    -

    - Pressione{" "} - F12{" "} - ou{" "} - - Cmd+Option+I - {" "} - para abrir as Ferramentas do Desenvolvedor. -

    -
    -
  4. - -
  5. - - 3 - -
    -

    Vá em Application → Cookies

    -

    - Na aba Application (Chrome/Edge) ou Storage{" "} - (Firefox), expanda Cookies e clique em{" "} - - .clerk.agent.adapta.one - - . -

    -
    -
  6. - -
  7. - - 4 - -
    -

    - Copie o valor do cookie{" "} - __client -

    -

    - Localize o cookie chamado{" "} - __client na - lista. Clique nele e copie o conteúdo da coluna Value — começa - com eyJ…. -

    -
    -
  8. - -
  9. - - 5 - -
    -

    Cole aqui e salve

    -

    - Clique em Add Connection, cole o valor do{" "} - __client no - campo de API Key e salve. O OmniRoute renovará a sessão automaticamente. -

    -
    -
  10. -
- -
- Dica: O cookie __client tem - validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta - invalidar a sessão. -
-
-
- )}
); } -// ModelRow, ModelVisibilityToolbar, PassthroughModelsSection, PassthroughModelRow, -// CustomModelsSection, CompatibleModelsSection → components/ (Phase 1e — Issue #3501) - -// Phase 1d: CooldownTimer, inferErrorType, getStatusPresentation, ConnectionRow → components/ConnectionRow.tsx -// Phase 1d: ModelCompatPopover, recordToHeaderRows → components/ModelCompatPopover.tsx -// Phase 1d: SiliconFlowEndpointModal → components/SiliconFlowEndpointModal.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx index 9ee6137d20..3ed902b55d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx @@ -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) => { 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 ( - {String(result.connections.length)}|{String(result.batchTesting)} + {String(hookResult.connections.length)}|{String(hookResult.batchTesting)} ); } @@ -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 ; } @@ -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 ; } @@ -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 ; } @@ -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 ; } @@ -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 ; } @@ -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. diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx new file mode 100644 index 0000000000..2e416e306a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx @@ -0,0 +1,120 @@ +"use client"; +import { Modal } from "@/shared/components"; + +type AdaptaTutorialModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) { + return ( + +
+

+ O Adapta usa autenticação via Clerk. O token{" "} + __client é um JWT + de longa duração que permite renovar sessões automaticamente. +

+ +
    +
  1. + + 1 + +
    +

    Acesse o chat do Adapta

    +

    + Abra{" "} + + agent.adapta.one/agentic-chat + {" "} + e faça login com sua conta Gold ou Business. +

    +
    +
  2. + +
  3. + + 2 + +
    +

    Abra o DevTools

    +

    + Pressione{" "} + F12{" "} + ou{" "} + + Cmd+Option+I + {" "} + para abrir as Ferramentas do Desenvolvedor. +

    +
    +
  4. + +
  5. + + 3 + +
    +

    Vá em Application → Cookies

    +

    + Na aba Application (Chrome/Edge) ou Storage{" "} + (Firefox), expanda Cookies e clique em{" "} + + .clerk.agent.adapta.one + + . +

    +
    +
  6. + +
  7. + + 4 + +
    +

    + Copie o valor do cookie{" "} + __client +

    +

    + Localize o cookie chamado{" "} + __client na + lista. Clique nele e copie o conteúdo da coluna Value — começa + com eyJ…. +

    +
    +
  8. + +
  9. + + 5 + +
    +

    Cole aqui e salve

    +

    + Clique em Add Connection, cole o valor do{" "} + __client no + campo de API Key e salve. O OmniRoute renovará a sessão automaticamente. +

    +
    +
  10. +
+ +
+ Dica: O cookie __client tem + validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta + invalidar a sessão. +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx new file mode 100644 index 0000000000..8285b0b310 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx @@ -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 ( +
+
+
e.stopPropagation()} + > +
+

{t("testResults")}

+ +
+
+ {batchTestResults.error && + (!batchTestResults.results || batchTestResults.results.length === 0) ? ( +
+ + error + +

{String(batchTestResults.error)}

+
+ ) : ( +
+ {batchTestResults.summary && ( +
+ {providerInfo?.name || providerId} + + {t("passedCount", { count: batchTestResults.summary.passed })} + + {batchTestResults.summary.failed > 0 && ( + + {t("failedCount", { count: batchTestResults.summary.failed })} + + )} + + {t("testedCount", { count: batchTestResults.summary.total })} + +
+ )} + {(batchTestResults.results || []).map((r: any, i: number) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ + {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} + +
+ {r.latencyMs !== undefined && ( + + {t("millisecondsAbbr", { value: r.latencyMs })} + + )} + + {r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")} + +
+ ))} + {(!batchTestResults.results || batchTestResults.results.length === 0) && ( +
+ {t("noActiveConnectionsInGroup")} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx new file mode 100644 index 0000000000..1b01f2eb7d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx @@ -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 ( + +
+
+

+ {isCcCompatible + ? t("ccCompatibleDetailsTitle") + : isAnthropicCompatible + ? t("anthropicCompatibleDetails") + : t("openaiCompatibleDetails")} +

+

+ {getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "} + {(providerNode.baseUrl || "").replace(/\/$/, "")}/ + {getApiPath( + isCcCompatible, + isAnthropicCompatible, + providerNode?.apiType, + providerNode?.chatPath + )} +

+
+
+ + + +
+
+ {isCcCompatible && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx new file mode 100644 index 0000000000..4fd07cb763 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -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; + codexGlobalServiceMode: string; + codexGlobalServiceModeOptions: Array<{ value: string; label: string }>; + codexSettingsLoaded: boolean; + codexSettingsLoadError: string | null; + savingCodexGlobalServiceMode: boolean; + handleChangeCodexGlobalServiceMode: (mode: any) => void; + loadCodexSettings: () => Promise; + // 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 ( +
+
+

{t("connections")}

+ {providerId === "claude" && ( +
+ + alt_route + + + {providerText( + t, + "preferClaudeCodeForUnprefixedClaudeModelsLabel", + "Claude Code default" + )} + + + + {preferClaudeCodeForUnprefixedClaudeModels + ? providerText(t, "toggleOnShort", "On") + : providerText(t, "toggleOffShort", "Off")} + + {claudeRoutingSettingsLoadError ? ( + + ) : null} +
+ )} + {providerId === "codex" && ( +
+ + {providerText(t, "providerDetailServiceModeLabel", "Global service mode:")} + + + {codexSettingsLoadError ? ( + + ) : null} +
+ )} + {/* Provider-level proxy indicator/button */} + +
+
+ {connections.length > 0 && ( + + )} + {connections.length > 1 && ( + + )} + {!isCompatible ? ( + <> + {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} + + ) : ( + connections.length === 0 && ( + + ) + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx new file mode 100644 index 0000000000..592022be96 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx @@ -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; + 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; + 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>>; + setPage: React.Dispatch>; + 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 && ( +
+ + + + +
+ ); + + 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 = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = + totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : 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 ( + <> +
+
+ + {filterPills} +
+ + {bulkActions} +
+
+ {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( + 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) + } + /> + )) + )} +
+ {paginationBar} + + ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + 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 ? ( +
+
+ + {filterPills} +
+ +
+ {/* 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} +
+
+ ) : null} +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + {groupConns.length} +
+ )} +
+ {groupConns.map((conn, index) => ( + 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) + } + /> + ))} +
+
+ ); + })} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx new file mode 100644 index 0000000000..79d073ecf3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx @@ -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 ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

{t("noConnectionsYet")}

+

{t("addFirstConnectionHint")}

+ {!isCompatible && ( +
+ {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx new file mode 100644 index 0000000000..02a4930465 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx @@ -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 ( + +
+

+ 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. +

+ {loading ? ( +

Gerando link…

+ ) : error ? ( +

{error}

+ ) : url ? ( + <> +
+ {url} +
+
+ + +
+

+ sync + Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha. +

+ + ) : null} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx new file mode 100644 index 0000000000..905749a8bf --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx @@ -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 ( + +
+ {/* Status text */} +
+ {importProgress.phase === "fetching" && ( + + progress_activity + + )} + {importProgress.phase === "importing" && ( + + progress_activity + + )} + {importProgress.phase === "done" && ( + check_circle + )} + {importProgress.phase === "error" && ( + error + )} + {importProgress.status} +
+ + {/* Progress bar */} + {(importProgress.phase === "importing" || importProgress.phase === "done") && + importProgress.total > 0 && ( +
+
+ + {importProgress.current} / {importProgress.total} + + + {Math.round((importProgress.current / importProgress.total) * 100)}% + +
+
+
+
+
+ )} + + {/* Fetching indeterminate bar */} + {importProgress.phase === "fetching" && ( +
+
+
+ )} + + {/* Error message */} + {importProgress.phase === "error" && importProgress.error && ( +
+

{importProgress.error}

+
+ )} + + {/* Log list */} + {importProgress.logs.length > 0 && ( +
+
+ {importProgress.logs.map((log, i) => ( +

+ {log} +

+ ))} +
+
+ )} + + {/* Close button */} + {importProgress.phase === "done" && ( +
+ +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx new file mode 100644 index 0000000000..7959d7e431 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -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; + handleCloseAddApiKeyModal: () => void; + // Batch delete confirm + batchDeleteConfirmOpen: boolean; + setBatchDeleteConfirmOpen: (open: boolean) => void; + handleBatchDeleteConfirm: () => void; + selectedIds: Set; + batchDeleting: boolean; + // Codex auth + applyCodexModalConnectionId: string | null; + setApplyCodexModalConnectionId: (id: string | null) => void; + applyingCodexAuthId: string | null; + handleApplyCodexAuthLocal: (id: string) => Promise; + importCodexModalOpen: boolean; + setImportCodexModalOpen: (open: boolean) => void; + fetchConnections: () => Promise; + // 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; + // Edit compatible node + showEditNodeModal: boolean; + setShowEditNodeModal: (open: boolean) => void; + providerNode: any; + handleUpdateNode: (data: any) => Promise; + // 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; + importClaudeModalOpen: boolean; + setImportClaudeModalOpen: (open: boolean) => void; + // Gemini auth + applyGeminiModalConnectionId: string | null; + setApplyGeminiModalConnectionId: (id: string | null) => void; + applyingGeminiAuthId: string | null; + handleApplyGeminiAuthLocal: (id: string) => Promise; + 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; + // 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 && ( + + )} + {!isUpstreamProxyProvider && + (providerId === "kiro" || providerId === "amazon-q" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "cursor" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "trae" ? ( + setShowOAuthModal(false)} + /> + ) : ( + setShowOAuthModal(false)} + /> + ))} + {providerId === "siliconflow" && ( + { + setSiliconFlowInitialBaseUrl(baseUrl); + setShowSiliconFlowEndpointModal(false); + setShowAddApiKeyModal(true); + }} + onClose={() => { + setShowSiliconFlowEndpointModal(false); + setSiliconFlowInitialBaseUrl(undefined); + }} + /> + )} + {!isUpstreamProxyProvider && ( + + )} + 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 && ( + setApplyCodexModalConnectionId(null)} + /> + )} + {!isUpstreamProxyProvider && ( + setShowEditModal(false)} + /> + )} + {!isUpstreamProxyProvider && isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicProtocolCompatible} + isCcCompatible={isCcCompatible} + /> + )} + setCodexCliGuideOpen(false)} /> + {providerId === "codex" && importCodexModalOpen && ( + setImportCodexModalOpen(false)} + onSuccess={() => { + setImportCodexModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "codex" && externalLinkModalOpen && ( + setExternalLinkModalOpen(false)} + loading={externalLinkLoading} + error={externalLinkError} + url={externalLinkUrl} + copied={externalLinkCopied} + onCopy={externalLinkCopy} + /> + )} + {providerId === "claude" && applyClaudeModalConnectionId && ( + setApplyClaudeModalConnectionId(null)} + /> + )} + {providerId === "claude" && importClaudeModalOpen && ( + setImportClaudeModalOpen(false)} + onSuccess={() => { + setImportClaudeModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( + setApplyGeminiModalConnectionId(null)} + /> + )} + {providerId === "gemini-cli" && importGeminiModalOpen && ( + setImportGeminiModalOpen(false)} + onSuccess={() => { + setImportGeminiModalOpen(false); + void fetchConnections(); + }} + /> + )} + setBatchTestResults(null)} + t={t} + /> + {proxyTarget && ( + setProxyTarget(null)} + level={proxyTarget.level} + levelId={proxyTarget.id} + levelLabel={proxyTarget.label} + onSaved={() => { + void fetchProxyConfig(); + }} + /> + )} + { + if (importProgress.phase === "done" || importProgress.phase === "error") { + setShowImportModal(false); + } + }} + t={t} + /> + {providerId === "adapta-web" && ( + setShowTutorialModal(false)} + /> + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx new file mode 100644 index 0000000000..c4e68811db --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx @@ -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; + syncedAvailableModels: any[]; + compatibleFallbackModels: any[]; + + // Clipboard + copied: string | null; + onCopy: (text: string) => void; + + // Model alias handlers + onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + onDeleteAlias: (alias: string) => Promise; + fetchProviderModelMeta: () => Promise; + + // Connections + connections: any[]; + selectedConnection: any; + + // Phase 1k: import handlers + canImportModels: boolean; + importingModels: boolean; + handleImportModels: () => Promise; + isAutoSyncEnabled: boolean; + togglingAutoSync: boolean; + handleToggleAutoSync: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + + // Phase 1l: visibility handlers + compatSavingModelId: string | null; + togglingModelId: string | null; + bulkVisibilityAction: "select" | "deselect" | null; + clearingModels: boolean; + modelFilter: string; + testingModelId: string | null; + modelTestStatus: Record; + 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; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + + // 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; + + // 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 && ( + + ); + + const clearAllButton = (modelMeta.customModels.length > 0 || + providerAliasEntries.length > 0) && ( + + ); + + 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 ( +
+
+ {autoSyncToggle} + {clearAllButton} +
+ + 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} + /> +
+ ); + } + + 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 ( +
+
+ + {autoSyncToggle} + {clearAllButton} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ + 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} + /> +
+ ); + } + + const importButton = ( +
+ + {autoSyncToggle} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ ); + + if (models.length === 0) { + return ( +
+ {importButton} +

{t("noModelsConfigured")}

+
+ ); + } + + 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 ( +
+ {importButton} + {modelsWithVisibility.length > 0 && ( + + 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} + /> + )} +
+ {filteredModels.map((model) => { + return ( + 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 && ( +

+ {providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, { + filter: modelFilter, + })} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx new file mode 100644 index 0000000000..2179a0f11b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx @@ -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 ( +
+ + arrow_back + {t("backToProviders")} + +
+
+ +
+
+ {providerInfo.website ? ( + + {providerInfo.name} + open_in_new + + ) : ( +

{providerInfo.name}

+ )} +
+

+ {t("connectionCountLabel", { count: connectionsCount })} +

+ + {providerId === "adapta-web" && ( + + )} +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx new file mode 100644 index 0000000000..a77bb6cd06 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx @@ -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 ; + case "embedding": + return ; + case "image": + return ; + case "tts": + return ; + case "stt": + return ; + case "webSearch": + return ; + case "webFetch": + return ; + case "video": + return ; + case "music": + return ; + 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 & { 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(playgroundableKinds[0] ?? "llm"); + + if (playgroundableKinds.length === 0) return null; + + return ( +
+

Playground

+ + {renderKindPanel(activeKind, providerId)} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx new file mode 100644 index 0000000000..0972380816 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx @@ -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 ( + +

{t("searchProvider")}

+

{t("searchProviderDesc")}

+ {providerId === "perplexity-search" && ( +
+ link +

{t("perplexitySearchSharedKeyInfo")}

+
+ )} + {providerId === "google-pse-search" && ( +
+ tune +

{t("googlePseInfo")}

+
+ )} + {providerId === "searxng-search" && ( +
+ dns +

{t("searxngInfo")}

+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx new file mode 100644 index 0000000000..f93810c4d1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx @@ -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 ( + +
+
+

+ {providerText(t, "upstreamProxyManagedTitle", "Managed via Upstream Proxy Settings")} +

+

+ {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." + )} +

+
+
+ + terminal + {t("openCliTools")} + + + settings + {t("openSettings")} + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx new file mode 100644 index 0000000000..d120dcff1f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button, Card } from "@/shared/components"; + +type ZedImportCardProps = { + fetchConnections: () => Promise; + 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 ( + <> + +
+
+

+ download + Import from Zed Keychain +

+

+ 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. +

+
+ +
+
+ +
+ + {showZedManual && ( +
+

+ Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the + API key that Zed stored under{" "} + ~/.config/zed/settings.json or copy + it from the Zed AI settings panel. +

+
+ + setZedManualToken(e.target.value)} + /> + +
+
+ )} +
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts new file mode 100644 index 0000000000..a6e3d4fcee --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts @@ -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; + fetchProviderModelMeta: () => Promise; + setImportProgress: React.Dispatch>; + 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) => { + 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 }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts new file mode 100644 index 0000000000..be1638da6a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts @@ -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; + 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(null); + const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( + null + ); + const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); + + // ── Claude ───────────────────────────────────────────────────────────────── + const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); + const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( + null + ); + const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); + + // ── Gemini ───────────────────────────────────────────────────────────────── + const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); + const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( + null + ); + const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts new file mode 100644 index 0000000000..ea1a6809d4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts @@ -0,0 +1,290 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { type CommandCodeAuthFlowState } from "../providerPageHelpers"; + +export type UseCommandCodeAuthParams = { + providerId: string; + fetchConnections: () => Promise | 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({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + + const commandCodeAuthWindowRef = useRef(null); + const commandCodeAuthTimerRef = useRef(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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts new file mode 100644 index 0000000000..c892fece2a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts @@ -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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts new file mode 100644 index 0000000000..125e663c03 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts @@ -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; +}; + +export function useExternalLinkFlow({ + providerId, + notify, + fetchConnections, +}: UseExternalLinkFlowParams) { + const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkToken, setExternalLinkToken] = useState(null); + const [externalLinkLoading, setExternalLinkLoading] = useState(false); + const [externalLinkError, setExternalLinkError] = useState(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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts new file mode 100644 index 0000000000..9357405ff3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts @@ -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; + +// ──── 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; + connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record }>; + isFreeNoAuth: boolean; + handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + fetchAliases: () => Promise; + fetchProviderModelMeta: () => Promise; + fetchConnections: () => Promise; + 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>; + handleImportModels: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + handleToggleAutoSync: () => Promise; +} + +// ──── 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({ + 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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts new file mode 100644 index 0000000000..4061c81613 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -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; + +// ──── types ────────────────────────────────────────────────────────────────── + +/** Subset of ModelCompatSavePatch fields needed by this hook. */ +export interface ModelCompatSavePatch { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; +} + +export interface UseModelVisibilityHandlersParams { + providerId: string; + modelAliases: Record; + /** The computed custom-model map from useModelCompatState. */ + customMap: Map; + providerStorageAlias: string; + fetchProviderModelMeta: () => Promise; + fetchAliases: () => Promise; + 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; + 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; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; +} + +// ──── hook ─────────────────────────────────────────────────────────────────── + +export function useModelVisibilityHandlers({ + providerId, + modelAliases, + customMap, + providerStorageAlias, + fetchProviderModelMeta, + fetchAliases, + notify, + t, + selectedConnection, + providerNode, +}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn { + const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [togglingModelId, setTogglingModelId] = useState(null); + const [bulkVisibilityAction, setBulkVisibilityAction] = useState< + "select" | "deselect" | null + >(null); + const [clearingModels, setClearingModels] = useState(false); + const [modelFilter, setModelFilter] = useState(""); + const [testingModelId, setTestingModelId] = useState(null); + const [modelTestStatus, setModelTestStatus] = useState>({}); + 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 | undefined; + let body: Record; + 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 => { + 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 => { + 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 => { + 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, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts new file mode 100644 index 0000000000..0b7c29979e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts @@ -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; + 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 }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index da5b053e0a..35c986e705 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -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; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 7e8c1537fd..6b254addcf 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -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() { } />
+
+
+

+ {translateOrFallback(t, "reasoningTokenBuffer", "Reasoning token buffer")} +

+

+ {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." + )} +

+
+ + setComboDefaults((prev) => ({ + ...prev, + reasoningTokenBufferEnabled: prev.reasoningTokenBufferEnabled === false, + })) + } + /> +

diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx new file mode 100644 index 0000000000..f686776fc3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx @@ -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 ( +

+ { + 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 ? {suffix} : null} +
+ + ); +} + +export default function ModelLockoutCard() { + const t = useTranslations("settings"); + const tc = useTranslations("common"); + const notify = useNotificationStore(); + + const [data, setData] = useState(DEFAULTS); + const [draft, setDraft] = useState(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).modelLockout as + | Record + | 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 = { + "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).modelLockout as + | Record + | 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(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 ( + +
+ + progress_activity + + Loading model lockout settings... +
+
+ ); + } + + return ( + +
+
+
+ + gpp_maybe + +

+ {t("modelLockout") || "Model Lockout"} +

+
+

+ {t("modelLockoutPageDescription")} +

+
+ {hasChanges ? ( +
+ + +
+ ) : ( + + )} +
+ +
+ {/* Master toggle */} +
+ { + setDraft((prev) => ({ ...prev, enabled: checked })); + playNotify(); + }} + label={t("modelLockoutEnabled")} + description={t("modelLockoutEnabledDescription")} + /> +
+ + {/* Error codes — tag input */} +
+ + + {/* Chips row */} + {draft.errorCodes.length > 0 && ( +
+ {draft.errorCodes.map((code) => ( + + {code} + + + ))} +
+ )} + + {/* Input row */} +
+ { + 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" + /> + +
+ + {/* Suggested common codes — chips as clickable suggestions */} + {draft.errorCodes.length === 0 && errorCodesInput === "" && ( +
+ Suggestions: + {[403, 404, 429, 502, 503, 504].map((code) => ( + + ))} +
+ )} +
+ + {/* Cooldowns - grid */} +
+
+ + setDraft((prev) => ({ ...prev, baseCooldownMs })) + } + /> +

+ {t("modelLockoutBaseCooldownDescription")} +

+
+ +
+ + setDraft((prev) => ({ ...prev, maxCooldownMs })) + } + /> +

+ {t("modelLockoutMaxCooldownDescription")} +

+
+
+ + {/* Exponential backoff */} +
+ { + setDraft((prev) => ({ + ...prev, + useExponentialBackoff: checked, + })); + playNotify(); + }} + label={t("modelLockoutExponentialBackoff")} + description={t("modelLockoutExponentialBackoffDescription")} + /> +
+ + {/* Max backoff steps */} +
+ + setDraft((prev) => ({ ...prev, maxBackoffSteps })) + } + /> +

+ {t("modelLockoutMaxBackoffStepsDescription")} +

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 1aa9f0855f..3e9d28edac 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -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 })} /> +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx index ea6ddde29f..d5a0861480 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx @@ -41,7 +41,8 @@ export default function DocumentationTab() {

SOCKS5

{t("proxyDocumentationSocks5DescBefore")}{" "} - ENABLE_SOCKS5_PROXY=true to enable. + ENABLE_SOCKS5_PROXY=false to disable + (ON by default).

diff --git a/src/app/api/intelligence/sync/route.ts b/src/app/api/intelligence/sync/route.ts new file mode 100644 index 0000000000..bf97489a72 --- /dev/null +++ b/src/app/api/intelligence/sync/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 78c9865f71..2e34784e42 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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 = { method: "GET", headers: { "Content-Type": "application/json" }, authQuery: "key", // Use query param for API key - parseResponse: (data) => { - const METHOD_TO_ENDPOINT: Record = { - 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) => { - 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 = { "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(); + + 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; diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 79a2812fe5..9e6ca358e4 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -55,6 +55,7 @@ export async function GET(request: Request) { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + reasoningTokenBufferEnabled: true, zeroLatencyOptimizationsEnabled: false, }, providerOverrides, diff --git a/src/app/api/settings/proxies/egress/route.ts b/src/app/api/settings/proxies/egress/route.ts new file mode 100644 index 0000000000..b43fcd6e6f --- /dev/null +++ b/src/app/api/settings/proxies/egress/route.ts @@ -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"); + } +} diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 9d923cf3c2..4bb156f9b6 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -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"); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index 1c59ee6353..2a878316b8 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -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; 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") { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index df38c1a8ae..811da78b65 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -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, + }) 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 diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 565c97cc34..07901aa6b0 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/lib/arenaEloSync.ts b/src/lib/arenaEloSync.ts new file mode 100644 index 0000000000..587370931c --- /dev/null +++ b/src/lib/arenaEloSync.ts @@ -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 = { + 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 = { + "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 | 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 { + 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> { + const entries: Array> = []; + 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 = { + 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 { + 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 { + if (process.env.ARENA_ELO_SYNC_ENABLED !== "true") { + console.log( + "[ARENA_ELO_SYNC] Disabled (set ARENA_ELO_SYNC_ENABLED=true to enable)" + ); + return; + } + startPeriodicSync(); +} diff --git a/src/lib/db/migrations/097_model_intelligence.sql b/src/lib/db/migrations/097_model_intelligence.sql new file mode 100644 index 0000000000..686e2cb453 --- /dev/null +++ b/src/lib/db/migrations/097_model_intelligence.sql @@ -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; diff --git a/src/lib/db/modelIntelligence.ts b/src/lib/db/modelIntelligence.ts new file mode 100644 index 0000000000..f5ed208e69 --- /dev/null +++ b/src/lib/db/modelIntelligence.ts @@ -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): 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 | 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 | undefined; + + return row ? rowToEntry(row) : null; +} + +export function upsertModelIntelligence(entry: Omit): 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[]; + return rows.map(rowToEntry); +} + +export function bulkUpsertModelIntelligence(entries: Array>): 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()); +} diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 74d114119a..88d65ef78c 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -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); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index ff428ae949..97079d4a2b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -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, diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 3a2bd1d1b0..379f5a535d 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -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), }; } diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts new file mode 100644 index 0000000000..cf40da47d2 --- /dev/null +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -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 = { + 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) => { + 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; + }); +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 61c1aa1d40..c6387ec805 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -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 }; diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts new file mode 100644 index 0000000000..aa2f3a4ea1 --- /dev/null +++ b/src/lib/proxyEgress.ts @@ -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; + +const egressCache = new Map(); + +async function defaultEgressProbe(proxyUrl: string | null): Promise { + 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(); + +/** + * 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 { + 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; + 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; + sharedWithinRotationGroup: EgressSharingWarning[]; +} { + const byEgressIp: Record = {}; + // ip -> rotationGroup -> labels + const byIpGroup = new Map>(); + + 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 { + 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; +}): Promise { + 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 } +): 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 }; +} diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 2fd45e3abc..19cd565f14 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -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) { diff --git a/src/lib/resilience/modelLockoutSettings.ts b/src/lib/resilience/modelLockoutSettings.ts new file mode 100644 index 0000000000..463881e28b --- /dev/null +++ b/src/lib/resilience/modelLockoutSettings.ts @@ -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; + +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 | 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 + ), + }; +} diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 1166238f04..305d9ec6e5 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -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 | null | undefined +): Record | 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; diff --git a/src/server-init.ts b/src/server-init.ts index 5083e7d64c..bf04fa3063 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -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 diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index 1ec9cb2922..ce5f0f3e0a 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -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"); } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index b5b9ec81eb..e855296431 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 40ae9f3268..81acbcd15d 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -304,6 +304,32 @@ export const updateSettingsSchema = z.object({ cliproxyapi_fallback_codes: z.string().max(200).optional(), // CLIProxyAPI model mapping (Record) 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( diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b9b3f74f87..a12cec1663 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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, } ); diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index cdeb3bd447..22f2695bcd 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -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, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index a5fce790cb..22234e1c9e 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -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, }); diff --git a/src/types/settings.ts b/src/types/settings.ts index b46b68cfc7..d1bd0a4bd2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -56,6 +56,7 @@ export interface ComboDefaults { fallbackDelayMs?: number; maxComboDepth: number; trackMetrics: boolean; + reasoningTokenBufferEnabled?: boolean; concurrencyPerModel?: number; queueTimeoutMs?: number; handoffThreshold?: number; diff --git a/tests/integration/gemini-live-429-classification.test.ts b/tests/integration/gemini-live-429-classification.test.ts new file mode 100644 index 0000000000..f8635c79ac --- /dev/null +++ b/tests/integration/gemini-live-429-classification.test.ts @@ -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"); + } + } +); diff --git a/tests/integration/gemini-rate-limit-classification.test.ts b/tests/integration/gemini-rate-limit-classification.test.ts new file mode 100644 index 0000000000..bbeb7cbb6e --- /dev/null +++ b/tests/integration/gemini-rate-limit-classification.test.ts @@ -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); +}); diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts index a4af359c7a..6597b16616 100644 --- a/tests/integration/integration-wiring.test.ts +++ b/tests/integration/integration-wiring.test.ts @@ -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/); diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts index 7dc7135100..dccee521fa 100644 --- a/tests/integration/resilience-http-e2e.test.ts +++ b/tests/integration/resilience-http-e2e.test.ts @@ -558,6 +558,7 @@ test("resilience API only exposes configuration, not runtime breaker state", asy "connectionCooldown", "legacy", "providerBreaker", + "providerCooldown", "requestQueue", "waitForCooldown", ]); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 3b52caa560..c9bcd31648 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -30,6 +30,8 @@ const { isProviderFailureCode, getProvidersInCooldown, getProviderBreakerState, + isCreditsExhausted, + CREDITS_EXHAUSTED_SIGNALS, } = accountFallback; const { selectAccount } = accountSelector; @@ -41,7 +43,6 @@ function makeProfile(overrides: Record = {}): 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 diff --git a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts new file mode 100644 index 0000000000..d8bbad9a8b --- /dev/null +++ b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts @@ -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(); + 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("; ")}`); +}); diff --git a/tests/unit/agy-gemini-400-3229.test.ts b/tests/unit/agy-gemini-400-3229.test.ts index 6d3fc448b0..783db2257a 100644 --- a/tests/unit/agy-gemini-400-3229.test.ts +++ b/tests/unit/agy-gemini-400-3229.test.ts @@ -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"); }); diff --git a/tests/unit/arena-elo-sync.test.ts b/tests/unit/arena-elo-sync.test.ts new file mode 100644 index 0000000000..2c13923428 --- /dev/null +++ b/tests/unit/arena-elo-sync.test.ts @@ -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, +): 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 { + 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>, +): 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 | undefined; + return Number(row?.cnt ?? 0); +} + +function getAllEntries(): Array> { + return testAdapter + .prepare("SELECT * FROM model_intelligence WHERE source = 'arena_elo' ORDER BY model, category") + .all() as Array>; +} + +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()); + }); +}); diff --git a/tests/unit/claude-empty-stream-error-3685.test.ts b/tests/unit/claude-empty-stream-error-3685.test.ts new file mode 100644 index 0000000000..719f2cb638 --- /dev/null +++ b/tests/unit/claude-empty-stream-error-3685.test.ts @@ -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) { + const source = new ReadableStream({ + 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 | 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) { + 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({ + 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" + ); +}); diff --git a/tests/unit/cli-setup-opencode.test.ts b/tests/unit/cli-setup-opencode.test.ts new file mode 100644 index 0000000000..714e901db8 --- /dev/null +++ b/tests/unit/cli-setup-opencode.test.ts @@ -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 `/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(); + } + }); +}); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 69eac65af2..783d2106c0 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -24,6 +24,7 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => { assert.equal(first.failoverBeforeRetry, true); assert.equal(first.maxSetRetries, 0); assert.equal(first.setRetryDelayMs, 2000); + assert.equal(first.reasoningTokenBufferEnabled, true); assert.equal(first.zeroLatencyOptimizationsEnabled, false); assert.equal(first.hedging, false); assert.equal(first.fallbackCompressionMode, "lite"); @@ -70,6 +71,39 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid assert.ok(!("healthCheckEnabled" in result)); }); +test("resolveComboConfig cascades reasoning token buffer feature flag", () => { + const providerDisabled = resolveComboConfig( + {}, + { + comboDefaults: { + reasoningTokenBufferEnabled: true, + }, + providerOverrides: { + openai: { + reasoningTokenBufferEnabled: false, + }, + }, + }, + "openai" + ); + + const comboEnabled = resolveComboConfig( + { + config: { + reasoningTokenBufferEnabled: true, + }, + }, + { + comboDefaults: { + reasoningTokenBufferEnabled: false, + }, + } + ); + + assert.equal(providerDisabled.reasoningTokenBufferEnabled, false); + assert.equal(comboEnabled.reasoningTokenBufferEnabled, true); +}); + test("resolveComboConfig preserves nested routing defaults for partial overrides", () => { const result = resolveComboConfig( { @@ -132,19 +166,23 @@ test("updateComboDefaultsSchema accepts arbitrarily large timeout defaults and p comboDefaults: { timeoutMs: 3600000, targetTimeoutMs: 30000, + reasoningTokenBufferEnabled: false, }, providerOverrides: { anthropic: { timeoutMs: 5400000, targetTimeoutMs: 45000, + reasoningTokenBufferEnabled: false, }, }, }); assert.equal(parsed.comboDefaults.timeoutMs, 3600000); assert.equal(parsed.comboDefaults.targetTimeoutMs, 30000); + assert.equal(parsed.comboDefaults.reasoningTokenBufferEnabled, false); assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 5400000); assert.equal(parsed.providerOverrides.anthropic.targetTimeoutMs, 45000); + assert.equal(parsed.providerOverrides.anthropic.reasoningTokenBufferEnabled, false); }); test("combo config schema accepts explicit zero-latency opt-in controls", () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 3322d54f47..2f3972f785 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -15,6 +15,8 @@ const { resolveNestedComboModels, handleComboChat, } = await import("../../open-sse/services/combo.ts"); +const { resolveReasoningBufferedMaxTokens } = + await import("../../open-sse/services/reasoningTokenBuffer.ts"); const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts"); const { registerStrategy } = await import("../../open-sse/services/autoCombo/routerStrategy.ts"); const { touchSession, clearSessions } = await import("../../open-sse/services/sessionManager.ts"); @@ -2874,7 +2876,7 @@ test("handleComboChat aborts combo when 503 response does NOT contain the unavai test("#3587 reasoning model gets max_tokens buffer applied", async () => { saveModelsDevCapabilities({ openai: { - "gpt-4o-reasoning": capabilityEntry(4096, { reasoning: true }), + "gpt-4o-reasoning": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), }, }); @@ -2885,14 +2887,14 @@ test("#3587 reasoning model gets max_tokens buffer applied", async () => { name: "reasoning-buffer", models: ["openai/gpt-4o-reasoning"], }, - handleSingleModel: async (body: any) => { + handleSingleModel: async (body: Record) => { bodies.push(body); return okResponse(); }, isModelAvailable: async () => true, log: createLog(), settings: null, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2902,6 +2904,129 @@ test("#3587 reasoning model gets max_tokens buffer applied", async () => { assert.equal(bodies[0].max_tokens, 6144, "max_tokens should be buffered for reasoning model"); }); +test("#3587 reasoning buffer preserves max_tokens when the full buffer exceeds model cap", async () => { + saveModelsDevCapabilities({ + openai: { + "gemini-high-cap": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", 64000), + 64000, + "near-cap requests should not be inflated beyond the model's accepted range" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "4096"), + 6144, + "numeric string max_tokens should be normalized before applying a safe buffer" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "not-a-number"), + null, + "non-numeric string max_tokens should not be changed" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", 70000), + 65536, + "already over-cap max_tokens should be clamped to a known explicit cap" + ); + + const bodies: Array> = []; + const result = await handleComboChat({ + body: { max_tokens: 64000 }, + combo: { + name: "reasoning-buffer-near-cap", + models: ["openai/gemini-high-cap"], + }, + handleSingleModel: async (body: Record) => { + bodies.push(body); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(bodies.length, 1, "should have called handleSingleModel once"); + assert.equal(bodies[0].max_tokens, 64000, "max_tokens should remain within the cap"); +}); + +test("#3587 reasoning buffer is disabled without explicit model capability data", async () => { + assert.equal( + resolveReasoningBufferedMaxTokens("missing-provider/unknown-reasoning-model", 100), + null, + "unknown models must not receive heuristic token inflation" + ); + + saveModelsDevCapabilities({ + openai: { + "capless-reasoning": capabilityEntry(8192, { + reasoning: true, + limit_output: null, + }), + "default-cap-reasoning": capabilityEntry(8192, { + reasoning: true, + limit_output: 8192, + }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/capless-reasoning", 100), + null, + "reasoning metadata without an explicit output cap is not safe enough to inflate" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 100), + null, + "default-sized caps are treated as unknown because registry fallbacks use the same value" + ); +}); + +test("#3588 reasoning token buffer feature flag preserves client max_tokens", async () => { + saveModelsDevCapabilities({ + openai: { + "flagged-reasoning": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/flagged-reasoning", 4096, { enabled: false }), + null, + "disabled feature flag should skip reasoning token inflation" + ); + + const bodies: Array> = []; + const result = await handleComboChat({ + body: { max_tokens: 4096 }, + combo: { + name: "reasoning-buffer-disabled", + models: ["openai/flagged-reasoning"], + }, + handleSingleModel: async (body: Record) => { + bodies.push(body); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + reasoningTokenBufferEnabled: false, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(bodies.length, 1, "should have called handleSingleModel once"); + assert.equal(bodies[0].max_tokens, 4096, "feature flag should preserve client max_tokens"); +}); + test("#3587 non-reasoning model does not get max_tokens buffer", async () => { saveModelsDevCapabilities({ openai: { @@ -2916,14 +3041,14 @@ test("#3587 non-reasoning model does not get max_tokens buffer", async () => { name: "no-reasoning-buffer", models: ["openai/gpt-4o-plain"], }, - handleSingleModel: async (body: any) => { + handleSingleModel: async (body: Record) => { bodies.push(body); return okResponse(); }, isModelAvailable: async () => true, log: createLog(), settings: null, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2945,8 +3070,8 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async // the shared-`body` mutation that compounded the buffer on every RR iteration. saveModelsDevCapabilities({ openai: { - "rr-reasoning-a": capabilityEntry(4096, { reasoning: true }), - "rr-reasoning-b": capabilityEntry(4096, { reasoning: true }), + "rr-reasoning-a": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + "rr-reasoning-b": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), }, }); @@ -2958,7 +3083,7 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async strategy: "round-robin", models: ["openai/rr-reasoning-a", "openai/rr-reasoning-b"], }, - handleSingleModel: async (body: any, modelStr: any) => { + handleSingleModel: async (body: Record, modelStr: string) => { seen.push({ model: modelStr, maxTokens: body.max_tokens }); if (modelStr === "openai/rr-reasoning-a") { return new Response(JSON.stringify({ error: { message: "transient" } }), { @@ -2978,7 +3103,7 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async retryDelayMs: 1, }, }, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2992,3 +3117,96 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async "second reasoning model must ALSO buffer from original 4096, not 6144" ); }); + +test("#3588 round-robin honors disabled reasoning token buffer feature flag", async () => { + saveModelsDevCapabilities({ + openai: { + "rr-flagged-a": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + "rr-flagged-b": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + }, + }); + + const seen: Array<{ model: string; maxTokens: unknown }> = []; + const result = await handleComboChat({ + body: { max_tokens: 4096 }, + combo: { + name: "rr-reasoning-buffer-disabled", + strategy: "round-robin", + models: ["openai/rr-flagged-a", "openai/rr-flagged-b"], + }, + handleSingleModel: async (body: Record, modelStr: string) => { + seen.push({ model: modelStr, maxTokens: body.max_tokens }); + if (modelStr === "openai/rr-flagged-a") { + return new Response(JSON.stringify({ error: { message: "transient" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + concurrencyPerModel: 1, + queueTimeoutMs: 5, + maxRetries: 0, + retryDelayMs: 1, + reasoningTokenBufferEnabled: false, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 200); + assert.equal(seen.length, 2, "both reasoning models should have been attempted"); + assert.equal(seen[0].maxTokens, 4096, "first model should preserve client max_tokens"); + assert.equal(seen[1].maxTokens, 4096, "second model should preserve client max_tokens"); +}); + +test("#3587 round-robin keeps near-cap reasoning max_tokens unchanged", async () => { + saveModelsDevCapabilities({ + openai: { + "rr-near-cap-a": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + "rr-near-cap-b": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + }, + }); + + const seen: Array<{ model: string; maxTokens: unknown }> = []; + const result = await handleComboChat({ + body: { max_tokens: 64000 }, + combo: { + name: "rr-reasoning-near-cap", + strategy: "round-robin", + models: ["openai/rr-near-cap-a", "openai/rr-near-cap-b"], + }, + handleSingleModel: async (body: Record, modelStr: string) => { + seen.push({ model: modelStr, maxTokens: body.max_tokens }); + if (modelStr === "openai/rr-near-cap-a") { + return new Response(JSON.stringify({ error: { message: "transient" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + concurrencyPerModel: 1, + queueTimeoutMs: 5, + maxRetries: 0, + retryDelayMs: 1, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 200); + assert.equal(seen.length, 2, "both reasoning models should have been attempted"); + assert.equal(seen[0].maxTokens, 64000, "first reasoning model should keep max_tokens"); + assert.equal(seen[1].maxTokens, 64000, "second reasoning model should keep max_tokens"); +}); diff --git a/tests/unit/combo-streaming-empty-content-failover.test.ts b/tests/unit/combo-streaming-empty-content-failover.test.ts new file mode 100644 index 0000000000..249a70bfb7 --- /dev/null +++ b/tests/unit/combo-streaming-empty-content-failover.test.ts @@ -0,0 +1,283 @@ +/** + * Issue #3685 — streaming combos must fail over to the next target when the + * upstream Claude stream emits a complete lifecycle (message_start → + * message_delta with stop_reason → message_stop) but ZERO content_block_* + * events (e.g. content_filter). Previously `validateResponseQuality` returned + * `{ valid: true }` for ALL streaming responses, so the combo loop never saw + * the empty response as a failure and never advanced to the next target. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateResponseQuality } = await import("../../open-sse/services/combo.ts"); + +const encoder = new TextEncoder(); +const silentLog = { warn: () => {} }; + +/** Build a ReadableStream from an array of SSE-formatted strings (single chunk). */ +function claudeSseStream(events: string[]): ReadableStream { + const body = events.join("\n") + "\n"; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +/** + * Build a ReadableStream that emits each SSE event as a SEPARATE chunk, + * simulating a real incremental network delivery. + */ +function claudeSseStreamMultiChunk(events: string[]): ReadableStream { + const chunks = events.map((e) => encoder.encode(e + "\n")); + let idx = 0; + return new ReadableStream({ + pull(controller) { + if (idx < chunks.length) { + controller.enqueue(chunks[idx++]); + } else { + controller.close(); + } + }, + }); +} + +/** Build a mock Claude 200 streaming response with no content blocks (content_filter case). */ +function makeEmptyClaudeStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_empty", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "content_filter", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 0 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +/** Build a mock Claude 200 streaming response WITH a content block (normal case). */ +function makeNonEmptyClaudeStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_content", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello, world!" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 5 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("#3685 empty Claude stream (content_filter, no content blocks) is marked invalid", async () => { + const res = makeEmptyClaudeStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + false, + `expected invalid for empty content-filtered stream, got valid=true (reason: ${out.reason})` + ); + assert.match( + out.reason ?? "", + /empty/i, + `reason should mention 'empty', got: "${out.reason}"` + ); +}); + +test("#3685 non-empty Claude stream (has content blocks) remains valid", async () => { + const res = makeNonEmptyClaudeStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + true, + `expected valid for non-empty stream, got invalid: ${out.reason}` + ); + // The clonedResponse must be present so the combo loop can pipe the stream body + assert.ok(out.clonedResponse, "clonedResponse must be returned for valid streaming response"); + assert.ok(out.clonedResponse!.body, "clonedResponse must have a body stream"); +}); + +test("#3685 non-SSE streaming response (e.g. plain JSON 200) still passes through as valid", async () => { + // A streaming=true call that returns plain JSON is not our target — preserve existing behavior. + const res = new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, "non-SSE streaming response should still be valid"); +}); + +test("#3685 empty Claude stream without message_start lifecycle → valid (incomplete lifecycle, not content_filter)", async () => { + // A stream that only has partial events (e.g. disconnected before message_start) + // should not trigger the failover since the lifecycle isn't complete — this is + // handled by other mechanisms (stream readiness timeout). + const partialEvents = [ + `event: ping\ndata: ${JSON.stringify({ type: "ping" })}`, + "", + ]; + const res = new Response(claudeSseStream(partialEvents), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, "incomplete lifecycle (no message_start) should pass through"); +}); + +test("#3685 streaming is preserved for non-empty response: clonedResponse body yields full original SSE byte sequence in order", async () => { + // Use a multi-chunk stream to simulate real incremental delivery. + // The content_block_start arrives in its own chunk so the peek loop can + // detect it mid-stream and stop buffering — the rest should forward via + // the original reader. + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_multipart", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: ", world!" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 7 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + // Build the original byte sequence for comparison. + const originalBody = events.map((e) => e + "\n").join(""); + const originalBytes = encoder.encode(originalBody); + + const res = new Response(claudeSseStreamMultiChunk(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const out = await validateResponseQuality(res, true, silentLog); + + assert.equal(out.valid, true, "non-empty multi-chunk stream must be valid"); + assert.ok(out.clonedResponse, "clonedResponse must be present"); + assert.ok(out.clonedResponse!.body, "clonedResponse must have a readable body"); + + // Drain the clonedResponse body and reconstruct the full byte sequence. + const reader = out.clonedResponse!.body!.getReader(); + const receivedChunks: Uint8Array[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + const totalLength = receivedChunks.reduce((sum, c) => sum + c.length, 0); + const reconstructed = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of receivedChunks) { + reconstructed.set(chunk, offset); + offset += chunk.length; + } + + // The reconstructed bytes must exactly match the original SSE byte sequence — + // no data is lost, duplicated, or reordered by the bounded-peek mechanism. + assert.deepEqual( + reconstructed, + originalBytes, + "clonedResponse body must reproduce the FULL original SSE byte sequence (buffered prefix + piped remainder = original)" + ); + + // Verify the response carries SSE content blocks in the decoded text, + // confirming real content was streamed through. + const decoded = new TextDecoder().decode(reconstructed); + assert.ok(decoded.includes("content_block_start"), "decoded body must contain content_block_start"); + assert.ok(decoded.includes("Hello"), "decoded body must contain the actual text content"); + assert.ok(decoded.includes(", world!"), "decoded body must contain the full text delta"); +}); diff --git a/tests/unit/executor-vertex-extended.test.ts b/tests/unit/executor-vertex-extended.test.ts index 02cce26a2a..6318195c40 100644 --- a/tests/unit/executor-vertex-extended.test.ts +++ b/tests/unit/executor-vertex-extended.test.ts @@ -51,17 +51,22 @@ test("VertexExecutor.buildUrl defaults to us-central1 and unknown-project when p apiKey: createServiceAccountJson({ includeProjectId: false }), providerSpecificData: {}, }); - const invalidJson = executor.buildUrl("gemini-2.5-flash", false, 0, { - apiKey: "not-json", - }); assert.equal( missingProject, "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" ); +}); + +test("VertexExecutor.buildUrl routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const expressUrl = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "express-key-abc", + }); + assert.equal( - invalidJson, - "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + expressUrl, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-abc" ); }); @@ -191,20 +196,12 @@ test("VertexExecutor.execute skips Service Account parsing when accessToken is a } }); -test("VertexExecutor.execute rejects invalid or incomplete Service Account JSON clearly", async () => { +test("VertexExecutor.execute rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); - await assert.rejects( - () => - executor.execute({ - model: "gemini-2.5-flash", - body: { contents: [] }, - stream: false, - credentials: { apiKey: "not-json" }, - }), - /Service Account JSON/ - ); - + // A JSON object missing client_email/private_key is treated as a Service Account (not an Express + // key) and must fail clearly when minting a JWT. A non-JSON string is an Express key (covered + // elsewhere) and is intentionally NOT rejected here. await assert.rejects( () => executor.execute({ diff --git a/tests/unit/gemini-models-parser.test.ts b/tests/unit/gemini-models-parser.test.ts new file mode 100644 index 0000000000..bdaac7f0fd --- /dev/null +++ b/tests/unit/gemini-models-parser.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseGeminiModelsList } from "../../src/lib/providerModels/geminiModelsParser"; + +// A representative slice of the live generativelanguage v1beta/models response — including the +// image models (gemini-*-image via generateContent, imagen-* via predict) that the Vertex catalog +// must surface dynamically. +const SAMPLE = { + models: [ + { + name: "models/gemini-2.5-flash", + displayName: "Gemini 2.5 Flash", + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"], + thinking: true, + }, + { + name: "models/gemini-3-pro-image-preview", + displayName: "Gemini 3 Pro Image Preview", + supportedGenerationMethods: ["generateContent", "countTokens"], + }, + { + name: "models/imagen-4.0-generate-001", + displayName: "Imagen 4.0", + supportedGenerationMethods: ["predict"], + }, + { + name: "models/text-embedding-004", + displayName: "Text Embedding 004", + supportedGenerationMethods: ["embedContent"], + }, + { + name: "models/gemini-live-2.5-flash", + displayName: "Gemini Live", + supportedGenerationMethods: ["bidiGenerateContent"], + }, + ], +}; + +test("parseGeminiModelsList strips the models/ prefix and maps display name", () => { + const models = parseGeminiModelsList(SAMPLE); + const flash = models.find((m) => m.id === "gemini-2.5-flash"); + assert.ok(flash, "gemini-2.5-flash should be present"); + assert.equal(flash!.name, "Gemini 2.5 Flash"); + assert.equal(flash!.inputTokenLimit, 1048576); + assert.equal(flash!.outputTokenLimit, 65536); + assert.equal(flash!.supportsThinking, true); + assert.deepEqual(flash!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview"); + assert.ok(proImage, "gemini-3-pro-image-preview should be present"); + // gemini-*-image models generate images via generateContent (chat-with-modalities path). + assert.deepEqual(proImage!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps Imagen predict models to the images endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const imagen = models.find((m) => m.id === "imagen-4.0-generate-001"); + assert.ok(imagen, "imagen-4.0-generate-001 should be present"); + assert.deepEqual(imagen!.supportedEndpoints, ["images"]); +}); + +test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => { + const models = parseGeminiModelsList(SAMPLE); + assert.deepEqual( + models.find((m) => m.id === "text-embedding-004")!.supportedEndpoints, + ["embeddings"] + ); + assert.deepEqual( + models.find((m) => m.id === "gemini-live-2.5-flash")!.supportedEndpoints, + ["audio"] + ); +}); + +test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => { + assert.deepEqual(parseGeminiModelsList({}), []); + assert.deepEqual(parseGeminiModelsList(null), []); + const [m] = parseGeminiModelsList({ models: [{ name: "models/mystery" }] }); + assert.equal(m.id, "mystery"); + assert.deepEqual(m.supportedEndpoints, ["chat"]); +}); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts new file mode 100644 index 0000000000..7c98eb9c7f --- /dev/null +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildKiroUsageResult, + discoverKiroProfileArn, +} from "@omniroute/open-sse/services/usage.ts"; + +// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") +// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). +const IAM_CREDIT_RESPONSE = { + daysUntilReset: 0, + nextDateReset: 1.782864e9, + subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, + usageBreakdownList: [ + { + currency: "USD", + currentUsage: 3670, + currentUsageWithPrecision: 3670.9, + displayName: "Credit", + resourceType: "CREDIT", + unit: "INVOCATIONS", + usageLimit: 10000, + usageLimitWithPrecision: 10000.0, + }, + ], +}; + +test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { + const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { + plan: string; + quotas: Record; + }; + assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); + assert.equal(result.plan, "KIRO POWER"); + const credit = result.quotas.credit; + assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); + assert.equal(credit.used, 3670.9); + assert.equal(credit.total, 10000); + assert.equal(credit.remaining, 10000 - 3670.9); +}); + +test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, + { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-central-1.amazonaws.com", + "eu-central-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn("tok", "https://q.eu-west-1.amazonaws.com", "eu-west-1"); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + + globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/model-intelligence-db.test.ts b/tests/unit/model-intelligence-db.test.ts new file mode 100644 index 0000000000..ba15f9f805 --- /dev/null +++ b/tests/unit/model-intelligence-db.test.ts @@ -0,0 +1,443 @@ +/** + * Unit tests for src/lib/db/modelIntelligence.ts + * + * Uses the project's own DB infrastructure (core.ts getDbInstance) + * with a temp DATA_DIR. Follows the Node.js native test runner pattern. + */ + +import { describe, it, beforeEach } 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-mi-test-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mi = await import("../../src/lib/db/modelIntelligence.ts"); + +function resetStorage(): void { + core.resetDbInstance(); + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + } catch { /* EBUSY — ignore */ } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function insertEntry( + model: string, + source: string, + category: string, + score: number, + opts: { + eloRaw?: number | null; + confidence?: string | null; + expiresAt?: string | null; + } = {}, +): void { + const db = core.getDbInstance(); + db.prepare( + `INSERT OR REPLACE INTO model_intelligence + (model, source, category, score, elo_raw, confidence, synced_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`, + ).run( + model, + source, + category, + score, + opts.eloRaw ?? null, + opts.confidence ?? null, + opts.expiresAt ?? null, + ); +} + +// ─── Tests ─────────────────────────────────────────────── + +describe("upsertModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("inserts a new entry", () => { + mi.upsertModelIntelligence({ + model: "claude-sonnet", + source: "arena_elo", + category: "coding", + score: 0.92, + eloRaw: 1350, + confidence: "high", + expiresAt: null, + }); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "arena_elo", "coding"); + assert.ok(entry, "Row should exist after upsert"); + assert.strictEqual(entry.model, "claude-sonnet"); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.category, "coding"); + assert.strictEqual(entry.score, 0.92); + assert.strictEqual(entry.eloRaw, 1350); + assert.strictEqual(entry.confidence, "high"); + assert.ok(entry.syncedAt, "synced_at should be auto-populated"); + }); + + it("updates an existing entry (INSERT OR REPLACE)", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.85); + + mi.upsertModelIntelligence({ + model: "gpt-4o", + source: "arena_elo", + category: "coding", + score: 0.90, + eloRaw: 1400, + confidence: "high", + expiresAt: null, + }); + + const entry = mi.getModelIntelligenceBySource("gpt-4o", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.score, 0.90); + assert.strictEqual(entry.eloRaw, 1400); + }); +}); + +describe("getModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("returns user_override when all three sources exist (highest priority)", () => { + insertEntry("claude-sonnet", "models_dev_tier", "coding", 0.75); + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const entry = mi.getModelIntelligence("claude-sonnet", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "user_override"); + assert.strictEqual(entry.score, 0.99); + }); + + it("returns arena_elo when no user_override exists", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + insertEntry("gpt-4o", "models_dev_tier", "coding", 0.70); + + const entry = mi.getModelIntelligence("gpt-4o", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.score, 0.87); + }); + + it("returns models_dev_tier when only that source exists", () => { + insertEntry("llama-4", "models_dev_tier", "coding", 0.65); + + const entry = mi.getModelIntelligence("llama-4", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "models_dev_tier"); + assert.strictEqual(entry.score, 0.65); + }); + + it("returns null when no entry exists", () => { + const entry = mi.getModelIntelligence("nonexistent-model", "coding"); + assert.strictEqual(entry, null); + }); + + it("skips expired entries and falls through to next source", () => { + insertEntry("gemini-pro", "arena_elo", "coding", 0.82, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("gemini-pro", "models_dev_tier", "coding", 0.70); + + const entry = mi.getModelIntelligence("gemini-pro", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "models_dev_tier"); + }); + + it("returns null when all entries for a model+category are expired", () => { + insertEntry("expired-model", "arena_elo", "coding", 0.80, { + expiresAt: "2000-01-01T00:00:00Z", + }); + + const entry = mi.getModelIntelligence("expired-model", "coding"); + assert.strictEqual(entry, null); + }); + + it("model names require exact match (case-sensitive in DB)", () => { + insertEntry("Claude-Sonnet", "arena_elo", "coding", 0.90); + + const exact = mi.getModelIntelligence("Claude-Sonnet", "coding"); + assert.ok(exact); + + const different = mi.getModelIntelligence("claude-sonnet", "coding"); + assert.strictEqual(different, null); + }); +}); + +describe("getModelIntelligenceBySource", () => { + beforeEach(() => { resetStorage(); }); + + it("returns a specific source entry", () => { + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.score, 0.88); + }); + + it("returns null when the specific source does not exist", () => { + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "user_override", "coding"); + assert.strictEqual(entry, null); + }); + + it("returns null when no entries exist at all", () => { + const entry = mi.getModelIntelligenceBySource("nonexistent", "arena_elo", "coding"); + assert.strictEqual(entry, null); + }); +}); + +describe("deleteModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("deletes an entry and returns true", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + + const deleted = mi.deleteModelIntelligence("gpt-4o", "arena_elo", "coding"); + assert.strictEqual(deleted, true); + + const entry = mi.getModelIntelligenceBySource("gpt-4o", "arena_elo", "coding"); + assert.strictEqual(entry, null); + }); + + it("returns false when entry does not exist", () => { + const deleted = mi.deleteModelIntelligence("nonexistent", "arena_elo", "coding"); + assert.strictEqual(deleted, false); + }); +}); + +describe("deleteExpiredIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("deletes only expired entries leaving valid ones", () => { + insertEntry("old-model", "arena_elo", "coding", 0.7, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("fresh-model", "arena_elo", "coding", 0.9, { + expiresAt: "2099-12-31T23:59:59Z", + }); + insertEntry("permanent-model", "user_override", "coding", 0.95); + + const deleted = mi.deleteExpiredIntelligence(); + assert.strictEqual(deleted, 1); + + const remaining = mi.listModelIntelligence(); + assert.strictEqual(remaining.length, 2); + }); + + it("deletes expired entries for a specific source only", () => { + insertEntry("old-arena", "arena_elo", "coding", 0.7, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("old-tier", "models_dev_tier", "coding", 0.5, { + expiresAt: "2000-01-01T00:00:00Z", + }); + + const deleted = mi.deleteExpiredIntelligence("arena_elo"); + assert.strictEqual(deleted, 1); + + const remaining = mi.listModelIntelligence(); + assert.strictEqual(remaining.length, 1); + assert.strictEqual(remaining[0].source, "models_dev_tier"); + }); + + it("returns 0 when no expired entries exist", () => { + insertEntry("fresh-model", "arena_elo", "coding", 0.9, { + expiresAt: "2099-12-31T23:59:59Z", + }); + + const deleted = mi.deleteExpiredIntelligence(); + assert.strictEqual(deleted, 0); + }); +}); + +describe("listModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("lists all entries when no filters provided", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "user_override", "coding", 0.95); + + const entries = mi.listModelIntelligence(); + assert.strictEqual(entries.length, 3); + }); + + it("filters by source", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "user_override", "coding", 0.95); + insertEntry("model-c", "models_dev_tier", "coding", 0.6); + + const entries = mi.listModelIntelligence({ source: "arena_elo" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].source, "arena_elo"); + }); + + it("filters by category", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "arena_elo", "documentation", 0.7); + + const entries = mi.listModelIntelligence({ category: "review" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].category, "review"); + }); + + it("filters by both source and category", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "user_override", "coding", 0.95); + + const entries = mi.listModelIntelligence({ source: "arena_elo", category: "coding" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].model, "model-a"); + }); + + it("returns empty array for empty table", () => { + const entries = mi.listModelIntelligence(); + assert.ok(Array.isArray(entries)); + assert.strictEqual(entries.length, 0); + }); +}); + +describe("bulkUpsertModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("bulk inserts multiple entries", () => { + const count = mi.bulkUpsertModelIntelligence([ + { model: "model-a", source: "arena_elo", category: "coding", score: 0.80, eloRaw: 1300, confidence: "high", expiresAt: "2099-12-31T23:59:59Z" }, + { model: "model-b", source: "arena_elo", category: "coding", score: 0.70, eloRaw: 1200, confidence: "medium", expiresAt: "2099-12-31T23:59:59Z" }, + { model: "model-c", source: "arena_elo", category: "review", score: 0.85, eloRaw: 1350, confidence: "high", expiresAt: "2099-12-31T23:59:59Z" }, + ]); + + assert.strictEqual(count, 3); + const entries = mi.listModelIntelligence(); + assert.strictEqual(entries.length, 3); + }); + + it("returns 0 for empty input array", () => { + const count = mi.bulkUpsertModelIntelligence([]); + assert.strictEqual(count, 0); + }); + + it("replaces existing entries on conflict (INSERT OR REPLACE)", () => { + insertEntry("model-a", "arena_elo", "coding", 0.70); + + mi.bulkUpsertModelIntelligence([ + { model: "model-a", source: "arena_elo", category: "coding", score: 0.90, eloRaw: 1450, confidence: "high", expiresAt: null }, + ]); + + const entry = mi.getModelIntelligenceBySource("model-a", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.score, 0.90); + assert.strictEqual(entry.eloRaw, 1450); + }); +}); + +describe("getResolvedTaskFitness", () => { + beforeEach(() => { resetStorage(); }); + + it("returns user_override score when all sources exist", () => { + insertEntry("claude-sonnet", "models_dev_tier", "coding", 0.75); + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const score = mi.getResolvedTaskFitness("claude-sonnet", "coding"); + assert.strictEqual(score, 0.99); + }); + + it("returns arena_elo score when no user_override exists", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + insertEntry("gpt-4o", "models_dev_tier", "coding", 0.70); + + const score = mi.getResolvedTaskFitness("gpt-4o", "coding"); + assert.strictEqual(score, 0.87); + }); + + it("returns models_dev_tier score when only that source exists", () => { + insertEntry("llama-4", "models_dev_tier", "coding", 0.65); + + const score = mi.getResolvedTaskFitness("llama-4", "coding"); + assert.strictEqual(score, 0.65); + }); + + it("returns null when nothing exists", () => { + const score = mi.getResolvedTaskFitness("nonexistent", "coding"); + assert.strictEqual(score, null); + }); + + it("skips expired entries in the resolution chain", () => { + insertEntry("gemini-pro", "arena_elo", "coding", 0.82, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("gemini-pro", "models_dev_tier", "coding", 0.70); + + const score = mi.getResolvedTaskFitness("gemini-pro", "coding"); + assert.strictEqual(score, 0.70); + }); +}); + +describe("edge cases", () => { + beforeEach(() => { resetStorage(); }); + + it("score values are stored and retrieved with float precision", () => { + mi.upsertModelIntelligence({ + model: "precise-model", + source: "arena_elo", + category: "coding", + score: 0.1234, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("precise-model", "coding"); + assert.ok(entry); + assert.ok(Math.abs(entry.score - 0.1234) < 0.0001); + }); + + it("null eloRaw and confidence are handled correctly", () => { + mi.upsertModelIntelligence({ + model: "minimal-model", + source: "models_dev_tier", + category: "default", + score: 0.6, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("minimal-model", "default"); + assert.ok(entry); + assert.strictEqual(entry.eloRaw, null); + assert.strictEqual(entry.confidence, null); + }); + + it("NULL expires_at means never expires", () => { + mi.upsertModelIntelligence({ + model: "permanent-model", + source: "user_override", + category: "coding", + score: 0.95, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("permanent-model", "coding"); + assert.ok(entry); + assert.strictEqual(entry.expiresAt, null); + assert.strictEqual(entry.score, 0.95); + }); +}); diff --git a/tests/unit/model-lockout-decay.test.ts b/tests/unit/model-lockout-decay.test.ts new file mode 100644 index 0000000000..aedc9100a4 --- /dev/null +++ b/tests/unit/model-lockout-decay.test.ts @@ -0,0 +1,104 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; + +describe("decayModelFailureCount — /2 on success", () => { + let accountFallback: typeof import("../../open-sse/services/accountFallback.ts"); + + before(async () => { + accountFallback = await import("../../open-sse/services/accountFallback.ts"); + }); + + it("S1: halves failureCount when model is locked with failureCount=4", () => { + accountFallback.clearAllModelLockouts(); + // Record a lockout with failureCount=4 + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + // Manually bump failureCount to 4 by calling it 3 more times + for (let i = 0; i < 3; i++) { + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + } + + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 2, "failureCount=4 should halve to 2"); + assert.equal(result.cleared, false, "should not be cleared"); + }); + + it("S2: clears lockout when failureCount reaches 0 (failureCount=1 → /2 = 0)", () => { + accountFallback.clearAllModelLockouts(); + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 0, "failureCount=1 should halve to 0 (floor(1/2))"); + assert.equal(result.cleared, true, "should be cleared when count reaches 0"); + }); + + it("S3: no-op when model has no lockout or failure state", () => { + accountFallback.clearAllModelLockouts(); + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 0, "no state → 0"); + assert.equal(result.cleared, false, "no state → not cleared"); + }); + + it("S4: no-op when model is null/undefined", () => { + accountFallback.clearAllModelLockouts(); + const r1 = accountFallback.decayModelFailureCount("openai", "conn-1", null); + assert.equal(r1.newFailureCount, 0, "null model → 0"); + assert.equal(r1.cleared, false, "null model → not cleared"); + + const r2 = accountFallback.decayModelFailureCount("openai", "conn-1", undefined); + assert.equal(r2.newFailureCount, 0, "undefined model → 0"); + assert.equal(r2.cleared, false, "undefined model → not cleared"); + }); + + it("S5: Math.floor(3/2) = 1, then Math.floor(1/2) = 0 → cleared", () => { + accountFallback.clearAllModelLockouts(); + // Start with failureCount=3 + for (let i = 0; i < 2; i++) { + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + } + // Should be failureCount=3 + const r1 = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(r1.newFailureCount, 1, "3/2=1.5 → floor=1"); + assert.equal(r1.cleared, false); + + // Second decay: 1/2=0.5 → floor=0 → cleared + const r2 = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(r2.newFailureCount, 0, "1/2=0.5 → floor=0 → cleared"); + assert.equal(r2.cleared, true); + }); +}); diff --git a/tests/unit/oauth-refresh-error-resilience.test.ts b/tests/unit/oauth-refresh-error-resilience.test.ts new file mode 100644 index 0000000000..aa3125ab79 --- /dev/null +++ b/tests/unit/oauth-refresh-error-resilience.test.ts @@ -0,0 +1,180 @@ +/** + * TDD — OAuth refresh error classification must be resilient to body SHAPE. + * + * Root cause of the production "1352× refresh loop" (claude/aa5dd5cf): when the + * Anthropic 400 body reaches `refreshClaudeOAuthToken` in a non-canonical shape + * (a JSON string instead of an object, a double-encoded string, a nested + * `{error:{code}}`, or the raw text in the catch branch), the old check + * `errorBody.error === "invalid_grant"` evaluated to false, so the function + * returned `null` instead of the `unrecoverable_refresh_error` sentinel. + * + * `null` makes the HealthCheck treat it as a recoverable failure → keeps the + * connection `active` → retries every 60s forever (the loop). The fix is a + * shape-agnostic extractor used by all refreshers that classify invalid_grant. + * + * These tests FAIL before the fix (functions return null) and pass after. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts"); +const { + extractOAuthErrorCode, + refreshClaudeOAuthToken, + refreshClineToken, + refreshQoderToken, + refreshGitHubToken, + isUnrecoverableRefreshError, +} = tokenRefresh as unknown as { + extractOAuthErrorCode: (raw: unknown) => string | null; + refreshClaudeOAuthToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshClineToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshQoderToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshGitHubToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + isUnrecoverableRefreshError: (r: unknown) => boolean; +}; + +function rawResponse(body: string, status = 400, contentType = "application/json") { + return new Response(body, { status, headers: { "content-type": contentType } }); +} + +async function withMockedFetch(impl: typeof fetch, fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +// ── extractOAuthErrorCode: shape matrix ───────────────────────────────────── + +test("extractOAuthErrorCode: canonical object { error: 'invalid_grant' }", () => { + assert.equal(extractOAuthErrorCode({ error: "invalid_grant" }), "invalid_grant"); +}); + +test("extractOAuthErrorCode: nested object { error: { code: 'invalid_grant' } }", () => { + assert.equal(extractOAuthErrorCode({ error: { code: "invalid_grant" } }), "invalid_grant"); +}); + +test("extractOAuthErrorCode: bare string code 'invalid_grant'", () => { + assert.equal(extractOAuthErrorCode("invalid_grant"), "invalid_grant"); +}); + +test("extractOAuthErrorCode: JSON string body '{\"error\":\"invalid_grant\"}'", () => { + assert.equal(extractOAuthErrorCode('{"error": "invalid_grant"}'), "invalid_grant"); +}); + +test("extractOAuthErrorCode: double-encoded JSON string (the production case)", () => { + // response.json() returned the inner JSON AS a string (proxy double-encode) + const doubleEncoded = JSON.stringify('{"error": "invalid_grant", "error_description": "x"}'); + const parsedOnce = JSON.parse(doubleEncoded); // a string, still JSON inside + assert.equal(extractOAuthErrorCode(parsedOnce), "invalid_grant"); +}); + +test("extractOAuthErrorCode: catch-branch shape { error: '' }", () => { + // refreshClaudeOAuthToken's catch did errorBody = { error: text } + const errorBody = { error: '{"error": "invalid_grant", "error_description": "x"}' }; + assert.equal(extractOAuthErrorCode(errorBody), "invalid_grant"); +}); + +test("extractOAuthErrorCode: invalid_request is recognized", () => { + assert.equal(extractOAuthErrorCode({ error: "invalid_request" }), "invalid_request"); +}); + +test("extractOAuthErrorCode: transient errors are NOT misclassified (no false positives)", () => { + assert.equal(extractOAuthErrorCode({ error: "server_error" }), null); + assert.equal(extractOAuthErrorCode("rate_limited"), null); + assert.equal(extractOAuthErrorCode("502 Bad Gateway"), null); + assert.equal(extractOAuthErrorCode(""), null); + assert.equal(extractOAuthErrorCode(null), null); + assert.equal(extractOAuthErrorCode(undefined), null); +}); + +// ── refreshClaudeOAuthToken: every shape → unrecoverable sentinel ──────────── + +const SENTINEL_SHAPES: Array<{ name: string; body: string; ct?: string }> = [ + { name: "canonical object", body: '{"error": "invalid_grant", "error_description": "Refresh token not found or invalid"}' }, + { name: "double-encoded JSON string", body: JSON.stringify('{"error": "invalid_grant", "error_description": "x"}') }, + { name: "bare string code", body: '"invalid_grant"' }, + { name: "nested error.code", body: '{"error": {"code": "invalid_grant", "message": "x"}}' }, + { name: "json served as text/plain", body: '{"error": "invalid_grant"}', ct: "text/plain" }, +]; + +for (const shape of SENTINEL_SHAPES) { + test(`refreshClaudeOAuthToken → unrecoverable sentinel for shape: ${shape.name}`, async () => { + await withMockedFetch( + (async () => rawResponse(shape.body, 400, shape.ct ?? "application/json")) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("dead-refresh-token"); + assert.ok( + isUnrecoverableRefreshError(result), + `shape "${shape.name}" must yield an unrecoverable sentinel, got ${JSON.stringify(result)}` + ); + assert.equal((result as { code?: string }).code, "invalid_grant"); + } + ); + }); +} + +test("refreshClaudeOAuthToken: transient 500 server_error stays null (NOT unrecoverable)", async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("token"); + assert.equal(result, null, "transient errors must remain recoverable (null), not deactivate the account"); + } + ); +}); + +test("refreshClaudeOAuthToken: 502 HTML gateway error stays null", async () => { + await withMockedFetch( + (async () => rawResponse("502 Bad Gateway", 502, "text/html")) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("token"); + assert.equal(result, null); + } + ); +}); + +// ── Previously-frágil refreshers that NEVER emitted a sentinel ────────────── +// refreshClineToken / refreshQoderToken / refreshGitHubToken returned null on +// ANY error → invalid_grant looked recoverable → HealthCheck refresh loop. + +// Note: refreshQoderToken also got the same fix, but it early-returns null via a +// config guard (no clientId/secret in the test env) so it can't be exercised here. +const FRAGILE_REFRESHERS: Array<{ + name: string; + fn: (rt: string) => Promise; +}> = [ + { name: "refreshClineToken", fn: (rt) => refreshClineToken(rt) }, + { name: "refreshGitHubToken", fn: (rt) => refreshGitHubToken(rt) }, +]; + +void refreshQoderToken; // fixed in source; not unit-testable without OAuth config + +for (const r of FRAGILE_REFRESHERS) { + test(`${r.name}: invalid_grant now yields an unrecoverable sentinel`, async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "invalid_grant"}', 400)) as unknown as typeof fetch, + async () => { + const result = await r.fn("dead-token"); + assert.ok( + isUnrecoverableRefreshError(result), + `${r.name} must classify invalid_grant as unrecoverable, got ${JSON.stringify(result)}` + ); + } + ); + }); + + test(`${r.name}: transient 500 server_error stays null`, async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch, + async () => { + const result = await r.fn("token"); + assert.equal(result, null, `${r.name} must keep transient errors recoverable`); + } + ); + }); +} diff --git a/tests/unit/proxy-egress-visibility.test.ts b/tests/unit/proxy-egress-visibility.test.ts new file mode 100644 index 0000000000..86d11905e0 --- /dev/null +++ b/tests/unit/proxy-egress-visibility.test.ts @@ -0,0 +1,190 @@ +/** + * TDD — proxy egress IP visibility. Confirms by which IP each OAuth connection + * leaves, and flags same-rotation-group accounts sharing one egress IP (the + * exact codex anomaly-revocation trigger). Network probe is injected. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const egress = await import("../../src/lib/proxyEgress.ts"); +const { + resolveEgressIp, + analyzeEgressSharing, + diagnoseAllEgressIps, + _setEgressProbeForTests, + clearEgressCache, +} = egress as unknown as { + resolveEgressIp: (u: string | null, o?: any) => Promise<{ ip: string | null; cached: boolean }>; + analyzeEgressSharing: (c: any[]) => { + byEgressIp: Record; + sharedWithinRotationGroup: Array<{ egressIp: string; rotationGroup: string; connections: string[] }>; + }; + diagnoseAllEgressIps: (deps?: any) => Promise; + validateProxyPool: (deps?: any) => Promise; + _setEgressProbeForTests: (fn: any) => void; + clearEgressCache: () => void; +}; +const { validateProxyPool, planProxyDistribution, applyProxyDistribution } = egress as any; + +test("resolveEgressIp returns the probed IP and caches by proxy URL", async () => { + clearEgressCache(); + let calls = 0; + _setEgressProbeForTests(async (proxyUrl: string | null) => { + calls++; + return { ip: proxyUrl ? "203.0.113.7" : "198.51.100.1", latencyMs: 5 }; + }); + + const viaProxy = await resolveEgressIp("http://1.2.3.4:8080"); + assert.equal(viaProxy.ip, "203.0.113.7"); + assert.equal(viaProxy.cached, false); + + const direct = await resolveEgressIp(null); + assert.equal(direct.ip, "198.51.100.1"); + + // second call for the same proxy URL is served from cache (no extra probe) + const again = await resolveEgressIp("http://1.2.3.4:8080"); + assert.equal(again.cached, true); + assert.equal(calls, 2, "only 2 distinct probes (proxy + direct), not 3"); + + _setEgressProbeForTests(null); +}); + +test("analyzeEgressSharing flags ≥2 same-rotation-group accounts on one egress IP", () => { + const result = analyzeEgressSharing([ + { connectionId: "a", provider: "codex", account: "acc-a", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "b", provider: "codex", account: "acc-b", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "c", provider: "openai", account: "acc-c", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "d", provider: "claude", account: "acc-d", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + ]); + + // codex + openai share the openai-auth0 family → 3 accounts on one IP = warning + const warn = result.sharedWithinRotationGroup.find((w) => w.rotationGroup === "openai-auth0"); + assert.ok(warn, "must warn about the codex/openai family sharing an egress IP"); + assert.equal(warn!.egressIp, "100.115.194.84"); + assert.equal(warn!.connections.length, 3, "acc-a + acc-b + acc-c"); + + // claude alone on the IP is NOT a warning (different family, single account) + assert.ok( + !result.sharedWithinRotationGroup.some((w) => w.connections.includes("acc-d")), + "a lone claude account must not be flagged" + ); + + assert.deepEqual(result.byEgressIp["100.115.194.84"].sort(), ["acc-a", "acc-b", "acc-c", "acc-d"]); +}); + +test("analyzeEgressSharing: distinct IPs per account = no warning (the healthy .17 case)", () => { + const result = analyzeEgressSharing([ + { connectionId: "a", provider: "codex", account: "acc-a", proxyLevel: "account", proxyHost: "p1", egressIp: "203.0.113.1" }, + { connectionId: "b", provider: "codex", account: "acc-b", proxyLevel: "account", proxyHost: "p2", egressIp: "203.0.113.2" }, + ]); + assert.equal(result.sharedWithinRotationGroup.length, 0, "1 IP per account is safe"); +}); + +test("diagnoseAllEgressIps wires resolution + probe and surfaces the shared-IP warning", async () => { + clearEgressCache(); + _setEgressProbeForTests(async () => ({ ip: "100.115.194.84", latencyMs: 3 })); + + const diag = await diagnoseAllEgressIps({ + getConnections: async () => [ + { id: "c1", provider: "codex", email: "one@x.com" }, + { id: "c2", provider: "codex", email: "two@x.com" }, + ], + resolveProxy: async () => ({ proxy: { type: "http", host: "9.9.9.9", port: 8080 }, level: "global" }), + }); + + assert.equal(diag.connections.length, 2); + assert.equal(diag.connections[0].egressIp, "100.115.194.84"); + assert.equal(diag.connections[0].proxyLevel, "global"); + assert.equal(diag.sharedWithinRotationGroup.length, 1, "both codex accounts share one egress IP"); + assert.equal(diag.sharedWithinRotationGroup[0].connections.length, 2); + + _setEgressProbeForTests(null); +}); + +test("validateProxyPool marks live proxies active and dead proxies error", async () => { + clearEgressCache(); + // proxy p-live reaches the internet; p-dead times out + _setEgressProbeForTests(async (proxyUrl: string | null) => { + if (proxyUrl && proxyUrl.includes("9.9.9.9")) return { ip: "203.0.113.9", latencyMs: 12 }; + return { ip: null, latencyMs: 7000, error: "timeout" }; + }); + + const marked: Record = {}; + const report = await validateProxyPool({ + listProxies: async () => [ + { id: "p-live", type: "http", host: "9.9.9.9", port: 8080, status: "error" }, + { id: "p-dead", type: "http", host: "1.1.1.1", port: 8080, status: "active" }, + ], + markStatus: async (id: string, status: string) => { + marked[id] = status; + }, + }); + + assert.equal(marked["p-live"], "active", "a reachable proxy must be (re)marked active"); + assert.equal(marked["p-dead"], "error", "an unreachable proxy must be marked error (so resolution skips it)"); + + const live = report.find((r) => r.proxyId === "p-live"); + assert.equal(live!.alive, true); + assert.equal(live!.egressIp, "203.0.113.9"); + assert.equal(live!.previousStatus, "error"); + const dead = report.find((r) => r.proxyId === "p-dead"); + assert.equal(dead!.alive, false); + assert.equal(dead!.previousStatus, "active", "was wrongly active before validation"); + + _setEgressProbeForTests(null); +}); + +test("planProxyDistribution: strict 1:1, extras left unassigned (no shared IP)", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"] + ); + assert.equal(plan.assignments.length, 2); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2"]); + assert.equal(plan.unassigned.length, 1, "c3 has no proxy → unassigned, not sharing"); + assert.equal(plan.unassigned[0].connectionId, "c3"); + assert.equal(plan.sharingRisk, false); +}); + +test("planProxyDistribution: enough proxies → 1 distinct per account", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2", "p3"] + ); + assert.equal(plan.assignments.length, 2); + assert.equal(plan.unassigned.length, 0); + assert.match(plan.note, /1 distinct proxy/); +}); + +test("planProxyDistribution: allowSharing round-robins and flags sharingRisk", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"], + { allowSharing: true } + ); + assert.equal(plan.assignments.length, 3); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2", "p1"]); + assert.equal(plan.sharingRisk, true); +}); + +test("planProxyDistribution: no live proxies → all unassigned with guidance", () => { + const plan = planProxyDistribution([{ id: "c1", account: "a1" }], []); + assert.equal(plan.assignments.length, 0); + assert.equal(plan.unassigned.length, 1); + assert.match(plan.note, /No live proxies/); +}); + +test("applyProxyDistribution assigns each proxy to its connection", async () => { + const calls: Array<[string, string]> = []; + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2"] + ); + const res = await applyProxyDistribution(plan, { + assign: async (connectionId: string, proxyId: string) => { + calls.push([connectionId, proxyId]); + }, + }); + assert.equal(res.applied, 2); + assert.deepEqual(calls, [["c1", "p1"], ["c2", "p2"]]); +}); diff --git a/tests/unit/proxy-resolution-status-filter.test.ts b/tests/unit/proxy-resolution-status-filter.test.ts new file mode 100644 index 0000000000..c2286be251 --- /dev/null +++ b/tests/unit/proxy-resolution-status-filter.test.ts @@ -0,0 +1,95 @@ +/** + * TDD — proxy resolution must not hand out a proxy that has been explicitly + * marked dead (status inactive/error/disabled). Today the resolution queries + * JOIN proxy_registry without any status filter, so an operator (or a health + * check) marking a proxy dead has no effect — it keeps being assigned, every + * request pays the timeout, and accounts fail. Part of the codex-invalidation + * proxy hardening. + * + * Conservative: only EXCLUDE explicit dead states; active/null/unknown stay + * usable so we never strand a working proxy. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("resolution SKIPS an account proxy marked inactive", async () => { + await resetStorage(); + const proxy = await proxiesDb.createProxy({ + name: "Dead Account Proxy", + type: "http", + host: "127.0.0.1", + port: 9991, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("account", "conn-dead", proxy!.id); + + const resolved = await proxiesDb.resolveProxyForConnectionFromRegistry("conn-dead"); + assert.equal(resolved, null, "a dead account proxy must not be resolved"); +}); + +test("resolution STILL returns an active account proxy", async () => { + await resetStorage(); + const proxy = await proxiesDb.createProxy({ + name: "Live Account Proxy", + type: "http", + host: "127.0.0.1", + port: 8081, + }); + await proxiesDb.assignProxyToScope("account", "conn-live", proxy!.id); + + const resolved = await proxiesDb.resolveProxyForConnectionFromRegistry("conn-live"); + assert.ok(resolved, "active proxy must resolve"); + assert.equal((resolved as any).proxy.host, "127.0.0.1"); + assert.equal((resolved as any).level, "account"); +}); + +test("scope resolver skips a dead provider proxy (status=error)", async () => { + await resetStorage(); + const provProxy = await proxiesDb.createProxy({ + name: "Dead Provider Proxy", + type: "http", + host: "10.0.0.1", + port: 9992, + }); + await proxiesDb.updateProxy(provProxy!.id, { status: "error" }); + await proxiesDb.assignProxyToScope("provider", "codex", provProxy!.id); + + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("provider", "codex"); + assert.equal(resolved, null, "a dead provider proxy must not be resolved"); +}); + +test("scope resolver still returns a live global proxy", async () => { + await resetStorage(); + const globalProxy = await proxiesDb.createProxy({ + name: "Live Global Proxy", + type: "http", + host: "10.0.0.2", + port: 8082, + }); + await proxiesDb.assignProxyToScope("global", null, globalProxy!.id); + + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("global"); + assert.ok(resolved, "live global proxy must resolve"); + assert.equal((resolved as any).proxy.host, "10.0.0.2"); +}); diff --git a/tests/unit/services/geminiRateLimitTracker.test.ts b/tests/unit/services/geminiRateLimitTracker.test.ts new file mode 100644 index 0000000000..a9d7deb5c3 --- /dev/null +++ b/tests/unit/services/geminiRateLimitTracker.test.ts @@ -0,0 +1,323 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getModelRpd, + getModelRpm, + incrementRequestCount, + getDailyRequestCount, + getMinuteRequestCount, + isRpdExhausted, + isRpmExhausted, + resetCounters, +} from "../../../open-sse/services/geminiRateLimitTracker.ts"; + +test.beforeEach(() => { + resetCounters(); +}); + +// ── getModelRpd ────────────────────────────────────────────────────────────── + +test("getModelRpd returns known RPD for exact model match", () => { + assert.equal(getModelRpd("gemini-2.5-flash"), 20); +}); + +test("getModelRpd returns known RPD for model with gemini/ prefix", () => { + assert.equal(getModelRpd("gemini/gemini-2.5-flash"), 20); +}); + +test("getModelRpd returns 0 for model with zero RPD (Gemini 2.5 Pro)", () => { + assert.equal(getModelRpd("gemini-2.5-pro"), 0); +}); + +test("getModelRpd returns 0 for unknown model", () => { + assert.equal(getModelRpd("gemini/fake-model-not-in-list"), 0); +}); + +test("getModelRpd returns 0 for empty string", () => { + assert.equal(getModelRpd(""), 0); +}); + +test("getModelRpd matches gemma-4-31b-it via suffix fallback", () => { + // The JSON has "gemma-4-31b-it", and callers may pass "gemma-4-31b-it" + assert.equal(getModelRpd("gemma-4-31b-it"), 1500); +}); + +test("getModelRpd strips gemma- prefix correctly for gemma models", () => { + // stripModelPrefix("gemini/gemma-4-31b-it") → "gemma-4-31b-it" + assert.equal(getModelRpd("gemini/gemma-4-31b-it"), 1500); +}); + +test("getModelRpd handles image-generation models (no RPM value, -1)", () => { + // RPD is 25 for imagen models; RPM is -1 in the JSON + assert.equal(getModelRpd("imagen-4-generate"), 25); +}); + +test("getModelRpd handles models with unlimited RPD (-1)", () => { + // gemini-3.5-live-translate has rpd: -1 + assert.equal(getModelRpd("gemini-3.5-live-translate"), 0); +}); + +test("getModelRpd returns 0 for null input", () => { + assert.equal(getModelRpd(null as unknown as string), 0); +}); + +test("getModelRpd returns 0 for undefined input", () => { + assert.equal(getModelRpd(undefined as unknown as string), 0); +}); + +// ── incrementRequestCount / getDailyRequestCount ───────────────────────────── + +test("incrementRequestCount starts at 1 for first request", () => { + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 1); +}); + +test("incrementRequestCount increments sequentially", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 3); +}); + +test("incrementRequestCount treats gemini/ prefix and bare name as same model", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2); + assert.equal(getDailyRequestCount("gemini/gemini-2.5-flash"), 2); +}); + +test("models have independent counters", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2); + assert.equal(getDailyRequestCount("gemma-4-31b-it"), 1); +}); + +test("getDailyRequestCount returns 0 for model with no requests", () => { + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); +}); + +test("incrementRequestCount does nothing for empty model ID", () => { + incrementRequestCount(""); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); +}); + +test("resetCounters clears all state between tests", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + resetCounters(); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); + assert.equal(getDailyRequestCount("gemma-4-31b-it"), 0); +}); + +// ── isRpdExhausted ─────────────────────────────────────────────────────────── + +test("isRpdExhausted returns false when count is below RPD limit", () => { + // gemini-2.5-flash has RPD=20 + for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); +}); + +test("isRpdExhausted returns true when count equals RPD limit", () => { + // gemini-2.5-flash has RPD=20 + for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); +}); + +test("isRpdExhausted returns true when count exceeds RPD limit", () => { + for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); +}); + +test("isRpdExhausted returns false for model with RPD=0", () => { + // gemini-2.5-pro has rpd=0 + incrementRequestCount("gemini-2.5-pro"); + assert.equal(isRpdExhausted("gemini-2.5-pro"), false); +}); + +test("isRpdExhausted returns false for unknown model", () => { + incrementRequestCount("gemini/unknown-model"); + assert.equal(isRpdExhausted("gemini/unknown-model"), false); +}); + +test("isRpdExhausted returns false for model with unlimited RPD (-1, no data)", () => { + // gemini-3.5-live-translate has rpd: -1 + incrementRequestCount("gemini-3.5-live-translate"); + assert.equal(isRpdExhausted("gemini-3.5-live-translate"), false); +}); + +test("isRpdExhausted works with gemini/ prefix for the model", () => { + for (let i = 0; i < 20; i++) incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini/gemini-2.5-flash"), true); +}); + +// ── Integration: Gemma 4 RPM scenario ──────────────────────────────────────── + +test("Gemma 4: 15 RPM hits never trigger quota_exhausted (RPD=1500)", () => { + // Gemma 4 has RPD=1500, so 15 RPM hits should not trigger RPD exhaustion + for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); + assert.equal(getDailyRequestCount("gemini/gemma-4-31b-it"), 15); +}); + +test("Gemma 4: RPD exhaustion requires 1500 requests", () => { + for (let i = 0; i < 1499; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); + + incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true); +}); + +// ── Integration: Gemini 2.5 Flash RPD scenario ─────────────────────────────── + +test("Gemini 2.5 Flash: first 19 requests do NOT exhaust RPD, 20th does", () => { + for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false, "19 requests < 20 RPD"); + + incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true, "20 requests = 20 RPD"); +}); + +test("Gemini 2.5 Flash: excess requests stay exhausted", () => { + for (let i = 0; i < 22; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 22); +}); + +// ── getModelRpm ─────────────────────────────────────────────────────────────── + +test("getModelRpm returns known RPM for exact model match", () => { + assert.equal(getModelRpm("gemini-2.5-flash"), 5); +}); + +test("getModelRpm returns known RPM for model with gemini/ prefix", () => { + assert.equal(getModelRpm("gemini/gemini-2.5-flash"), 5); +}); + +test("getModelRpm returns 0 for model with zero RPM", () => { + assert.equal(getModelRpm("gemini-2.5-pro"), 0); +}); + +test("getModelRpm returns 0 for unknown model", () => { + assert.equal(getModelRpm("gemini/fake-model-not-in-list"), 0); +}); + +test("getModelRpm returns 0 for empty string", () => { + assert.equal(getModelRpm(""), 0); +}); + +test("getModelRpm returns 0 for models with RPM=-1 (imagen)", () => { + assert.equal(getModelRpm("imagen-4-generate"), 0); +}); + +test("getModelRpm returns 0 for null input", () => { + assert.equal(getModelRpm(null as unknown as string), 0); +}); + +// ── incrementMinuteRequestCount / getMinuteRequestCount ─────────────────────── + +test("getMinuteRequestCount returns 0 before first request", () => { + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); +}); + +test("incrementMinuteRequestCount starts at 1", () => { + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 1); +}); + +test("incrementMinuteRequestCount increments with each call", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 3); +}); + +test("incrementMinuteRequestCount normalizes gemini/ prefix", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2); +}); + +test("minute counters are independent per model", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2); + assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 1); +}); + +test("incrementRequestCount does nothing for empty model ID", () => { + incrementRequestCount(""); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); +}); + +test("resetCounters clears minute windows", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + resetCounters(); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); + assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 0); +}); + +// ── isRpmExhausted ──────────────────────────────────────────────────────────── + +test("isRpmExhausted returns false below RPM limit", () => { + // gemini-2.5-flash has RPM=5 + for (let i = 0; i < 4; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), false); +}); + +test("isRpmExhausted returns true at RPM limit", () => { + for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); +}); + +test("isRpmExhausted returns true above RPM limit", () => { + for (let i = 0; i < 8; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); +}); + +test("isRpmExhausted returns false for model with RPM=0", () => { + incrementRequestCount("gemini-2.5-pro"); + assert.equal(isRpmExhausted("gemini-2.5-pro"), false); +}); + +test("isRpmExhausted returns false for unknown model", () => { + incrementRequestCount("gemini/unknown-model"); + assert.equal(isRpmExhausted("gemini/unknown-model"), false); +}); + +test("isRpmExhausted returns false for model with RPM=-1 (imagen)", () => { + incrementRequestCount("imagen-4-generate"); + assert.equal(isRpmExhausted("imagen-4-generate"), false); +}); + +test("isRpmExhausted works with gemini/ prefix", () => { + for (let i = 0; i < 5; i++) incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini/gemini-2.5-flash"), true); +}); + +// ── Integration: RPM + RPD work independently ───────────────────────────────── + +test("RPM and RPD limits are tracked independently", () => { + // Gemma 4: RPM=15, RPD=1500 + // After 15 requests, RPM is exhausted but RPD is not + 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); +}); + +test("Gemini 2.5 Flash: RPM=5 always hits before RPD=20", () => { + // After 5 requests, RPM is exhausted; after 20, RPD is exhausted + 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); + + incrementRequestCount("gemini-2.5-flash"); // 6 + incrementRequestCount("gemini-2.5-flash"); // 7 + incrementRequestCount("gemini-2.5-flash"); // 8 + + // Still at RPD=8 which is < 20 + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); +}); diff --git a/tests/unit/socks5-default-on.test.ts b/tests/unit/socks5-default-on.test.ts new file mode 100644 index 0000000000..02ed37c616 --- /dev/null +++ b/tests/unit/socks5-default-on.test.ts @@ -0,0 +1,43 @@ +/** + * TDD — SOCKS5 proxy support must default ON (opt-OUT), so a fresh deploy + * (Docker/npm/Electron with no .env) honours SOCKS5 proxies out of the box. + * Previously the code defaulted OFF (`=== "true"`), so SOCKS5 proxies were + * silently rejected unless the operator set the env explicitly — accounts then + * fell back to the host IP. Only an explicit falsey value disables it now. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { isSocks5ProxyEnabled } = await import("../../open-sse/utils/proxyDispatcher.ts"); + +function withEnv(value: string | undefined, fn: () => void) { + const prev = process.env.ENABLE_SOCKS5_PROXY; + if (value === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = value; + try { + fn(); + } finally { + if (prev === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = prev; + } +} + +test("SOCKS5 is ON by default when the env is unset", () => { + withEnv(undefined, () => assert.equal(isSocks5ProxyEnabled(), true)); +}); + +test("SOCKS5 is ON for empty string (treated as unset)", () => { + withEnv("", () => assert.equal(isSocks5ProxyEnabled(), true)); +}); + +test("SOCKS5 stays ON for explicit true-ish values", () => { + for (const v of ["true", "TRUE", "1", "yes", "on"]) { + withEnv(v, () => assert.equal(isSocks5ProxyEnabled(), true, `value ${v} must enable`)); + } +}); + +test("SOCKS5 is OFF only for explicit falsey values (opt-out)", () => { + for (const v of ["false", "FALSE", "0", "no", "off"]) { + withEnv(v, () => assert.equal(isSocks5ProxyEnabled(), false, `value ${v} must disable`)); + } +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index eef3b244f7..ce049cef19 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -1077,6 +1077,14 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko circuitBreakerReset: 5000, }, }, + modelLockout: { + enabled: true, + baseCooldownMs: 250, + maxCooldownMs: 1000, + maxBackoffSteps: 3, + useExponentialBackoff: true, + errorCodes: [404], + }, }); const connection = await seedConnection("openai", { @@ -1101,6 +1109,8 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko assert.equal(updated.rateLimitedUntil, undefined); assert.equal(updated.lastErrorType, "not_found"); assert.equal(Number(updated.errorCode), 404); + + await settingsDb.updateSettings({ modelLockout: null }); }); test("markAccountUnavailable applies a model-only lockout for Gemini 429 responses", async () => { @@ -1481,3 +1491,36 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () db.prepare = originalPrepare; } }); + +test("markAccountUnavailable persists in-memory model lockout for combo transient 429 when persistUnavailableState=false", async () => { + const connection = await seedConnection("openai", { + name: "combo-transient-test", + }); + const model = "gpt-4o"; + const connId = connection.id as string; + + assert.equal(fallback.isModelLocked("openai", connId, model), false); + + await auth.markAccountUnavailable( + connId, + 429, + "Rate limit exceeded", + "openai", + model, + null, + { persistUnavailableState: false } + ); + + assert.equal(fallback.isModelLocked("openai", connId, model), true); + + assert.equal(fallback.isModelLocked("openai", connId, "gpt-4o-mini"), false); + + const otherConn = await seedConnection("openai", { + name: "other-conn", + }); + assert.equal(fallback.isModelLocked("openai", (otherConn.id as string), model), false); + + const updated = await providersDb.getProviderConnectionById(connId); + assert.equal(updated.rateLimitedUntil == null, true); + assert.notEqual(updated.testStatus, "unavailable"); +}); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7447ddc7dd..933bc549d5 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -725,7 +725,11 @@ test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for R created: 1, model: "gpt-5.4", choices: [ - { index: 0, delta: { role: "assistant", content: null, refusal: null }, finish_reason: null }, + { + index: 0, + delta: { role: "assistant", content: null, refusal: null }, + finish_reason: null, + }, ], })}\n\n`, `event: response.created\ndata: ${JSON.stringify({ @@ -1047,50 +1051,46 @@ test("createSSEStream passthrough merges Claude usage chunks and restores mapped assert.equal(onCompletePayload.responseBody.usage.total_tokens, 10); }); -test("createSSEStream passthrough injects a synthetic Claude text block for empty assistant SSE", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `event: message_start\ndata: ${JSON.stringify({ - type: "message_start", - message: { - id: "msg_empty_passthrough", - type: "message", - role: "assistant", - model: "claude-sonnet-4", - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 7, output_tokens: 0 }, +test("#3685 createSSEStream passthrough emits SSE error (not synthetic text) for empty Claude assistant SSE", async () => { + let failurePayload = null; + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_empty_passthrough", + type: "message", + role: "assistant", + model: "claude-sonnet-4", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 7, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ + type: "message_stop", + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "claude", + model: "claude-sonnet-4", + body: { + messages: [{ role: "user", content: "hello" }], }, - })}\n\n`, - `event: message_stop\ndata: ${JSON.stringify({ - type: "message_stop", - })}\n\n`, - ], - { - mode: "passthrough", - sourceFormat: FORMATS.CLAUDE, - provider: "claude", - model: "claude-sonnet-4", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + onFailure(payload) { + failurePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.equal((text.match(/event: message_delta/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_stop") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); }); test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", async () => { @@ -1149,51 +1149,46 @@ test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", assert.doesNotMatch(text, /\[DONE\]/); }); -test("createSSEStream translate mode injects a synthetic Claude text block when OpenAI finishes empty", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, +test("#3685 createSSEStream translate mode emits SSE error (not synthetic text) when OpenAI upstream finishes empty for Claude client", async () => { + let failurePayload = null; + await assert.rejects( + readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, + })}\n\n`, + ], + { + mode: "translate", + targetFormat: FORMATS.OPENAI, + sourceFormat: FORMATS.CLAUDE, + provider: "openai", model: "gpt-4.1-mini", - choices: [{ index: 0, delta: { role: "assistant" } }], - })}\n\n`, - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, - model: "gpt-4.1-mini", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, - })}\n\n`, - ], - { - mode: "translate", - targetFormat: FORMATS.OPENAI, - sourceFormat: FORMATS.CLAUDE, - provider: "openai", - model: "gpt-4.1-mini", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + body: { + messages: [{ role: "user", content: "hello" }], + }, + onFailure(payload) { + failurePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_delta") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); - assert.equal(onCompletePayload.responseBody.usage.total_tokens, 3); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); }); test("createSSETransformStreamWithLogger flushes a trailing Claude usage event without a newline", async () => { @@ -1741,7 +1736,7 @@ test("createSSEStream passthrough logs empty response after tool_calls completio index: 0, id: "call_tc", type: "function", - function: { name: "task_complete", arguments: '{}' }, + function: { name: "task_complete", arguments: "{}" }, }, ], }, @@ -1771,7 +1766,10 @@ test("createSSEStream passthrough logs empty response after tool_calls completio assert.match(text, /"finish_reason":"tool_calls"/); assert.equal(onCompletePayload.status, 200); assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls"); - assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete"); + assert.equal( + onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, + "task_complete" + ); // Content should be null (empty) since no text was generated assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); }); diff --git a/tests/unit/t29-vertex-sa-json-executor.test.ts b/tests/unit/t29-vertex-sa-json-executor.test.ts index e2a2207ce7..f367718084 100644 --- a/tests/unit/t29-vertex-sa-json-executor.test.ts +++ b/tests/unit/t29-vertex-sa-json-executor.test.ts @@ -55,17 +55,34 @@ test("T29: Vertex executor headers include Bearer token and SSE Accept when stre assert.equal(headers.Accept, "text/event-stream"); }); -test("T29: Vertex executor rejects invalid Service Account JSON clearly", async () => { +test("T29: Vertex executor rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); + // A JSON object (not an opaque Express key) that is missing client_email/private_key must still + // fail clearly when the executor tries to mint a JWT from it. await assert.rejects( () => executor.execute({ model: "gemini-2.5-flash", body: { contents: [] }, stream: false, - credentials: { apiKey: "not-json" }, + credentials: { apiKey: JSON.stringify({ project_id: "p" }) }, }), - /Service Account JSON/i + /missing required fields/i + ); +}); + +test("T29: Vertex executor routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const stream = executor.buildUrl("gemini-2.5-flash", true, 0, { apiKey: "express-key-123" }); + const nonStream = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: "express-key-123" }); + + assert.equal( + stream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=express-key-123" + ); + assert.equal( + nonStream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-123" ); }); diff --git a/tests/unit/token-health-check-circuit-breaker.test.ts b/tests/unit/token-health-check-circuit-breaker.test.ts new file mode 100644 index 0000000000..b4da27af55 --- /dev/null +++ b/tests/unit/token-health-check-circuit-breaker.test.ts @@ -0,0 +1,89 @@ +/** + * TDD — HealthCheck refresh circuit breaker. + * + * Production incident: claude/aa5dd5cf refreshed 1352× and kimi-coding 270×, + * each retrying every 60s forever because a refresh that returns `null` + * (network blip, dead proxy, or an unclassified error) leaves the connection + * `active`, so the next sweep tries again immediately — no backoff. + * + * The circuit breaker tracks consecutive refresh failures in + * providerSpecificData.refreshCircuit and computes an exponential backoff + * window. While inside the window, checkConnection must SKIP the refresh + * instead of hammering every tick. A successful refresh clears the circuit. + * + * These exercise the pure helpers (no DB/network needed). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts"); +const { buildRefreshFailureUpdate, isInRefreshBackoff, getRefreshBackoffUntil } = + tokenHealthCheck as unknown as { + buildRefreshFailureUpdate: (conn: any, now: string) => any; + isInRefreshBackoff: (conn: any, nowMs: number) => boolean; + getRefreshBackoffUntil: (streak: number, now: string) => string; + }; + +const NOW = "2026-06-11T12:00:00.000Z"; +const NOW_MS = new Date(NOW).getTime(); + +test("getRefreshBackoffUntil grows exponentially and caps", () => { + const min = (iso: string) => Math.round((new Date(iso).getTime() - NOW_MS) / 60000); + assert.equal(min(getRefreshBackoffUntil(1, NOW)), 5); // 5 * 2^0 + assert.equal(min(getRefreshBackoffUntil(2, NOW)), 10); // 5 * 2^1 + assert.equal(min(getRefreshBackoffUntil(3, NOW)), 20); + assert.equal(min(getRefreshBackoffUntil(4, NOW)), 40); + assert.ok(min(getRefreshBackoffUntil(20, NOW)) <= 240, "must cap at 4h"); +}); + +test("buildRefreshFailureUpdate starts a circuit streak of 1 on first failure", () => { + const update = buildRefreshFailureUpdate({ testStatus: "active" }, NOW); + assert.equal(update.testStatus, "active", "first failure stays routable"); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); + assert.ok( + new Date(update.providerSpecificData.refreshCircuit.until).getTime() > NOW_MS, + "must set a future backoff window" + ); +}); + +test("buildRefreshFailureUpdate increments the streak across consecutive failures", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "active", providerSpecificData: { refreshCircuit: { streak: 3 } } }, + NOW + ); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 4); +}); + +test("buildRefreshFailureUpdate preserves unrelated providerSpecificData", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "active", providerSpecificData: { projectId: "p-123", copilotToken: "x" } }, + NOW + ); + assert.equal(update.providerSpecificData.projectId, "p-123"); + assert.equal(update.providerSpecificData.copilotToken, "x"); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); +}); + +test("isInRefreshBackoff true while within the window, false after", () => { + const conn = { + providerSpecificData: { refreshCircuit: { until: getRefreshBackoffUntil(2, NOW) } }, + }; + assert.equal(isInRefreshBackoff(conn, NOW_MS), true, "10min window, now → inside"); + assert.equal(isInRefreshBackoff(conn, NOW_MS + 11 * 60000), false, "after 11min → outside"); +}); + +test("isInRefreshBackoff false when no circuit recorded", () => { + assert.equal(isInRefreshBackoff({}, NOW_MS), false); + assert.equal(isInRefreshBackoff({ providerSpecificData: {} }, NOW_MS), false); + assert.equal(isInRefreshBackoff({ providerSpecificData: { refreshCircuit: {} } }, NOW_MS), false); +}); + +test("expired connections still track expiredRetryCount AND the circuit", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "expired", expiredRetryCount: 1 }, + NOW + ); + assert.equal(update.testStatus, "expired"); + assert.equal(update.expiredRetryCount, 2); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); +}); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 1bf5ca456a..cd819c574f 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -84,7 +84,8 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.equal(result.stream, true); assert.equal(result.max_tokens, 33); assert.equal(result.temperature, 0.25); - assert.equal(result.top_p, 0.8); + // top_p is stripped when temperature is also present (Anthropic rejects both). + assert.equal(result.top_p, undefined); assert.deepEqual(result.stop_sequences, ["DONE"]); assert.equal(result.system[0].text, "Rule A\nRule B\nRule C"); assert.equal(result.messages[0].role, "user"); @@ -94,6 +95,21 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.deepEqual(result.messages[1].content[0].cache_control, { type: "ephemeral" }); }); +test("OpenAI -> Claude strips top_p when temperature is also present", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Hello" }], + temperature: 0.25, + top_p: 0.8, + }, + false + ); + + assert.equal(result.temperature, 0.25); + assert.equal(result.top_p, undefined); +}); + test("OpenAI -> Claude converts multimodal content, tool declarations, tool calls and tool results", () => { const result = openaiToClaudeRequest( "claude-4-sonnet", diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 04bf81de7b..dcae756f0a 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -1362,11 +1362,17 @@ test("native-mode assistant tool_calls produce functionCall parts, not text labe ); }); -// Integration: registered translator (OPENAI -> GEMINI) emits native functionCall/functionResponse -test("registered OPENAI->GEMINI translator uses native functionCall/functionResponse, not text labels", () => { +// Integration: registered translator (OPENAI -> GEMINI) uses context-mode for +// signature-less tool calls (post-#3688 fix: "native" → "context" registration). +// A signature-less functionCall must be omitted from native parts and represented +// as context text so the standard Gemini API does not return HTTP 400. +// For a SIGNED call (signature in store) native parts must still be emitted — see +// the "keeps native functionCall+thoughtSignature when signature is present" test. +test("registered OPENAI->GEMINI translator uses context-mode for signature-less tool calls, not text labels or native bare functionCall", () => { const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); assert.ok(typeof translate === "function", "registered translator must be a function"); + // No signature in store (cleared by beforeEach) — context-mode fallback applies. const result = translate( "gemini-2.0-flash", { @@ -1395,15 +1401,18 @@ test("registered OPENAI->GEMINI translator uses native functionCall/functionResp ) as any; const body = JSON.stringify(result); - // Native mode: functionCall/functionResponse parts, no text labels - assert.ok( + // Context mode: signature-less functionCall must NOT appear as a native part. + assert.equal( body.includes('"functionCall"'), - "registered translator must emit native functionCall parts" + false, + "registered translator must NOT emit bare native functionCall parts for signature-less calls (would trigger HTTP 400)" ); - assert.ok( + assert.equal( body.includes('"functionResponse"'), - "registered translator must emit native functionResponse parts" + false, + "registered translator must NOT emit native functionResponse for signature-less calls" ); + // Old leaky text labels must never appear. assert.equal( body.includes("Historical tool-call record only"), false, @@ -1417,6 +1426,133 @@ test("registered OPENAI->GEMINI translator uses native functionCall/functionResp assert.equal( body.includes("[tool_history_call:"), false, - "native mode must NOT emit text labels" + "text-label format must NOT be used by registered GEMINI translator" + ); + // Context-mode: tool result must appear as block. + assert.ok( + body.includes("previous_tool_result_context"), + "signature-less tool result must be represented as context text block" + ); +}); + +// Regression for #3688: standard Gemini (AI Studio) returns HTTP 400 +// "Function call is missing a thought_signature" when a multi-turn conversation +// includes a functionCall whose signature was not captured (process restart / TTL +// expiry / never stored). The standard GEMINI registration must use "context" mode +// so signature-less tool calls are omitted from the native parts and represented as +// context text, avoiding the 400 while preserving conversational continuity. +test("registered OPENAI->GEMINI translator falls back to context mode for signatureless tool calls (#3688)", () => { + // Signature store is cleared by beforeEach — no stored signature for call_3688. + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); + assert.ok(typeof translate === "function", "registered translator must be a function"); + + const result = translate( + "gemini-2.5-pro-preview", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_3688_missing_sig", + type: "function", + function: { name: "bash", arguments: '{"cmd":"ls /tmp"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_3688_missing_sig", + content: "file-a.txt\nfile-b.txt", + }, + { role: "user", content: "What files did you see?" }, + ], + }, + false, + { _signatureNamespace: "conn-3688-test" } + ) as any; + + const body = JSON.stringify(result); + + // (a) NO functionCall part lacking a thoughtSignature must appear. + // A functionCall without a thoughtSignature is the exact payload that triggers + // Gemini's HTTP 400 "Function call is missing a thought_signature". + const modelTurn = result.contents.find( + (c: any) => c.role === "model" && c.parts?.some((p: any) => p.functionCall) + ); + assert.equal( + modelTurn, + undefined, + "standard GEMINI translator must NOT emit a functionCall part when thoughtSignature is absent (would trigger HTTP 400)" + ); + + // (b) The tool call/result must be represented as context/text fallback instead. + // In "context" mode the response is wrapped in tags. + assert.ok( + body.includes("previous_tool_result_context"), + "signature-less tool result must be represented as a context text block when signature is absent" + ); + assert.ok( + body.includes("file-a.txt"), + "context text block must contain the tool response content" + ); +}); + +// Happy-path: when thoughtSignature IS present in the store, the registered +// OPENAI->GEMINI translator must still emit native functionCall + thoughtSignature +// (no regression on the signed-signature path fixed by #2504). +test("registered OPENAI->GEMINI translator keeps native functionCall+thoughtSignature when signature is present (#2504 no-regression)", async () => { + const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + const ns = "conn-3688-signed-happy"; + const toolId = "call_3688_signed"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_3688_HAPPY_PATH"); + + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); + const result = translate( + "gemini-2.5-pro-preview", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: toolId, + type: "function", + function: { name: "bash", arguments: '{"cmd":"echo hi"}' }, + }, + ], + }, + { role: "tool", tool_call_id: toolId, content: "hi" }, + { role: "user", content: "What did it say?" }, + ], + }, + false, + { _signatureNamespace: ns } + ) as any; + + const body = JSON.stringify(result); + + // The functionCall part WITH the thoughtSignature must be present. + assert.ok( + body.includes("SIG_3688_HAPPY_PATH"), + "cached thoughtSignature must be re-attached to the functionCall (happy-path regression check)" + ); + assert.ok( + body.includes('"functionCall"'), + "signed tool call must be emitted as native functionCall (not context text)" + ); + assert.ok( + body.includes('"functionResponse"'), + "signed tool response must be emitted as native functionResponse" + ); + assert.equal( + body.includes("previous_tool_result_context"), + false, + "signed tool call must NOT fall back to context text" ); }); diff --git a/tests/unit/vertex-express-apikey.test.ts b/tests/unit/vertex-express-apikey.test.ts new file mode 100644 index 0000000000..555b11c1de --- /dev/null +++ b/tests/unit/vertex-express-apikey.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VertexExecutor, isExpressApiKey, looksLikeServiceAccountJson } = await import( + "../../open-sse/executors/vertex.ts" +); + +test("looksLikeServiceAccountJson is true only for a JSON object credential", () => { + assert.equal(looksLikeServiceAccountJson(JSON.stringify({ project_id: "p" })), true); + assert.equal(looksLikeServiceAccountJson("express-opaque-key"), false); + assert.equal(looksLikeServiceAccountJson(JSON.stringify([1, 2, 3])), false); + assert.equal(looksLikeServiceAccountJson(""), false); +}); + +test("isExpressApiKey is true for a non-empty, non-JSON credential", () => { + assert.equal(isExpressApiKey("AIzaSyExpressKey"), true); + assert.equal(isExpressApiKey(" "), false); + assert.equal(isExpressApiKey(""), false); + assert.equal(isExpressApiKey(null), false); + assert.equal(isExpressApiKey(undefined), false); + assert.equal(isExpressApiKey(JSON.stringify({ project_id: "p" })), false); +}); + +test("buildUrl Express: streaming google model uses streamGenerateContent + ?alt=sse&key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", true, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse&key=k-express" + ); +}); + +test("buildUrl Express: non-streaming google model uses generateContent?key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", false, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=k-express" + ); +}); + +test("buildUrl Express: the API key is URL-encoded and trimmed", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: " a/b+c=d " }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=a%2Fb%2Bc%3Dd" + ); +}); + +test("buildUrl Express: a present accessToken takes the Service Account path, not Express", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "k-express", + accessToken: "ya29.token", + providerSpecificData: { region: "us-central1" }, + }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + ); +}); + +test("buildHeaders for an Express key (no accessToken) omits the Authorization header", () => { + const executor = new VertexExecutor(); + const headers = executor.buildHeaders({ apiKey: "k-express" }, false); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers.Authorization, undefined); +}); + +test("execute with an Express key calls the publisher endpoint directly (no OAuth token exchange)", async () => { + const executor = new VertexExecutor(); + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url: any) => { + calls.push(String(url)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gemini-3-flash-preview", + body: { contents: [{ role: "user", parts: [{ text: "hi" }] }] }, + stream: false, + credentials: { apiKey: "AIzaSyExpressKey" }, + } as any); + + assert.equal(result.response.status, 200); + // Exactly one call — straight to Vertex, no oauth2 token mint. + assert.equal(calls.length, 1); + assert.equal( + calls[0], + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=AIzaSyExpressKey" + ); + } finally { + globalThis.fetch = originalFetch; + } +});