mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
Merge remote-tracking branch 'origin/release/v3.8.23' into r4/combo
This commit is contained in:
10
.env.example
10
.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)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
5
.github/FUNDING.yml
vendored
Normal file
5
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Funding links for OmniRoute — rendered as the "Sponsor" button on GitHub.
|
||||
# Docs: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
github: diegosouzapw
|
||||
# Additional platforms (uncomment and fill in before enabling):
|
||||
# custom: ["https://omniroute.online/donate"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
32
CHANGELOG.md
32
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 <id>`
|
||||
The prepublish pipeline (Step 8.8) now builds the plugin's `dist/` via tsup so every
|
||||
published tarball includes it. Users no longer need to extract the plugin manually.
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
- **`@omniroute/opencode-plugin`: baseURL resolution for partner/tiered providers**
|
||||
([#3711] / [#3726] — thanks @herjarsa):
|
||||
The `provider.models` hook now checks `_provider.options.baseURL` (set by the config
|
||||
hook or opencode.json) as a third fallback after plugin opts and auth.json. Previously,
|
||||
providers whose baseURL came from options returned zero models. A diagnostic `console.warn`
|
||||
fires when no baseURL is resolvable from any source.
|
||||
|
||||
---
|
||||
|
||||
## [3.8.22] — TBD
|
||||
## [3.8.23] — TBD
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
@@ -17,6 +40,12 @@
|
||||
- **Combo strategy fallback coverage** (`tests/unit/combo-strategy-fallbacks.test.ts`, 11 tests): fill-first / p2c / random / cost-optimized / strict-random fallback paths (previously happy-path only), price-tie stability, stale strict-random deck degradation, unknown-strategy normalization to priority, and circuit-breaker HALF_OPEN recovery inside the combo loop + `preScreenTargets` (lazy-recovery contract).
|
||||
- **`#1731` fast-skip suite restored** (`tests/integration/combo-provider-exhaustion.test.ts`): the five skipped tests were rewritten against the current routing policy (quota-exhausted 429 marks the provider for the request; transient 429 retries other connections; connection errors skip per-connection; nothing persists across requests) and re-enabled — 8/8 green.
|
||||
- **Proxy context passthrough** (`tests/integration/proxy-context-passthrough.test.ts`): combo targets each execute under their own connection's proxy; `count_tokens` runs inside the connection's proxy context.
|
||||
### 🐛 Fixed
|
||||
|
||||
- chore(quality-gate): reconcile check-file-size baseline — freeze 27 inherited/this-round grown files at current LOC + register providerLimits.ts (greens the file-size gate; shrink tracked via #3501)
|
||||
- fix(gemini): standard Gemini provider no longer sends tool calls without a thought_signature (falls back to context mode when the signature is unavailable), fixing HTTP 400 on multi-turn thinking-model tool calls (#3688)
|
||||
- fix(antigravity): preserve gemini-3.1-pro High/Low budget tiers (upstream accepts the suffixed ids; stop collapsing to bare gemini-3.1-pro) (#3696)
|
||||
- fix: streaming combos now fail over to the next target when an upstream returns an empty/content-filtered response instead of surfacing a blank reply (#3685)
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +69,7 @@
|
||||
- **Provider-detail god-component decomposition — Phase 2b (remaining shared helpers→leaf)** ([#3501]): extended `providers/[id]/providerPageHelpers.ts` with all remaining pure helpers needed by the heavy modals (`AddApiKeyModal`/`EditConnectionModal`) before they can be extracted. Moved 22 symbols: web-session credential label/hint/check/title helpers; upstream-headers helpers (`upstreamHeadersRecordsEqual`, `headerRowsToRecord`, `effectiveUpstreamHeadersForProtocol`, `anyUpstreamHeadersBadge`, `getProtoSlice`) plus their `HeaderDraftRow`/`CompatModelRow`/`CompatModelMap`/`CompatByProtocolMap` types; Codex consts and helpers (`CODEX_REASONING_STRENGTH_OPTIONS`, `CODEX_ACCOUNT_SERVICE_TIER_VALUES`, `CODEX_GLOBAL_SERVICE_MODE_VALUES`, `getCodexServiceTierLabel`, `normalizeCodexLimitPolicy`, `getCodexRequestDefaults`, `getClaudeCodeCompatibleRequestDefaults`); misc helpers (`compatProtocolLabelKey`, `extractCommandCodeCredentialInput`, `normalizeAndValidateHttpBaseUrl`, `SILICONFLOW_ENDPOINTS`, `CommandCodeAuthFlowState`). New transitive imports wired into the leaf: `MODEL_COMPAT_PROTOCOL_KEYS` (`@/shared/constants/modelCompat`), `CodexServiceTier`/`getCodexRequestDefaults`/`getClaudeCodeCompatibleRequestDefaults` (`@/lib/providers/requestDefaults`), `CodexGlobalServiceMode` (`@/lib/providers/codexFastTier`), `WebSessionCredentialRequirement` (`./webSessionCredentials`). `ProviderDetailPageClient.tsx`: 10,288 → 9,980 LOC. Leaf module: 589 LOC (acyclic). 25-assertion unit test suite passes; smoke test 3/3; no import cycles. Co-authored with @oyi77.
|
||||
- **Provider-detail god-component decomposition — Phase 2 (helpers→lib)** ([#3501]): extracted the pure shared helpers — `ProviderMessageTranslator`/`LocalProviderMetadata` types, `providerText`/`providerCountText`/`readBooleanToggle`, and the provider base-URL + routing-tag/excluded-model parse/format block — into a new leaf `providers/[id]/providerPageHelpers.ts` (imports only `@/shared`, so the client and modals share them with no import cycle). `ProviderDetailPageClient.tsx`: 10,435 → 10,288 LOC. Unblocks extracting the heavier `AddApiKeyModal`/`EditConnectionModal` (which depend on these helpers) without cycling. The Phase 0 smoke test caught a missing transitive import (`isSelfHostedChatProvider`) at mount — now wired + locked by a new helpers unit test (12 assertions). Co-authored with @oyi77.
|
||||
|
||||
- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call_logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open *another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB.
|
||||
- **#3500 fully resolved** — Hard Rule #5 (no raw SQL in route handlers): all 13 internal offenders migrated to `src/lib/db/` modules across slices (call*logs, usage_history/daily_usage_summary, community_servers, usage_logs, semantic_cache, proxy_logs, skills UPDATE, db-backups). The gate's `KNOWN_RAW_SQL` set is renamed to `EXTERNAL_DB_ALLOWED` (with a back-compat alias) and now holds only the **2 external-DB reads** (`oauth/cursor/auto-import`, `oauth/kiro/auto-import`) — these open \_another app's* SQLite to import credentials, so by design they cannot live in OmniRoute's `db/` domain. The gate still blocks any NEW raw SQL against OmniRoute's DB.
|
||||
- **chore(db-gate):** reclassify `KNOWN_UNEXPORTED` → `INTENTIONALLY_INTERNAL` in `scripts/check/check-db-rules.mjs` ([#3499]): a full audit of all 25 db modules confirmed each is consumed via direct/dynamic import per Hard Rule #2 ("Never barrel-import from localDb.ts"). The old framing labelled them as "debt", which was misleading — they are the correct pattern. The gate's blocking behaviour is unchanged (a NEW unexported module still fails); only the name, comments, and per-module justifications were updated to reflect audited truth. Four modules flagged `DEAD?` (`compressionScheduler`, `discovery`, `pluginMetrics`, `prompts`) have zero production importers and are documented as schema-reserved. A new regression-guard test (`tests/unit/check-db-rules-classification.test.ts`) asserts every non-dead module in the set has ≥1 real importer, so a future consumer removal surfaces as a test failure requiring explicit reclassification.
|
||||
- **refactor(db): move `call_logs` aggregations into `callLogStats` db module** ([#3500]): extracted raw SQL from three route handlers (`/api/provider-metrics`, `/api/search/stats`, `/api/v1/search/analytics`) into a new `src/lib/db/callLogStats.ts` domain module (`getProviderMetrics`, `getSearchProviderStats`, `getRecentSearchLogs`, `getSearchAggregateStats`, `getSearchProviderCounts`). First slice of #3500 (call_logs cluster). Behavior unchanged; the three routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (6 assertions seeding an in-memory SQLite fixture).
|
||||
@@ -47,6 +77,7 @@
|
||||
|
||||
- **refactor(db): move `community_servers` auth look-up into `gamification` db module** ([#3500]): extracted raw SQL from two federation route handlers (`/api/gamification/federation/leaderboard`, `/api/gamification/federation/score`) into a new `getConnectedServerByKeyHash(apiKeyHash)` function in `src/lib/db/gamification.ts`. Third slice of #3500 (gamification federation cluster). Behavior unchanged; the two routes are removed from `KNOWN_RAW_SQL` in the gate. Validated with TDD unit tests (3 assertions seeding a temp SQLite fixture).
|
||||
|
||||
- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`).
|
||||
- **refactor(db): move `skills UPDATE` + `db-backups` SQL into db modules** ([#3500]): fifth slice of #3500. Extracted the dynamic `UPDATE skills SET …` from `src/app/api/skills/[id]/route.ts` into a new `src/lib/db/skills.ts` module (`updateSkill(id, patch)`). The dynamic SET clause is injection-safe: column names are validated against a hard-coded allowlist of known writable columns before being interpolated; unknown keys are silently ignored. Extended `src/lib/db/backup.ts` with three new functions: `exportAllSummaryRows()` (multi-table SELECT for key_value / combos / provider_connections / api_keys, used by exportAll), `getTableNamesFromAdapter()` (sqlite_master introspection via an adapter arg, used by import validation), and `countImportedRows()` (post-import COUNT(\*) per table). The backup domain module is the correct home for sqlite_master introspection — it is not "raw SQL in a route" once moved there. `KNOWN_RAW_SQL` drops by 3 (from 8 → 5). Validated with 11 TDD unit tests (`tests/unit/db-backups-skills-3500.test.ts`).
|
||||
- **refactor(db): move `usage_logs`/`semantic_cache`/`proxy_logs` SQL into db modules** ([#3500]): extracted raw `db.prepare(...)` SQL from three route handlers (`/api/analytics/auto-routing` → `usageLogs.ts`; `/api/cache/entries` → `semanticCache.ts`; `/api/logs/export` → `proxyLogs.ts`) into new `src/lib/db/` domain modules. New exports: `getAutoRoutingTotalCount`, `getAutoRoutingVariantBreakdown`, `getAutoRoutingTopProviders` (usage_logs), `listSemanticCacheEntries`, `deleteSemanticCacheBySignature`, `deleteSemanticCacheByModel` (semantic_cache), and `exportProxyLogsSince` (proxy_logs). Fourth slice of #3500. `KNOWN_RAW_SQL` drops from 8 → 5. Validated with 13 TDD unit tests (`tests/unit/db-logs-cache-3500.test.ts`) seeding temp SQLite fixtures.
|
||||
|
||||
@@ -153,6 +184,7 @@
|
||||
- **fix(translator):** fix OpenAI→Gemini translation of historical tool calls — tool results from earlier turns were being converted to text, causing Gemini to pattern-match the response as prose rather than structured content; they now use the native Gemini `functionResponse` part format. ([#3569](https://github.com/diegosouzapw/OmniRoute/pull/3569) — thanks @hartmark)
|
||||
- **fix(plugins):** forward plugin lifecycle hooks (`onInstall`, `onActivate`, `onDeactivate`, `onUninstall`) via IPC and wrap `onDeactivate`/`onUninstall` in try/catch so a buggy plugin handler can no longer brick teardown; also removes redundant `RegExp()` wrappers in `accountFallback.ts` and fixes indentation in `requestLogger.ts`. ([#3562](https://github.com/diegosouzapw/OmniRoute/pull/3562) — thanks @oyi77)
|
||||
- **fix(auto-update):** use a stable PROJECT_ROOT walker instead of frozen `process.cwd()` — `resolveProjectRoot` now walks up from `__dirname` to find the nearest directory containing `package.json` or `.git` (bounded at 16 levels), preventing ENOENT errors when the working directory is not the project root. ([#3561](https://github.com/diegosouzapw/OmniRoute/pull/3561) — thanks @oyi77 / @ViFigueiredo via [#3423](https://github.com/diegosouzapw/OmniRoute/pull/3423))
|
||||
- **fix(resilience):** expose `providerCooldown` in `GET /api/resilience` and accept it in `PATCH` — PR #3556 added the global provider cooldown tracker to the settings model and `ResilienceTab` UI, but the API route never returned the field (causing a crash when the tab loaded) and `updateResilienceSchema` (`.strict()`) rejected PATCH bodies containing it with 400. `providerCooldownSettingsSchema` is now wired into the Zod schema, returned in the GET response, and merged in the PATCH handler. Caught during v3.8.20 VPS homologation (Hard Rule #18 — 5 TDD tests); shipped to main via [#3591](https://github.com/diegosouzapw/OmniRoute/pull/3591). ([#3590](https://github.com/diegosouzapw/OmniRoute/pull/3590) — thanks @diegosouzapw)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
384
bin/cli/commands/setup-open-code.mjs
Normal file
384
bin/cli/commands/setup-open-code.mjs
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* omniroute setup opencode — Wire the bundled @omniroute/opencode-plugin
|
||||
* into a local OpenCode install.
|
||||
*
|
||||
* Closes the gap where `npm install -g omniroute` ships the plugin
|
||||
* inside the omniroute package (`@omniroute/opencode-plugin/dist/`) but
|
||||
* OpenCode discovers plugins via `~/.config/opencode/plugins/` or
|
||||
* via entries in `opencode.json`. Without this command, the user has
|
||||
* to extract the tarball and wire it up by hand (see the plugin README,
|
||||
* "Install" section).
|
||||
*
|
||||
* What it does, in order:
|
||||
* 1. Resolves the bundled plugin path (source + built dist).
|
||||
* 2. Resolves the OpenCode config directory (XDG-aware).
|
||||
* 3. Copies the built plugin into `<opencode>/plugins/omniroute/`.
|
||||
* 4. Creates or updates `opencode.json` with a single `plugin` entry
|
||||
* pointing at the local copy (so OC ≥1.15 picks it up).
|
||||
* 5. Optionally runs `opencode auth login --provider omniroute`
|
||||
* so the next `opencode` invocation already has the API key.
|
||||
*
|
||||
* Idempotent: re-running with the same `--provider-id` updates the
|
||||
* entry in place (path + baseURL) without duplicating it.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// We walk up from this file to find the omniroute package root. The script
|
||||
// lives at `<omniroute>/bin/cli/commands/setup-open-code.mjs`, so the
|
||||
// package root is three levels up. Using import.meta.url (not process.cwd())
|
||||
// means the command works the same way whether you run it from the source
|
||||
// repo, a global install, or a symlinked location.
|
||||
const PACKAGE_ROOT = resolve(__dirname, "..", "..", "..");
|
||||
|
||||
// The bundled plugin ships at PACKAGE_ROOT/@omniroute/opencode-plugin/
|
||||
// (see root package.json `files`: ["@omniroute/", ...]). The env override
|
||||
// exists so tests can point at a fixture without building the real plugin.
|
||||
const BUNDLED_PLUGIN_DIR =
|
||||
process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR ||
|
||||
join(PACKAGE_ROOT, "@omniroute", "opencode-plugin");
|
||||
|
||||
/**
|
||||
* Resolve the OpenCode config directory. Honours XDG_CONFIG_HOME and the
|
||||
* platform-specific defaults documented at https://opencode.ai/.
|
||||
*
|
||||
* @returns {{ configDir: string, dataDir: string }}
|
||||
*/
|
||||
function resolveOpenCodeDirs() {
|
||||
const home = os.homedir();
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
||||
const xdgData = process.env.XDG_DATA_HOME;
|
||||
const platform = process.platform;
|
||||
|
||||
let configDir;
|
||||
let dataDir;
|
||||
if (platform === "darwin") {
|
||||
// macOS: ~/Library/Application Support/opencode
|
||||
configDir = join(home, "Library", "Application Support", "opencode");
|
||||
dataDir = configDir; // OC uses the same root for config + data on macOS
|
||||
} else if (platform === "win32") {
|
||||
const appdata = process.env.APPDATA || join(home, "AppData", "Roaming");
|
||||
const localAppdata = process.env.LOCALAPPDATA || join(home, "AppData", "Local");
|
||||
configDir = join(appdata, "opencode");
|
||||
dataDir = join(localAppdata, "opencode");
|
||||
} else {
|
||||
// Linux + everything else: XDG-style
|
||||
configDir = xdgConfig ? join(xdgConfig, "opencode") : join(home, ".config", "opencode");
|
||||
dataDir = xdgData ? join(xdgData, "opencode") : join(home, ".local", "share", "opencode");
|
||||
}
|
||||
return { configDir, dataDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the bundled @omniroute/opencode-plugin dist. The plugin may be
|
||||
* present in two states:
|
||||
*
|
||||
* - Built (`dist/index.cjs` + `dist/index.js` exist) — preferred,
|
||||
* ships from a published omniroute tarball after Step 8.8 of
|
||||
* `scripts/build/prepublish.ts` runs.
|
||||
* - Unbuilt (only `src/index.ts`) — local dev / fresh clone. We surface
|
||||
* a clear error instead of running tsup here, because the CLI runtime
|
||||
* may not have tsup available (it's a devDependency).
|
||||
*
|
||||
* @returns {{ distEntry: string, cjsEntry: string, packageDir: string }}
|
||||
*/
|
||||
function resolveBundledPlugin() {
|
||||
if (!existsSync(BUNDLED_PLUGIN_DIR)) {
|
||||
throw new Error(
|
||||
`Bundled @omniroute/opencode-plugin not found at ${BUNDLED_PLUGIN_DIR}.\n` +
|
||||
`This usually means omniroute was installed from a source tree that does not ` +
|
||||
`include the workspace package. Try reinstalling omniroute (npm install -g omniroute) ` +
|
||||
`or run \`cd @omniroute/opencode-plugin && npm install && npm run build\` from the source repo.`
|
||||
);
|
||||
}
|
||||
|
||||
const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js");
|
||||
const cjsEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.cjs");
|
||||
|
||||
if (!existsSync(esmEntry) || !existsSync(cjsEntry)) {
|
||||
throw new Error(
|
||||
`@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` +
|
||||
`Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.`
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer ESM. OpenCode (≥1.15) loads ESM modules natively.
|
||||
return { distEntry: esmEntry, cjsEntry, packageDir: BUNDLED_PLUGIN_DIR };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the plugin package into `<opencodeConfig>/plugins/omniroute/`. We
|
||||
* copy the entire package (dist/ + package.json) so the dist file's
|
||||
* require/import of `zod` and `@opencode-ai/plugin` resolves against the
|
||||
* copy's own node_modules. Without the copy, OpenCode would need to
|
||||
* resolve the peer deps from the omniroute package's tree, which is
|
||||
* unreliable.
|
||||
*/
|
||||
function installPluginToOpenCode(pluginInfo, opencodeConfigDir) {
|
||||
const targetDir = join(opencodeConfigDir, "plugins", "omniroute");
|
||||
mkdirSync(dirname(targetDir), { recursive: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Copy package.json + dist/. We intentionally do NOT recursively copy
|
||||
// node_modules from the source — `peerDependenciesMeta` declares zod +
|
||||
// @opencode-ai/plugin as peers, and the user's OpenCode install already
|
||||
// provides them. Copying our own node_modules would risk duplicate zod
|
||||
// instances (the @opencode-ai/plugin contract uses a singleton).
|
||||
const packageJsonSrc = join(pluginInfo.packageDir, "package.json");
|
||||
const distSrc = join(pluginInfo.packageDir, "dist");
|
||||
cpSync(packageJsonSrc, join(targetDir, "package.json"));
|
||||
cpSync(distSrc, join(targetDir, "dist"), { recursive: true });
|
||||
|
||||
return targetDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update `opencode.json` to register the plugin. Idempotent: if an entry
|
||||
* for the same `providerId` already exists, replace it in place. If the
|
||||
* user has any other plugin entries, preserve them.
|
||||
*
|
||||
* @returns {{ configPath: string, changed: boolean }}
|
||||
*/
|
||||
function registerPluginInOpenCodeConfig({
|
||||
opencodeConfigDir,
|
||||
pluginTargetDir,
|
||||
providerId,
|
||||
baseURL,
|
||||
displayName,
|
||||
}) {
|
||||
const configPath = join(opencodeConfigDir, "opencode.json");
|
||||
let cfg = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to parse existing ${configPath}: ${err.message}\n` +
|
||||
`Fix or remove the file manually, then re-run \`omniroute setup opencode\`.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : [];
|
||||
|
||||
// Plugin entries can be either a string ("@some/pkg") or a tuple
|
||||
// ("@some/pkg", { options }). The README documents the tuple form, so
|
||||
// we use that. The "module path" is a file:// URL relative to the
|
||||
// opencode config dir — that is what opencode ≥1.15 resolves.
|
||||
const entry = [
|
||||
`./plugins/omniroute/dist/index.js`,
|
||||
{
|
||||
providerId,
|
||||
baseURL,
|
||||
...(displayName ? { displayName } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
// Idempotency: drop any prior entry for the same providerId. We also
|
||||
// drop a legacy `opencode-omniroute-auth` entry if present — that
|
||||
// package is the obsolete predecessor of @omniroute/opencode-plugin
|
||||
// and was the root cause of issue #3711.
|
||||
const filtered = plugins.filter((p) => {
|
||||
if (typeof p === "string") {
|
||||
return !p.includes("opencode-omniroute-auth");
|
||||
}
|
||||
if (Array.isArray(p) && p[1] && typeof p[1] === "object") {
|
||||
const pid = p[1].providerId;
|
||||
if (pid === providerId) return false;
|
||||
// Also drop the legacy auth plugin if it's there.
|
||||
if (typeof p[0] === "string" && p[0].includes("opencode-omniroute-auth")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
filtered.push(entry);
|
||||
cfg.plugin = filtered;
|
||||
|
||||
// Make sure the config dir exists, then write the updated config.
|
||||
mkdirSync(dirname(configPath), { recursive: true });
|
||||
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
||||
|
||||
return { configPath, changed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally invoke `opencode auth login --provider <providerId>`. We
|
||||
* shell out (instead of importing) so this command works even if
|
||||
* OpenCode's CLI surface shifts between minor versions — the user gets
|
||||
* a clear "could not run opencode" message instead of a hard import
|
||||
* failure.
|
||||
*/
|
||||
function runOpenCodeAuth(providerId) {
|
||||
const isWin = process.platform === "win32";
|
||||
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
|
||||
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
if (res.error) {
|
||||
// ENOENT = opencode is not on PATH
|
||||
if (res.error.code === "ENOENT") {
|
||||
printInfo(
|
||||
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
printError(`opencode auth login failed: ${res.error.message}`);
|
||||
return 1;
|
||||
}
|
||||
return typeof res.status === "number" ? res.status : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level action handler. Kept exported so the integration test can
|
||||
* drive it without spawning a subprocess.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.providerId="omniroute"]
|
||||
* @param {string} [opts.baseURL="http://localhost:20128"] (Commander camelCases
|
||||
* `--base-url` into `baseUrl`, so both spellings are accepted.)
|
||||
* @param {string} [opts.configDir] Override the OpenCode config dir (tests / non-standard installs).
|
||||
* @param {string} [opts.displayName]
|
||||
* @param {boolean} [opts.auth=false] Run `opencode auth login` after wiring.
|
||||
* @param {boolean} [opts.nonInteractive=false] Skip prompts.
|
||||
* @returns {Promise<{ exitCode: number, configPath?: string, pluginTargetDir?: string }>}
|
||||
*/
|
||||
export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
const providerId = opts.providerId || "omniroute";
|
||||
const baseURL = opts.baseURL || opts.baseUrl || "http://localhost:20128";
|
||||
const displayName = opts.displayName || null;
|
||||
const wantsAuth = Boolean(opts.auth);
|
||||
const nonInteractive = Boolean(opts.nonInteractive);
|
||||
|
||||
printHeading("OmniRoute → OpenCode Plugin Setup");
|
||||
|
||||
const resolvedDirs = resolveOpenCodeDirs();
|
||||
const opencodeConfigDir = opts.configDir || resolvedDirs.configDir;
|
||||
const opencodeDataDir = resolvedDirs.dataDir;
|
||||
printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
|
||||
printInfo(`OpenCode data dir: ${opencodeDataDir}`);
|
||||
|
||||
// 1. Resolve bundled plugin
|
||||
let pluginInfo;
|
||||
try {
|
||||
pluginInfo = resolveBundledPlugin();
|
||||
} catch (err) {
|
||||
printError(err.message);
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
printInfo(`Bundled plugin: ${pluginInfo.distEntry}`);
|
||||
|
||||
// 2. Ensure OpenCode config dir exists (opencode will create it on
|
||||
// first run, but creating it now means we can write opencode.json
|
||||
// even if OC has never been launched).
|
||||
if (!existsSync(opencodeConfigDir)) {
|
||||
mkdirSync(opencodeConfigDir, { recursive: true });
|
||||
printInfo(`Created OpenCode config dir (didn't exist yet).`);
|
||||
}
|
||||
|
||||
// 3. Copy plugin into OpenCode's plugin dir
|
||||
let pluginTargetDir;
|
||||
try {
|
||||
pluginTargetDir = installPluginToOpenCode(pluginInfo, opencodeConfigDir);
|
||||
printSuccess(`Plugin installed at ${pluginTargetDir}`);
|
||||
} catch (err) {
|
||||
printError(`Failed to install plugin: ${err.message}`);
|
||||
return { exitCode: 1 };
|
||||
}
|
||||
|
||||
// 4. Register in opencode.json
|
||||
let configPath;
|
||||
try {
|
||||
const reg = registerPluginInOpenCodeConfig({
|
||||
opencodeConfigDir,
|
||||
pluginTargetDir,
|
||||
providerId,
|
||||
baseURL,
|
||||
displayName,
|
||||
});
|
||||
configPath = reg.configPath;
|
||||
printSuccess(`opencode.json updated at ${configPath}`);
|
||||
} catch (err) {
|
||||
printError(`Failed to update opencode.json: ${err.message}`);
|
||||
return { exitCode: 1, pluginTargetDir };
|
||||
}
|
||||
|
||||
// 5. Optionally run auth login
|
||||
if (wantsAuth) {
|
||||
if (nonInteractive) {
|
||||
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
|
||||
printInfo(`Run manually: opencode auth login --provider ${providerId}`);
|
||||
} else {
|
||||
printHeading("Authenticating with OpenCode");
|
||||
const authExit = runOpenCodeAuth(providerId);
|
||||
if (authExit !== 0) {
|
||||
return { exitCode: authExit, configPath, pluginTargetDir };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
printInfo(
|
||||
`Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)`
|
||||
);
|
||||
}
|
||||
|
||||
printSuccess("OpenCode plugin setup complete");
|
||||
printInfo(`Restart OpenCode to pick up the new plugin entry.`);
|
||||
return { exitCode: 0, configPath, pluginTargetDir };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `omniroute setup opencode` subcommand on the parent
|
||||
* `setup` command. Commander builds the doc/help from the chain, so
|
||||
* `omniroute setup --help` automatically shows the new subcommand.
|
||||
*
|
||||
* @param {import("commander").Command} setupCommand the registered `setup` command
|
||||
*/
|
||||
export function registerSetupOpenCode(setupCommand) {
|
||||
setupCommand
|
||||
.command("opencode")
|
||||
.description(
|
||||
t("setup.opencode") ||
|
||||
"Install and register the bundled @omniroute/opencode-plugin with a local OpenCode install"
|
||||
)
|
||||
.option(
|
||||
"--provider-id <id>",
|
||||
"OpenCode provider id to register (default: omniroute)",
|
||||
"omniroute"
|
||||
)
|
||||
.option(
|
||||
"--base-url <url>",
|
||||
"OmniRoute base URL the plugin should talk to (default: http://localhost:20128)",
|
||||
"http://localhost:20128"
|
||||
)
|
||||
.option("--display-name <name>", "Display name in the OpenCode UI (optional)")
|
||||
.option(
|
||||
"--auth",
|
||||
"Run `opencode auth login --provider <providerId>` after wiring (interactive)",
|
||||
false
|
||||
)
|
||||
.option("--non-interactive", "Do not prompt; skip the auth login step", false)
|
||||
.action(async (opts, cmd) => {
|
||||
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror
|
||||
// that here so global flags (--json, --base-url, --api-key) still
|
||||
// flow through to the runner.
|
||||
const globalOpts = cmd.parent?.parent?.optsWithGlobals?.() ?? {};
|
||||
const merged = {
|
||||
...opts,
|
||||
output: globalOpts.output,
|
||||
apiKey: opts.apiKey ?? globalOpts.apiKey,
|
||||
baseUrl: opts.baseUrl ?? globalOpts.baseUrl,
|
||||
};
|
||||
const { exitCode } = await runSetupOpenCodeCommand(merged);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.)."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
open-sse/config/geminiRateLimits.json
Normal file
34
open-sse/config/geminiRateLimits.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"gemini-2.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
|
||||
"gemini-2.5-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-2-flash": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-2-flash-lite": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-2.5-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 },
|
||||
"gemini-2.5-pro-tts": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"imagen-4-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
|
||||
"imagen-4-ultra-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
|
||||
"imagen-4-fast-generate": { "rpm": -1, "rpd": 25, "tpm": -1 },
|
||||
"gemma-4-26b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 },
|
||||
"gemma-4-31b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 },
|
||||
"gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
|
||||
"gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
|
||||
"gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 },
|
||||
"gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 },
|
||||
"nano-banana-gemini-2.5-flash-preview-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"nano-banana-pro-gemini-3-pro-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"nano-banana-2-gemini-3.1-flash-image": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"lyria-3-clip": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"lyria-3-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"veo-3-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
|
||||
"veo-3-fast-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
|
||||
"veo-3-lite-generate": { "rpm": 0, "rpd": 0, "tpm": -1 },
|
||||
"gemini-3.1-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 },
|
||||
"gemini-robotics-er-1.5-preview": { "rpm": 10, "rpd": 20, "tpm": 250000 },
|
||||
"gemini-robotics-er-1.6-preview": { "rpm": 5, "rpd": 20, "tpm": 250000 },
|
||||
"computer-use-preview": { "rpm": 0, "rpd": 0, "tpm": 0 },
|
||||
"gemini-embedding-exp-04-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
|
||||
"gemini-3.5-live-translate": { "rpm": -1, "rpd": -1, "tpm": 20000 },
|
||||
"gemini-2.5-flash-native-audio-dialog": { "rpm": -1, "rpd": -1, "tpm": 1000000 },
|
||||
"gemini-3-flash-live": { "rpm": -1, "rpd": -1, "tpm": 65000 }
|
||||
}
|
||||
@@ -21,6 +21,28 @@ export function parseSAFromApiKey(apiKey: string): ServiceAccount {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Service Account credential is a JSON object (type/client_email/private_key). A Vertex AI
|
||||
* Express-mode API key is an opaque non-JSON string. Distinguishing them lets the executor
|
||||
* support BOTH: Service Account JSON (JWT → OAuth → project-scoped endpoint + Bearer auth) and
|
||||
* Express keys (project-less publisher endpoint + x-goog-api-key auth), instead of failing every
|
||||
* Express key with "requires a valid Service Account JSON".
|
||||
*/
|
||||
export function looksLikeServiceAccountJson(apiKey: string): boolean {
|
||||
if (!apiKey || typeof apiKey !== "string") return false;
|
||||
try {
|
||||
const parsed = JSON.parse(apiKey);
|
||||
return !!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a Vertex AI Express-mode API key (a non-empty, non-JSON, non-OAuth credential). */
|
||||
export function isExpressApiKey(apiKey?: string | null): boolean {
|
||||
return typeof apiKey === "string" && apiKey.trim().length > 0 && !looksLikeServiceAccountJson(apiKey);
|
||||
}
|
||||
|
||||
export async function getAccessToken(sa: ServiceAccount): Promise<string> {
|
||||
if (!sa.client_email || !sa.private_key) {
|
||||
throw new Error(
|
||||
@@ -110,7 +132,13 @@ export class VertexExecutor extends BaseExecutor {
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
const { credentials, log } = input;
|
||||
if (credentials.apiKey && !credentials.accessToken) {
|
||||
// Defensive: trim stray surrounding whitespace from a pasted credential.
|
||||
if (typeof credentials.apiKey === "string") {
|
||||
credentials.apiKey = credentials.apiKey.trim();
|
||||
}
|
||||
// Service Account JSON → mint a short-lived OAuth token (Bearer). An Express-mode API key is
|
||||
// sent as-is via x-goog-api-key (see buildHeaders), so no token exchange is needed for it.
|
||||
if (credentials.apiKey && !credentials.accessToken && looksLikeServiceAccountJson(credentials.apiKey)) {
|
||||
try {
|
||||
const sa = parseSAFromApiKey(credentials.apiKey);
|
||||
credentials.accessToken = await getAccessToken(sa);
|
||||
@@ -123,6 +151,19 @@ export class VertexExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) {
|
||||
// Vertex AI Express mode: project-less v1 publisher endpoint with the API key passed as a
|
||||
// ?key= query parameter (verified working contract — same as the CaptionAI GeminiClient). The
|
||||
// Express key is NOT accepted as a Bearer/OAuth credential or via x-goog-api-key on this API.
|
||||
if (isExpressApiKey(credentials?.apiKey) && !credentials?.accessToken) {
|
||||
const expressKey = encodeURIComponent(String(credentials.apiKey).trim());
|
||||
if (isPartnerModel(model)) {
|
||||
// Partner (Anthropic/etc.) models are not available via Express keys; best-effort.
|
||||
return `https://aiplatform.googleapis.com/v1/publishers/openapi/chat/completions?key=${expressKey}`;
|
||||
}
|
||||
const op = stream ? "streamGenerateContent?alt=sse&" : "generateContent?";
|
||||
return `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${op}key=${expressKey}`;
|
||||
}
|
||||
|
||||
const region = credentials?.providerSpecificData?.region || "us-central1";
|
||||
let project = "unknown-project";
|
||||
|
||||
@@ -146,6 +187,7 @@ export class VertexExecutor extends BaseExecutor {
|
||||
if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
// Express-mode keys are carried in the ?key= query parameter (see buildUrl), not a header.
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ import {
|
||||
getModelScopeRetryDelayMs,
|
||||
isModelScopeProvider,
|
||||
} from "../services/modelscopePolicy.ts";
|
||||
import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts";
|
||||
|
||||
const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024;
|
||||
|
||||
@@ -1980,12 +1981,13 @@ export async function handleChatCore({
|
||||
// Use credentials.connectionId as a fallback so that requests without an
|
||||
// explicit session-level connectionId still register in the pendingRequests map.
|
||||
const pendingConnId = connectionId || credentials?.connectionId || null;
|
||||
const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, {
|
||||
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
clientRequest: clientRawRequest?.body ?? body,
|
||||
providerRequest: initialProviderRequest,
|
||||
stage: "registered",
|
||||
}) || generateRequestId();
|
||||
const pendingRequestId =
|
||||
trackPendingRequest(model, provider, pendingConnId, true, {
|
||||
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
||||
clientRequest: clientRawRequest?.body ?? body,
|
||||
providerRequest: initialProviderRequest,
|
||||
stage: "registered",
|
||||
}) || generateRequestId();
|
||||
|
||||
// Initialize rate limit settings from persisted DB (once, lazy)
|
||||
await initializeRateLimits();
|
||||
@@ -2763,7 +2765,10 @@ export async function handleChatCore({
|
||||
comboTargetLimits,
|
||||
});
|
||||
contextLimit = resolved.limit;
|
||||
log?.info?.("CONTEXT", `Combo context limit: ${resolved.limit} (source=${resolved.source})`);
|
||||
log?.info?.(
|
||||
"CONTEXT",
|
||||
`Combo context limit: ${resolved.limit} (source=${resolved.source})`
|
||||
);
|
||||
} catch (err) {
|
||||
log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err);
|
||||
}
|
||||
@@ -3098,6 +3103,12 @@ export async function handleChatCore({
|
||||
translatedBody.messages,
|
||||
DEFAULT_THINKING_CLAUDE_SIGNATURE
|
||||
) as typeof translatedBody.messages;
|
||||
|
||||
// Anthropic API rejects requests with both temperature and top_p.
|
||||
// VS Code Claude extension and similar clients send both; strip top_p.
|
||||
if (translatedBody.temperature !== undefined && translatedBody.top_p !== undefined) {
|
||||
delete translatedBody.top_p;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2468: always extract role:"system" → top-level system.
|
||||
@@ -3781,6 +3792,12 @@ export async function handleChatCore({
|
||||
});
|
||||
const res = normalizeExecutorResult(rawExecutorResult);
|
||||
trace("post_executor", { status: res?.response?.status });
|
||||
|
||||
// Track Gemini RPM + RPD request counts for 429 classification
|
||||
if (provider === "gemini") {
|
||||
incrementRequestCount(modelToCall);
|
||||
}
|
||||
|
||||
updatePendingRequest(model, provider, connectionId, {
|
||||
stage: "provider_response_started",
|
||||
});
|
||||
@@ -5371,17 +5388,14 @@ export async function handleChatCore({
|
||||
}
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
...buildStreamingResponseHeaders(
|
||||
providerResponse.headers,
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
cacheHit: false,
|
||||
latencyMs: 0,
|
||||
usage: null,
|
||||
costUsd: 0,
|
||||
}
|
||||
),
|
||||
...buildStreamingResponseHeaders(providerResponse.headers, {
|
||||
provider,
|
||||
model,
|
||||
cacheHit: false,
|
||||
latencyMs: 0,
|
||||
usage: null,
|
||||
costUsd: 0,
|
||||
}),
|
||||
"x-omniroute-request-id": pendingRequestId,
|
||||
};
|
||||
|
||||
@@ -5489,7 +5503,9 @@ export async function handleChatCore({
|
||||
});
|
||||
} catch (e) {
|
||||
// Best-effort — don't break the stream completion path if this fails
|
||||
try { console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e)); } catch {}
|
||||
try {
|
||||
console.warn("finalizeMostRecentPendingRequest failed:", e && (e.message || e));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (apiKeyInfo?.id && streamUsage) {
|
||||
|
||||
@@ -27,7 +27,9 @@ import {
|
||||
looksLikeQuotaExhausted,
|
||||
type FailureKind,
|
||||
} from "../../src/shared/utils/classify429";
|
||||
import { resolveProviderId } from "../../src/shared/constants/providers";
|
||||
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
|
||||
import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts";
|
||||
|
||||
export type ProviderProfile = {
|
||||
baseCooldownMs: number;
|
||||
@@ -65,6 +67,9 @@ type ModelFailureState = {
|
||||
failureCount: number;
|
||||
lastFailureAt: number;
|
||||
resetAfterMs: number;
|
||||
/** Cooldown applied on the last failure — extends the escalation window so a
|
||||
* model that fails again right after its lockout expires keeps escalating. */
|
||||
lastCooldownMs?: number;
|
||||
};
|
||||
type AccountState = JsonRecord & {
|
||||
id?: string | null;
|
||||
@@ -150,9 +155,6 @@ export const CREDITS_EXHAUSTED_SIGNALS = [
|
||||
"credits exhausted",
|
||||
"out of credits",
|
||||
"payment required",
|
||||
"resource has been exhausted",
|
||||
"resource_exhausted",
|
||||
"check quota",
|
||||
"free tier of the model has been exhausted",
|
||||
];
|
||||
|
||||
@@ -350,8 +352,20 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi
|
||||
const modelLockouts = new Map<string, ModelLockoutEntry>();
|
||||
const modelFailureState = new Map<string, ModelFailureState>();
|
||||
|
||||
// Aliases (e.g. "cx" → "codex") must share lockout state with their canonical
|
||||
// provider, otherwise a model locked via one spelling stays routable via the other.
|
||||
const canonicalProviderCache = new Map<string, string>();
|
||||
function getCanonicalLockProvider(provider: string): string {
|
||||
let canonical = canonicalProviderCache.get(provider);
|
||||
if (!canonical) {
|
||||
canonical = resolveProviderId(provider);
|
||||
canonicalProviderCache.set(provider, canonical);
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function getModelLockKey(provider: string, connectionId: string, model: string) {
|
||||
return `${provider}:${connectionId}:${model}`;
|
||||
return `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`;
|
||||
}
|
||||
|
||||
function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) {
|
||||
@@ -367,7 +381,9 @@ function cleanupModelLockKey(key: string, now = Date.now()) {
|
||||
|
||||
const failure = modelFailureState.get(key);
|
||||
if (!failure) return;
|
||||
if (now - failure.lastFailureAt <= failure.resetAfterMs) return;
|
||||
// The escalation window extends past the applied cooldown: a model that fails
|
||||
// again right after its lockout expires must keep escalating, not reset to 1.
|
||||
if (now - failure.lastFailureAt <= failure.resetAfterMs + (failure.lastCooldownMs ?? 0)) return;
|
||||
if (modelLockouts.has(key)) return;
|
||||
modelFailureState.delete(key);
|
||||
}
|
||||
@@ -468,7 +484,7 @@ export function recordModelLockoutFailure(
|
||||
status: number,
|
||||
fallbackCooldownMs: number,
|
||||
profile: ProviderProfile | null = null,
|
||||
options: { exactCooldownMs?: number | null } = {}
|
||||
options: { exactCooldownMs?: number | null; maxCooldownMs?: number } = {}
|
||||
) {
|
||||
ensureCleanupTimer();
|
||||
const key = getModelLockKey(provider, connectionId, model);
|
||||
@@ -483,24 +499,39 @@ export function recordModelLockoutFailure(
|
||||
|
||||
const resetAfterMs = getFailureWindowMs(profile);
|
||||
const previous = modelFailureState.get(key);
|
||||
const withinWindow = previous && now - previous.lastFailureAt <= previous.resetAfterMs;
|
||||
// Escalation window extends past the previously applied cooldown so a model
|
||||
// that fails again right after its lockout expires keeps escalating.
|
||||
const withinWindow =
|
||||
previous &&
|
||||
now - previous.lastFailureAt <= previous.resetAfterMs + (previous.lastCooldownMs ?? 0);
|
||||
const failureCount = withinWindow ? previous.failureCount + 1 : 1;
|
||||
|
||||
const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile);
|
||||
// Cap exponential backoff so repeated failures cannot produce absurdly long
|
||||
// lockouts; exact cooldowns (e.g. daily-quota until-midnight) are not capped.
|
||||
const maxCooldownMs =
|
||||
typeof options.maxCooldownMs === "number" && options.maxCooldownMs > 0
|
||||
? options.maxCooldownMs
|
||||
: BACKOFF_CONFIG.max;
|
||||
const cooldownMs =
|
||||
typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0
|
||||
? options.exactCooldownMs
|
||||
: Math.min(
|
||||
getScaledCooldown(
|
||||
baseCooldownMs,
|
||||
failureCount,
|
||||
profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel
|
||||
),
|
||||
maxCooldownMs
|
||||
);
|
||||
|
||||
modelFailureState.set(key, {
|
||||
failureCount,
|
||||
lastFailureAt: now,
|
||||
resetAfterMs,
|
||||
lastCooldownMs: cooldownMs,
|
||||
});
|
||||
|
||||
const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile);
|
||||
const cooldownMs =
|
||||
typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0
|
||||
? options.exactCooldownMs
|
||||
: getScaledCooldown(
|
||||
baseCooldownMs,
|
||||
failureCount,
|
||||
profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel
|
||||
);
|
||||
|
||||
lockModel(provider, connectionId, model, reason, cooldownMs, {
|
||||
failureCount,
|
||||
lastFailureAt: now,
|
||||
@@ -590,6 +621,36 @@ export function shouldMarkAccountExhaustedFrom429(
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyLockoutReason(status: number): string {
|
||||
if (status === 429) return "rate_limit";
|
||||
if (status === 403) return "quota_exhausted";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export type DecayResult = { cleared: boolean; newFailureCount: number };
|
||||
|
||||
export function decayModelFailureCount(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
model: string
|
||||
): DecayResult {
|
||||
const key = getModelLockKey(provider, connectionId, model);
|
||||
const failure = modelFailureState.get(key);
|
||||
if (!failure) return { cleared: false, newFailureCount: 0 };
|
||||
|
||||
const newFailureCount = Math.floor(failure.failureCount / 2);
|
||||
if (newFailureCount === 0) {
|
||||
modelFailureState.delete(key);
|
||||
return { cleared: true, newFailureCount: 0 };
|
||||
} else {
|
||||
modelFailureState.set(key, {
|
||||
...failure,
|
||||
failureCount: newFailureCount,
|
||||
});
|
||||
return { cleared: false, newFailureCount };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all in-memory model lockouts and failure state (for tests / full reset).
|
||||
*/
|
||||
@@ -933,8 +994,9 @@ export function parseRetryFromErrorText(errorText: unknown): number | null {
|
||||
// 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z").
|
||||
// Convert to a future-duration in milliseconds if it parses.
|
||||
const isoMatch =
|
||||
/\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i
|
||||
.exec(msg);
|
||||
/\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec(
|
||||
msg
|
||||
);
|
||||
if (isoMatch) {
|
||||
const parsedTs = Date.parse(isoMatch[1]);
|
||||
if (Number.isFinite(parsedTs)) {
|
||||
@@ -1021,10 +1083,8 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue {
|
||||
const configuredRule = matchErrorRuleByText(errorText);
|
||||
if (configuredRule?.reason) return configuredRule.reason;
|
||||
if (lower.includes("rate_limit")) return RateLimitReason.RATE_LIMIT_EXCEEDED;
|
||||
if (
|
||||
lower.includes("resource exhausted") ||
|
||||
lower.includes("high demand")
|
||||
) return RateLimitReason.MODEL_CAPACITY;
|
||||
if (lower.includes("resource exhausted") || lower.includes("high demand"))
|
||||
return RateLimitReason.MODEL_CAPACITY;
|
||||
if (
|
||||
lower.includes("unauthorized") ||
|
||||
lower.includes("invalid api key") ||
|
||||
@@ -1414,6 +1474,19 @@ export function checkFallbackError(
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini-specific: use known published RPM/RPD limits to distinguish 429 types.
|
||||
// Gemini returns the same error body for both, so we use per-model request
|
||||
// counters to decide: if daily count >= RPD → quota_exhausted (midnight lockout);
|
||||
// if minute count >= RPM → rate_limit_exceeded (exponential backoff).
|
||||
if (provider === "gemini" && status === HTTP_STATUS.RATE_LIMITED && _model) {
|
||||
if (isRpdExhausted(_model)) {
|
||||
return buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED);
|
||||
}
|
||||
if (isRpmExhausted(_model)) {
|
||||
return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredRule =
|
||||
isRateLimitStatus && !preserveQuota429
|
||||
? matchErrorRuleByStatus(status)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,8 +3,24 @@
|
||||
*
|
||||
* Maps model patterns × task types → fitness score [0..1].
|
||||
* Supports wildcards and prefix matching.
|
||||
*
|
||||
* Resolution chain (highest → lowest priority):
|
||||
* 1. User override — DB `model_intelligence` where source='user_override'
|
||||
* 2. Arena ELO — DB `model_intelligence` where source='arena_elo'
|
||||
* 3. Models.dev tier — derived from `model_capabilities` table capability data
|
||||
* 4. Static FITNESS_TABLE — existing hardcoded lookup (current behavior)
|
||||
* 5. Wildcard boosts — existing pattern matching boosts (current behavior)
|
||||
*/
|
||||
|
||||
// ─── Static fitness table (unchanged, fallback layer 4) ─────────────────
|
||||
|
||||
import { getDbInstance } from "../../../src/lib/db/core.ts";
|
||||
import {
|
||||
getModelIntelligenceBySource,
|
||||
setUserFitnessOverrideEntry,
|
||||
deleteUserFitnessOverrideEntry,
|
||||
} from "../../../src/lib/db/modelIntelligence.ts";
|
||||
|
||||
const FITNESS_TABLE: Record<string, Record<string, number>> = {
|
||||
coding: {
|
||||
"claude-sonnet": 0.95,
|
||||
@@ -131,34 +147,274 @@ const WILDCARD_BOOSTS: Array<{ pattern: string; taskType: string; boost: number
|
||||
{ pattern: "thinking", taskType: "analysis", boost: 0.1 },
|
||||
];
|
||||
|
||||
// ─── Models.dev tier → task fitness mapping (resolution layer 3) ────────
|
||||
|
||||
/**
|
||||
* Get task fitness score for a model × taskType combination.
|
||||
* Returns 0.5 (neutral) if no mapping found.
|
||||
* Intelligence tier derived from models.dev capability data.
|
||||
* Tier assignment rules:
|
||||
* - `reasoning === true` → "premium"
|
||||
* - `tool_call === true && context >= 128000` → "standard"
|
||||
* - `tool_call === true` → "fast"
|
||||
* - everything else → "budget"
|
||||
*/
|
||||
export function getTaskFitness(model: string, taskType: string): number {
|
||||
const TIER_TASK_FITNESS: Record<string, Record<string, number>> = {
|
||||
premium: {
|
||||
coding: 0.92,
|
||||
review: 0.93,
|
||||
planning: 0.94,
|
||||
analysis: 0.95,
|
||||
debugging: 0.9,
|
||||
documentation: 0.88,
|
||||
default: 0.85,
|
||||
},
|
||||
standard: {
|
||||
coding: 0.85,
|
||||
review: 0.84,
|
||||
planning: 0.85,
|
||||
analysis: 0.85,
|
||||
debugging: 0.82,
|
||||
documentation: 0.85,
|
||||
default: 0.78,
|
||||
},
|
||||
fast: {
|
||||
coding: 0.78,
|
||||
review: 0.72,
|
||||
planning: 0.7,
|
||||
analysis: 0.72,
|
||||
debugging: 0.75,
|
||||
documentation: 0.8,
|
||||
default: 0.72,
|
||||
},
|
||||
budget: {
|
||||
coding: 0.65,
|
||||
review: 0.6,
|
||||
planning: 0.55,
|
||||
analysis: 0.58,
|
||||
debugging: 0.6,
|
||||
documentation: 0.7,
|
||||
default: 0.55,
|
||||
},
|
||||
};
|
||||
// ─── DB access helpers ──────────────────────────────────────────────────
|
||||
|
||||
const _intelligenceCache = new Map<string, number | null>();
|
||||
|
||||
function queryModelIntelligence(
|
||||
model: string,
|
||||
category: string,
|
||||
source: string,
|
||||
): number | null {
|
||||
const cacheKey = `${model}:${category}:${source}`;
|
||||
if (_intelligenceCache.has(cacheKey)) {
|
||||
return _intelligenceCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = getModelIntelligenceBySource(model, source, category);
|
||||
if (entry) {
|
||||
_intelligenceCache.set(cacheKey, entry.score);
|
||||
return entry.score;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Models.dev capability → tier → fitness resolution ──────────────────
|
||||
|
||||
let _capabilitiesCache: Record<string, ModelCapRow> | null = null;
|
||||
|
||||
interface ModelCapRow {
|
||||
tool_call: boolean | null;
|
||||
reasoning: boolean | null;
|
||||
limit_context: number | null;
|
||||
}
|
||||
|
||||
function deriveTierFromCapabilities(cap: ModelCapRow): string {
|
||||
if (cap.reasoning === true) return "premium";
|
||||
if (cap.tool_call === true && (cap.limit_context ?? 0) >= 128000)
|
||||
return "standard";
|
||||
if (cap.tool_call === true) return "fast";
|
||||
return "budget";
|
||||
}
|
||||
|
||||
function loadModelCapabilities(): Record<string, ModelCapRow> | null {
|
||||
if (_capabilitiesCache) return _capabilitiesCache;
|
||||
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT * FROM model_capabilities").all() as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const cache: Record<string, ModelCapRow> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const modelId = typeof row.model_id === "string" ? row.model_id : "";
|
||||
if (!modelId) continue;
|
||||
|
||||
cache[modelId.toLowerCase()] = {
|
||||
tool_call:
|
||||
row.tool_call === true || row.tool_call === 1
|
||||
? true
|
||||
: row.tool_call === false || row.tool_call === 0
|
||||
? false
|
||||
: null,
|
||||
reasoning:
|
||||
row.reasoning === true || row.reasoning === 1
|
||||
? true
|
||||
: row.reasoning === false || row.reasoning === 0
|
||||
? false
|
||||
: null,
|
||||
limit_context:
|
||||
typeof row.limit_context === "number" ? row.limit_context : null,
|
||||
};
|
||||
}
|
||||
|
||||
_capabilitiesCache = cache;
|
||||
return cache;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelsDevTierFitness(
|
||||
model: string,
|
||||
taskType: string,
|
||||
): number | null {
|
||||
const normalizedModel = model.toLowerCase();
|
||||
const normalizedTask = taskType.toLowerCase();
|
||||
const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default;
|
||||
|
||||
// Direct match
|
||||
const dbScore = queryModelIntelligence(
|
||||
normalizedModel,
|
||||
normalizedTask,
|
||||
"models_dev_tier",
|
||||
);
|
||||
if (dbScore !== null) return dbScore;
|
||||
|
||||
const caps = loadModelCapabilities();
|
||||
if (!caps) return null;
|
||||
|
||||
const capRow = caps[normalizedModel];
|
||||
if (!capRow) return null;
|
||||
|
||||
const tier = deriveTierFromCapabilities(capRow);
|
||||
const tierScores = TIER_TASK_FITNESS[tier];
|
||||
if (!tierScores) return null;
|
||||
|
||||
return tierScores[normalizedTask] ?? tierScores.default ?? null;
|
||||
}
|
||||
|
||||
// ─── Resolution chain ───────────────────────────────────────────────────
|
||||
|
||||
function lookupStaticFitnessTable(
|
||||
normalizedModel: string,
|
||||
normalizedTask: string,
|
||||
): number | null {
|
||||
const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default;
|
||||
for (const [pattern, score] of Object.entries(table)) {
|
||||
if (normalizedModel.includes(pattern)) return score;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Wildcard boost
|
||||
function lookupWildcardBoosts(
|
||||
normalizedModel: string,
|
||||
normalizedTask: string,
|
||||
): number {
|
||||
let baseScore = 0.5;
|
||||
for (const wc of WILDCARD_BOOSTS) {
|
||||
if (normalizedModel.includes(wc.pattern) && normalizedTask === wc.taskType) {
|
||||
baseScore += wc.boost;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(1.0, baseScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all task types available.
|
||||
*/
|
||||
export function getTaskFitness(model: string, taskType: string): number {
|
||||
return getTaskFitnessWithSource(model, taskType).score;
|
||||
}
|
||||
|
||||
export function getTaskFitnessWithSource(
|
||||
model: string,
|
||||
taskType: string,
|
||||
): { score: number; source: string } {
|
||||
const normalizedModel = model.toLowerCase();
|
||||
const normalizedTask = taskType.toLowerCase();
|
||||
|
||||
const userOverride = queryModelIntelligence(
|
||||
normalizedModel,
|
||||
normalizedTask,
|
||||
"user_override",
|
||||
);
|
||||
if (userOverride !== null) {
|
||||
return { score: userOverride, source: "user_override" };
|
||||
}
|
||||
|
||||
const arenaElo = queryModelIntelligence(
|
||||
normalizedModel,
|
||||
normalizedTask,
|
||||
"arena_elo",
|
||||
);
|
||||
if (arenaElo !== null) {
|
||||
return { score: arenaElo, source: "arena_elo" };
|
||||
}
|
||||
|
||||
const tierScore = getModelsDevTierFitness(normalizedModel, normalizedTask);
|
||||
if (tierScore !== null) {
|
||||
return { score: tierScore, source: "models_dev_tier" };
|
||||
}
|
||||
|
||||
const staticScore = lookupStaticFitnessTable(
|
||||
normalizedModel,
|
||||
normalizedTask,
|
||||
);
|
||||
if (staticScore !== null) {
|
||||
return { score: staticScore, source: "fitness_table" };
|
||||
}
|
||||
|
||||
return { score: lookupWildcardBoosts(normalizedModel, normalizedTask), source: "wildcard_boost" };
|
||||
}
|
||||
|
||||
export function setUserFitnessOverride(
|
||||
model: string,
|
||||
category: string,
|
||||
score: number,
|
||||
): void {
|
||||
try {
|
||||
setUserFitnessOverrideEntry(
|
||||
model.toLowerCase(),
|
||||
category.toLowerCase(),
|
||||
score,
|
||||
);
|
||||
invalidateFitnessCache();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to set user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearUserFitnessOverride(
|
||||
model: string,
|
||||
category: string,
|
||||
): void {
|
||||
try {
|
||||
deleteUserFitnessOverrideEntry(model.toLowerCase(), category.toLowerCase());
|
||||
invalidateFitnessCache();
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Failed to clear user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTaskTypes(): string[] {
|
||||
return Object.keys(FITNESS_TABLE).filter((k) => k !== "default");
|
||||
}
|
||||
|
||||
export function invalidateFitnessCache(): void {
|
||||
_capabilitiesCache = null;
|
||||
_intelligenceCache.clear();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
import {
|
||||
checkFallbackError,
|
||||
classifyErrorText,
|
||||
classifyLockoutReason,
|
||||
decayModelFailureCount,
|
||||
formatRetryAfter,
|
||||
getRuntimeProviderProfile,
|
||||
isModelLocked,
|
||||
recordModelLockoutFailure,
|
||||
recordProviderFailure,
|
||||
isProviderFailureCode,
|
||||
isProviderExhaustedReason,
|
||||
@@ -44,6 +48,7 @@ import {
|
||||
getLastSessionModel,
|
||||
getHandoff,
|
||||
} from "../../src/lib/db/contextHandoffs.ts";
|
||||
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
|
||||
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
|
||||
import { getQuotaFetcher } from "./quotaPreflight.ts";
|
||||
import * as semaphore from "./rateLimitSemaphore.ts";
|
||||
@@ -71,11 +76,7 @@ import {
|
||||
type ProviderCandidate,
|
||||
type ScoringWeights,
|
||||
} from "./autoCombo/scoring.ts";
|
||||
import {
|
||||
getResolvedModelCapabilities,
|
||||
supportsReasoning,
|
||||
supportsToolCalling,
|
||||
} from "./modelCapabilities.ts";
|
||||
import { getResolvedModelCapabilities, supportsToolCalling } from "./modelCapabilities.ts";
|
||||
import { estimateTokens } from "./contextManager.ts";
|
||||
import { getReasoningTokens } from "../../src/lib/usage/tokenAccounting.ts";
|
||||
import { getSessionConnection } from "./sessionManager.ts";
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
resolveResilienceSettings,
|
||||
type ResilienceSettings,
|
||||
} from "../../src/lib/resilience/settings";
|
||||
import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "./reasoningTokenBuffer.ts";
|
||||
|
||||
// Status codes that should mark round-robin target semaphores as cooling down.
|
||||
const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
|
||||
@@ -446,7 +448,211 @@ export async function validateResponseQuality(
|
||||
isStreaming: boolean,
|
||||
log: { warn?: (...args: unknown[]) => void }
|
||||
): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> {
|
||||
if (isStreaming) return { valid: true };
|
||||
// Issue #3685: For Claude SSE streaming responses, use a BOUNDED PEEK to
|
||||
// detect the empty-content-block pattern (content_filter stop_reason with
|
||||
// no content_block_* events) WITHOUT de-streaming non-empty responses.
|
||||
//
|
||||
// Strategy:
|
||||
// - Read chunks from response.body one at a time, accumulating raw bytes.
|
||||
// - Parse SSE events incrementally.
|
||||
// - If a content_block_* event appears → stream HAS content. Stop buffering.
|
||||
// Return a clonedResponse whose body replays buffered bytes then pipes the
|
||||
// remainder of the original reader. Only the chunks up to the first content
|
||||
// block were held in memory — the rest stream normally.
|
||||
// - If the stream ends with a complete Claude lifecycle but NO content_block
|
||||
// → return invalid (combo failover). The empty lifecycle is tiny so fully
|
||||
// reading it is acceptable.
|
||||
// - If the stream ends without a recognisable complete Claude lifecycle →
|
||||
// return valid with a clonedResponse replaying all buffered bytes (don't
|
||||
// misclassify non-Claude or partial streams as empty).
|
||||
//
|
||||
// Non-text/event-stream streaming responses are not buffered at all.
|
||||
if (isStreaming) {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("text/event-stream")) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
// Raw Uint8Array chunks accumulated so far — used to replay the prefix
|
||||
// in the returned clonedResponse.
|
||||
const bufferedChunks: Uint8Array[] = [];
|
||||
// Decoded text accumulated across chunks for incremental SSE parsing.
|
||||
// Only the tail of the most-recently-processed line window remains here
|
||||
// between iterations (incomplete lines are deferred to the next chunk).
|
||||
let decodedSoFar = "";
|
||||
|
||||
// SSE lifecycle state.
|
||||
let hasMessageStart = false;
|
||||
let hasContentBlock = false;
|
||||
let hasLifecycleEnd = false;
|
||||
// `event:` type line seen before the next `data:` line in the same event.
|
||||
let pendingEventType = "";
|
||||
|
||||
/**
|
||||
* Parse any complete SSE lines from `decodedSoFar`, updating lifecycle
|
||||
* flags in the closure. The last (potentially incomplete) line is kept in
|
||||
* `decodedSoFar` for the next iteration.
|
||||
*
|
||||
* Returns true when a content_block_* event is detected — the caller
|
||||
* should stop peeking and treat the stream as non-empty.
|
||||
*/
|
||||
function parseAccumulatedSse(): boolean {
|
||||
const lines = decodedSoFar.split(/\r?\n/);
|
||||
// Retain the potentially-incomplete trailing fragment.
|
||||
decodedSoFar = lines[lines.length - 1];
|
||||
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
|
||||
if (trimmed.startsWith("event:")) {
|
||||
pendingEventType = trimmed.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith("data:")) {
|
||||
if (!trimmed) pendingEventType = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventType =
|
||||
(typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || "";
|
||||
pendingEventType = "";
|
||||
|
||||
switch (eventType) {
|
||||
case "message_start":
|
||||
hasMessageStart = true;
|
||||
break;
|
||||
case "content_block_start":
|
||||
case "content_block_delta":
|
||||
case "content_block_stop":
|
||||
hasContentBlock = true;
|
||||
// Signal caller to stop buffering immediately.
|
||||
return true;
|
||||
case "message_stop":
|
||||
hasLifecycleEnd = true;
|
||||
break;
|
||||
case "message_delta": {
|
||||
const delta = parsed.delta;
|
||||
if (
|
||||
delta &&
|
||||
typeof delta === "object" &&
|
||||
(delta as Record<string, unknown>).stop_reason != null
|
||||
) {
|
||||
hasLifecycleEnd = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Response whose body first replays all bytes in `bufferedChunks`,
|
||||
* then forwards the remainder of `readerToForward` chunk-by-chunk.
|
||||
* Preserves the original response's status, statusText, and headers.
|
||||
*/
|
||||
function buildReplayResponse(
|
||||
readerToForward: ReadableStreamDefaultReader<Uint8Array>
|
||||
): Response {
|
||||
// Snapshot the prefix so mutations after this point don't affect it.
|
||||
const prefix = bufferedChunks.slice();
|
||||
let prefixIdx = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
// 1. Drain the buffered prefix one chunk at a time.
|
||||
if (prefixIdx < prefix.length) {
|
||||
controller.enqueue(prefix[prefixIdx++]);
|
||||
return;
|
||||
}
|
||||
// 2. Forward the remainder from the original reader.
|
||||
try {
|
||||
const { done, value } = await readerToForward.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
// Main bounded-peek loop.
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
// Stream finished — flush the TextDecoder and parse any remaining text.
|
||||
const tail = decoder.decode(undefined, { stream: false });
|
||||
if (tail) decodedSoFar += tail;
|
||||
parseAccumulatedSse();
|
||||
|
||||
if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) {
|
||||
// Complete Claude lifecycle with zero content blocks → failover.
|
||||
log.warn?.(
|
||||
"COMBO",
|
||||
"Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover"
|
||||
);
|
||||
return { valid: false, reason: "streaming empty content block" };
|
||||
}
|
||||
|
||||
// Incomplete lifecycle or non-Claude stream — replay all buffered
|
||||
// bytes. The reader is exhausted so the forwarding reader will
|
||||
// immediately signal done.
|
||||
const clonedResponse = buildReplayResponse(reader);
|
||||
return { valid: true, clonedResponse };
|
||||
}
|
||||
|
||||
// Accumulate raw bytes for potential replay.
|
||||
bufferedChunks.push(value);
|
||||
|
||||
// Decode incrementally (stream:true keeps multi-byte char state).
|
||||
decodedSoFar += decoder.decode(value, { stream: true });
|
||||
const foundContent = parseAccumulatedSse();
|
||||
|
||||
if (foundContent) {
|
||||
// A content_block_* event was found — stop peeking. Return a
|
||||
// clonedResponse that replays all buffered bytes (the current chunk
|
||||
// is already in bufferedChunks) and then forwards the remainder of
|
||||
// the original reader unchanged.
|
||||
const clonedResponse = buildReplayResponse(reader);
|
||||
return { valid: true, clonedResponse };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If reading the stream fails, pass through — other mechanisms
|
||||
// (stream readiness timeout) will catch truly broken streams.
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json") && !contentType.includes("text/")) {
|
||||
@@ -2815,6 +3021,7 @@ export async function handleComboChat({
|
||||
? resolveComboConfig(combo, settings)
|
||||
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
|
||||
const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS);
|
||||
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
|
||||
|
||||
// ── Per-model timeout wrapper ────────────────────────────────────────────
|
||||
// Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can
|
||||
@@ -3391,6 +3598,7 @@ export async function handleComboChat({
|
||||
): Promise<{ ok: boolean; response?: Response } | null> => {
|
||||
const target = orderedTargets[i];
|
||||
const modelStr = target.modelStr;
|
||||
const rawModel = parseModel(modelStr).model || modelStr;
|
||||
const provider = target.provider;
|
||||
|
||||
const cb = getCircuitBreaker(provider);
|
||||
@@ -3447,6 +3655,13 @@ export async function handleComboChat({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pre-check: skip models locked by the resilience system (model-level lockout)
|
||||
if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) {
|
||||
log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`);
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pre-screen may have already determined this target unavailable (e.g.
|
||||
// circuit-breaker OPEN at resolve time). Skip immediately in that case.
|
||||
// For targets pre-screened as "available" we still call isModelAvailable
|
||||
@@ -3603,23 +3818,25 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #3587: Reasoning models (deepseek-v4-flash, nemotron, etc.) consume
|
||||
// ALL max_tokens for reasoning_tokens, leaving content empty. Add a buffer
|
||||
// to max_tokens so the model has enough tokens for both reasoning and content.
|
||||
if (supportsReasoning(modelStr)) {
|
||||
// Issue #3587: Reasoning models can spend the whole output budget on
|
||||
// reasoning. Only add headroom when the complete buffer fits inside the
|
||||
// model's known output cap; otherwise preserve the client's explicit limit.
|
||||
{
|
||||
const bodyRecord = attemptBody as Record<string, unknown>;
|
||||
const currentMaxTokens = Number(bodyRecord.max_tokens) || 0;
|
||||
if (currentMaxTokens > 0) {
|
||||
// Add 50% buffer + 1000 floor to ensure reasoning + content both fit
|
||||
const bufferedMaxTokens = Math.max(
|
||||
currentMaxTokens + 1000,
|
||||
Math.ceil(currentMaxTokens * 1.5)
|
||||
);
|
||||
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
||||
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
|
||||
modelStr,
|
||||
bodyRecord.max_tokens,
|
||||
{ enabled: reasoningTokenBufferEnabled }
|
||||
);
|
||||
if (currentMaxTokens !== null && bufferedMaxTokens !== null) {
|
||||
bodyRecord.max_tokens = bufferedMaxTokens;
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
||||
);
|
||||
if (bufferedMaxTokens !== currentMaxTokens) {
|
||||
log.info(
|
||||
"COMBO",
|
||||
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
|
||||
@@ -3648,6 +3865,25 @@ export async function handleComboChat({
|
||||
lastError = `Upstream response failed quality validation: ${quality.reason}`;
|
||||
if (!lastStatus) lastStatus = 502;
|
||||
if (i > 0) fallbackCount++;
|
||||
if (provider && rawModel) {
|
||||
const mlSettings = resolveModelLockoutSettings(settings);
|
||||
if (mlSettings.enabled && mlSettings.errorCodes.includes(502)) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
target.connectionId || "",
|
||||
rawModel,
|
||||
"quality_failure",
|
||||
502,
|
||||
mlSettings.baseCooldownMs,
|
||||
profile,
|
||||
{
|
||||
exactCooldownMs: mlSettings.useExponentialBackoff
|
||||
? 0
|
||||
: mlSettings.baseCooldownMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
emit("combo.target.failed", {
|
||||
comboName: combo.name,
|
||||
targetIndex: i,
|
||||
@@ -3658,6 +3894,25 @@ export async function handleComboChat({
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Success decay: a healthy response walks the model's lockout failure
|
||||
// count back down (and eventually clears an expired lockout entirely).
|
||||
if (provider && rawModel) {
|
||||
const dcResult = decayModelFailureCount(
|
||||
provider,
|
||||
target.connectionId || "",
|
||||
rawModel
|
||||
);
|
||||
if (dcResult.cleared) {
|
||||
log.info("COMBO", `Model ${modelStr} fully recovered — lockout cleared`);
|
||||
} else if (dcResult.newFailureCount > 0) {
|
||||
log.debug(
|
||||
"COMBO",
|
||||
`Model ${modelStr} decayed to failureCount=${dcResult.newFailureCount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
emit("combo.target.succeeded", {
|
||||
comboName: combo.name,
|
||||
@@ -4031,7 +4286,36 @@ export async function handleComboChat({
|
||||
!isTokenLimitBreach &&
|
||||
[408, 429, 500, 502, 503, 504].includes(result.status);
|
||||
if (retry < maxRetries && isTransient && !providerExhausted) {
|
||||
continue; // Retry same model
|
||||
// Record model lockout immediately on the first transient failure —
|
||||
// once the model is cooling down, retrying it would waste an upstream
|
||||
// call and extend the cooldown via exponential backoff.
|
||||
let lockoutRecorded = false;
|
||||
if (provider && rawModel && retry === 0) {
|
||||
const mlSettings = resolveModelLockoutSettings(settings);
|
||||
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
target.connectionId || "",
|
||||
rawModel,
|
||||
classifyLockoutReason(result.status),
|
||||
result.status,
|
||||
mlSettings.baseCooldownMs,
|
||||
profile,
|
||||
{
|
||||
exactCooldownMs: mlSettings.useExponentialBackoff
|
||||
? 0
|
||||
: mlSettings.baseCooldownMs,
|
||||
}
|
||||
);
|
||||
lockoutRecorded = true;
|
||||
}
|
||||
}
|
||||
if (lockoutRecorded) {
|
||||
log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
continue; // Retry same model (transient error, no lockout recorded)
|
||||
}
|
||||
|
||||
// Done retrying this model
|
||||
@@ -4046,6 +4330,25 @@ export async function handleComboChat({
|
||||
lastError = errorText || String(result.status);
|
||||
if (!lastStatus) lastStatus = result.status;
|
||||
if (i > 0) fallbackCount++;
|
||||
// Wire combo failures into the resilience dashboard (model-level lockout)
|
||||
// alongside the provider-level cooldown below — they govern different scopes.
|
||||
if (provider && rawModel) {
|
||||
const mlSettings = resolveModelLockoutSettings(settings);
|
||||
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
target.connectionId || "",
|
||||
rawModel,
|
||||
classifyLockoutReason(result.status),
|
||||
result.status,
|
||||
mlSettings.baseCooldownMs,
|
||||
profile,
|
||||
{
|
||||
exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
|
||||
if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") {
|
||||
@@ -4222,6 +4525,7 @@ async function handleRoundRobinCombo({
|
||||
const maxRetries = config.maxRetries ?? 1;
|
||||
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
|
||||
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
|
||||
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
|
||||
|
||||
const resilienceSettings: ResilienceSettings = settings
|
||||
? resolveResilienceSettings(settings)
|
||||
@@ -4381,27 +4685,30 @@ async function handleRoundRobinCombo({
|
||||
`[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}`
|
||||
);
|
||||
|
||||
// Issue #3587: Reasoning models consume ALL max_tokens for reasoning_tokens.
|
||||
// Add buffer to ensure reasoning + content both fit. Apply the buffer to a
|
||||
// per-attempt COPY — never mutate the shared `body` — so it does not compound
|
||||
// across round-robin iterations/retries (otherwise 4096 -> 6144 -> 9216 -> ...
|
||||
// as each reasoning model re-reads an already-buffered value and overshoots the
|
||||
// model's real limit, triggering 400s).
|
||||
// Issue #3587: Reasoning models can spend the whole output budget on
|
||||
// reasoning. Apply any safe buffer to a per-attempt copy so round-robin
|
||||
// retries never compound across models.
|
||||
let attemptBody = body;
|
||||
if (supportsReasoning(modelStr)) {
|
||||
const currentMaxTokens = Number((body as Record<string, unknown>).max_tokens) || 0;
|
||||
if (currentMaxTokens > 0) {
|
||||
const bufferedMaxTokens = Math.max(
|
||||
currentMaxTokens + 1000,
|
||||
Math.ceil(currentMaxTokens * 1.5)
|
||||
);
|
||||
{
|
||||
const bodyRecord = body as Record<string, unknown>;
|
||||
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
||||
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
|
||||
modelStr,
|
||||
bodyRecord.max_tokens,
|
||||
{ enabled: reasoningTokenBufferEnabled }
|
||||
);
|
||||
if (
|
||||
currentMaxTokens !== null &&
|
||||
bufferedMaxTokens !== null &&
|
||||
bufferedMaxTokens !== currentMaxTokens
|
||||
) {
|
||||
attemptBody = {
|
||||
...(body as Record<string, unknown>),
|
||||
...bodyRecord,
|
||||
max_tokens: bufferedMaxTokens,
|
||||
} as typeof body;
|
||||
log.info(
|
||||
"COMBO-RR",
|
||||
`Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
||||
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
maxMessagesForSummary: 30,
|
||||
maxComboDepth: 3,
|
||||
trackMetrics: true,
|
||||
reasoningTokenBufferEnabled: true,
|
||||
manifestRouting: false,
|
||||
resetAwareSessionWeight: 0.35,
|
||||
resetAwareWeeklyWeight: 0.65,
|
||||
|
||||
145
open-sse/services/geminiRateLimitTracker.ts
Normal file
145
open-sse/services/geminiRateLimitTracker.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* In-memory request counters for Gemini models — tracks both RPD (daily)
|
||||
* and RPM (sliding 60s window) so that 429 responses can be classified
|
||||
* as either quota_exhausted (RPD hit) or rate_limit_exceeded (RPM hit).
|
||||
*
|
||||
* Gemini returns identical error bodies for both types, so we rely on
|
||||
* published per-model limits from geminiRateLimits.json to distinguish them.
|
||||
*
|
||||
* Counters are incremented on every Gemini request so that once usage
|
||||
* reaches the published limit, subsequent 429s are correctly classified.
|
||||
*/
|
||||
|
||||
import geminiLimits from "../config/geminiRateLimits.json";
|
||||
|
||||
// ── RPD (daily) state ────────────────────────────────────────────────────────
|
||||
|
||||
interface DailyCount {
|
||||
date: string; // "YYYY-MM-DD"
|
||||
count: number;
|
||||
}
|
||||
|
||||
const dailyCounts = new Map<string, DailyCount>();
|
||||
|
||||
// ── RPM (sliding 60s window) state ───────────────────────────────────────────
|
||||
|
||||
const minuteWindows = new Map<string, number[]>();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function toDateKey(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function stripModelPrefix(modelId: string): string {
|
||||
// Only strip the "gemini/" provider prefix, never "gemini-" which is part
|
||||
// of the actual model name (e.g. "gemini-2.5-flash", "gemini-3.5-live-translate").
|
||||
return modelId.replace(/^gemini\//, "").trim();
|
||||
}
|
||||
|
||||
function lookupValue(modelId: string, field: "rpm" | "rpd"): number {
|
||||
if (!modelId) return 0;
|
||||
const key = stripModelPrefix(modelId);
|
||||
const entry = (geminiLimits as Record<string, Record<string, number>>)[key];
|
||||
if (!entry) {
|
||||
for (const [knownKey, knownEntry] of Object.entries(geminiLimits)) {
|
||||
if (key.endsWith(knownKey) || knownKey.endsWith(key)) {
|
||||
const val = knownEntry[field];
|
||||
return typeof val === "number" && val > 0 ? val : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const val = entry[field];
|
||||
return typeof val === "number" && val > 0 ? val : 0;
|
||||
}
|
||||
|
||||
// ── RPD exports ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function getModelRpd(modelId: string): number {
|
||||
return lookupValue(modelId, "rpd");
|
||||
}
|
||||
|
||||
export function incrementDailyRequestCount(modelId: string): void {
|
||||
if (!modelId) return;
|
||||
const key = stripModelPrefix(modelId);
|
||||
const today = toDateKey();
|
||||
const existing = dailyCounts.get(key);
|
||||
if (existing && existing.date === today) {
|
||||
existing.count++;
|
||||
} else {
|
||||
dailyCounts.set(key, { date: today, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
export function getDailyRequestCount(modelId: string): number {
|
||||
if (!modelId) return 0;
|
||||
const key = stripModelPrefix(modelId);
|
||||
const today = toDateKey();
|
||||
const entry = dailyCounts.get(key);
|
||||
if (entry && entry.date === today) return entry.count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isRpdExhausted(modelId: string): boolean {
|
||||
const rpd = getModelRpd(modelId);
|
||||
if (rpd <= 0) return false;
|
||||
return getDailyRequestCount(modelId) >= rpd;
|
||||
}
|
||||
|
||||
// ── RPM exports ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function getModelRpm(modelId: string): number {
|
||||
return lookupValue(modelId, "rpm");
|
||||
}
|
||||
|
||||
/** Prune timestamps older than 60 seconds from a model's window. */
|
||||
function pruneMinuteWindow(key: string): void {
|
||||
const now = Date.now();
|
||||
const cutoff = now - 60_000;
|
||||
const timestamps = minuteWindows.get(key);
|
||||
if (!timestamps) return;
|
||||
// Keep only timestamps >= cutoff
|
||||
let i = 0;
|
||||
while (i < timestamps.length && timestamps[i] < cutoff) i++;
|
||||
if (i > 0) {
|
||||
minuteWindows.set(key, timestamps.slice(i));
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementMinuteRequestCount(modelId: string): void {
|
||||
if (!modelId) return;
|
||||
const key = stripModelPrefix(modelId);
|
||||
pruneMinuteWindow(key);
|
||||
const timestamps = minuteWindows.get(key) ?? [];
|
||||
timestamps.push(Date.now());
|
||||
minuteWindows.set(key, timestamps);
|
||||
}
|
||||
|
||||
export function getMinuteRequestCount(modelId: string): number {
|
||||
if (!modelId) return 0;
|
||||
const key = stripModelPrefix(modelId);
|
||||
pruneMinuteWindow(key);
|
||||
return minuteWindows.get(key)?.length ?? 0;
|
||||
}
|
||||
|
||||
export function isRpmExhausted(modelId: string): boolean {
|
||||
const rpm = getModelRpm(modelId);
|
||||
if (rpm <= 0) return false;
|
||||
return getMinuteRequestCount(modelId) >= rpm;
|
||||
}
|
||||
|
||||
// ── Increment both (convenience) ─────────────────────────────────────────────
|
||||
|
||||
/** Increment both daily and minute counters for a Gemini request. */
|
||||
export function incrementRequestCount(modelId: string): void {
|
||||
incrementDailyRequestCount(modelId);
|
||||
incrementMinuteRequestCount(modelId);
|
||||
}
|
||||
|
||||
// ── Reset (testing) ──────────────────────────────────────────────────────────
|
||||
|
||||
export function resetCounters(): void {
|
||||
dailyCounts.clear();
|
||||
minuteWindows.clear();
|
||||
}
|
||||
40
open-sse/services/reasoningTokenBuffer.ts
Normal file
40
open-sse/services/reasoningTokenBuffer.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts";
|
||||
import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts";
|
||||
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens;
|
||||
|
||||
export function toPositiveInteger(value: unknown): number | null {
|
||||
const numericValue =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim() !== ""
|
||||
? Number(value)
|
||||
: null;
|
||||
if (numericValue === null || !Number.isFinite(numericValue)) return null;
|
||||
const normalized = Math.floor(numericValue);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveReasoningBufferedMaxTokens(
|
||||
modelStr: string,
|
||||
currentMaxTokens: unknown,
|
||||
options: { enabled?: boolean } = {}
|
||||
): number | null {
|
||||
if (options.enabled === false) return null;
|
||||
|
||||
const current = toPositiveInteger(currentMaxTokens);
|
||||
if (current === null) return null;
|
||||
|
||||
const capabilities = getResolvedModelCapabilities(modelStr);
|
||||
if (capabilities.supportsThinking !== true) return null;
|
||||
|
||||
const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens);
|
||||
if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null;
|
||||
if (current > maxOutputTokens) return maxOutputTokens;
|
||||
if (current === maxOutputTokens) return current;
|
||||
|
||||
const buffered = Math.max(current + 1000, Math.ceil(current * 1.5));
|
||||
if (buffered > maxOutputTokens) return current;
|
||||
|
||||
return buffered;
|
||||
}
|
||||
@@ -181,6 +181,93 @@ function getRefreshCacheKey(provider, refreshToken) {
|
||||
return `${provider}:${tokenHash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth2 error codes that mean the refresh token is permanently dead and
|
||||
* retrying will never succeed → callers must emit the unrecoverable sentinel
|
||||
* so the HealthCheck deactivates the account instead of looping every 60s.
|
||||
* Deliberately EXCLUDES transient codes (server_error, temporarily_unavailable,
|
||||
* slow_down) so we never deactivate an account over a recoverable blip.
|
||||
*/
|
||||
const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([
|
||||
"invalid_grant",
|
||||
"invalid_request",
|
||||
"refresh_token_reused",
|
||||
"invalid_token",
|
||||
"expired_token",
|
||||
"unauthorized_client",
|
||||
"access_denied",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract a canonical OAuth error code from a refresh-endpoint error body of
|
||||
* ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in
|
||||
* several shapes — a plain object `{error:"invalid_grant"}`, a nested
|
||||
* `{error:{code:"invalid_grant"}}`, a JSON **string** (double-encoded body),
|
||||
* or the raw JSON text wrapped as `{error:"<json text>"}` by a catch branch.
|
||||
* The old `errorBody.error === "invalid_grant"` only matched the first shape,
|
||||
* so the others returned `null` → the HealthCheck refresh loop (root cause of
|
||||
* the 1352× claude/aa5dd5cf invalidation storm).
|
||||
*
|
||||
* Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES)
|
||||
* or null. Never matches loosely — a known code is accepted only when it is a
|
||||
* bare code string or the value of an `"error"`/`"error_code"` field, so a 502
|
||||
* HTML page or a `server_error` body never becomes a false positive.
|
||||
*/
|
||||
export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null {
|
||||
if (raw == null || depth > 6) return null;
|
||||
|
||||
if (typeof raw === "string") {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
if (UNRECOVERABLE_OAUTH_ERROR_CODES.has(s)) return s;
|
||||
// The string may itself be JSON (a double-encoded body, or the raw text).
|
||||
if (s[0] === "{" || s[0] === "[" || s[0] === '"') {
|
||||
try {
|
||||
const nested = extractOAuthErrorCode(JSON.parse(s), depth + 1);
|
||||
if (nested) return nested;
|
||||
} catch {
|
||||
// not valid JSON — fall through to the field scan
|
||||
}
|
||||
}
|
||||
// Safety net: a known code appearing as the value of an "error"/"error_code"
|
||||
// field inside otherwise-unparsed text. Scoped to avoid false positives.
|
||||
const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i);
|
||||
if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
const o = raw as Record<string, unknown>;
|
||||
return (
|
||||
extractOAuthErrorCode(o.error, depth + 1) ??
|
||||
extractOAuthErrorCode(o.code, depth + 1) ??
|
||||
extractOAuthErrorCode(o.error_code, depth + 1)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an error response body ONCE and classify it. Returns the raw text (for
|
||||
* logging) and the extracted unrecoverable OAuth code (or null). Reading once
|
||||
* avoids the double-read bug where `response.json()` consumes the stream and a
|
||||
* later `response.text()` returns empty.
|
||||
*/
|
||||
async function readRefreshErrorBody(
|
||||
response: Response
|
||||
): Promise<{ rawText: string; code: string | null }> {
|
||||
const rawText = await response.text().catch(() => "");
|
||||
let parsed: unknown = rawText;
|
||||
try {
|
||||
parsed = JSON.parse(rawText);
|
||||
} catch {
|
||||
// keep rawText as-is
|
||||
}
|
||||
const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText);
|
||||
return { rawText, code };
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth access token using refresh token
|
||||
*/
|
||||
@@ -229,6 +316,10 @@ export async function refreshAccessToken(
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const code = extractOAuthErrorCode(errorText);
|
||||
if (code === "invalid_grant" || code === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -401,6 +492,10 @@ export async function refreshClineToken(refreshToken, log, proxyConfig: unknown
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const code = extractOAuthErrorCode(errorText);
|
||||
if (code === "invalid_grant" || code === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -664,19 +759,17 @@ export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: un
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody: { error?: string; error_description?: string } = {};
|
||||
try {
|
||||
errorBody = await response.json();
|
||||
} catch {
|
||||
const text = await response.text().catch(() => "unknown");
|
||||
errorBody = { error: text };
|
||||
}
|
||||
// Read + classify the body ONCE, shape-agnostic. A proxy/MITM can deliver
|
||||
// the invalid_grant 400 as a JSON string, a double-encoded string, a
|
||||
// nested {error:{code}}, or raw text — all must yield the sentinel so the
|
||||
// HealthCheck deactivates instead of looping every 60s.
|
||||
const { rawText, code } = await readRefreshErrorBody(response);
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
||||
status: response.status,
|
||||
error: errorBody,
|
||||
error: rawText.slice(0, 300),
|
||||
});
|
||||
if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code: errorBody.error };
|
||||
if (code === "invalid_grant" || code === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1186,6 +1279,10 @@ export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const code = extractOAuthErrorCode(errorText);
|
||||
if (code === "invalid_grant" || code === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1230,6 +1327,10 @@ export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
const code = extractOAuthErrorCode(errorText);
|
||||
if (code === "invalid_grant" || code === "invalid_request") {
|
||||
return { error: "unrecoverable_refresh_error", code };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2995,24 +2995,65 @@ export function buildKiroUsageResult(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for
|
||||
* AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the
|
||||
* region-matched endpoint and prefers a profile whose ARN is in the same region. Returns
|
||||
* undefined when no profile is available (e.g. the org/token has no Kiro entitlement).
|
||||
* Exported for testing.
|
||||
*/
|
||||
export async function discoverKiroProfileArn(
|
||||
accessToken: string,
|
||||
usageBaseUrl: string,
|
||||
region: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(usageBaseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ maxResults: 10 }),
|
||||
// Don't let a hung profile lookup block the usage/quota refresh indefinitely.
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
const data = toRecord(await response.json());
|
||||
const profiles = Array.isArray(data.profiles) ? data.profiles : [];
|
||||
const normalizedRegion = region.toLowerCase();
|
||||
const matched =
|
||||
profiles.find((profile: unknown) => {
|
||||
const arn = toRecord(profile).arn;
|
||||
return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`);
|
||||
}) || profiles[0];
|
||||
const arn = toRecord(matched).arn;
|
||||
return typeof arn === "string" && arn.length > 0 ? arn : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) Usage
|
||||
*/
|
||||
async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) {
|
||||
try {
|
||||
const profileArn = providerSpecificData?.profileArn;
|
||||
if (!profileArn) {
|
||||
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
||||
}
|
||||
let profileArn =
|
||||
typeof providerSpecificData?.profileArn === "string"
|
||||
? providerSpecificData.profileArn
|
||||
: undefined;
|
||||
|
||||
// Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and
|
||||
// endpoint must all match the region. Derive the region from the stored region (preferred)
|
||||
// or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the
|
||||
// legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions).
|
||||
const regionFromArn =
|
||||
typeof profileArn === "string"
|
||||
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
|
||||
: undefined;
|
||||
const regionFromArn = profileArn
|
||||
? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1]
|
||||
: undefined;
|
||||
const region =
|
||||
(typeof providerSpecificData?.region === "string" &&
|
||||
providerSpecificData.region.trim().toLowerCase()) ||
|
||||
@@ -3021,6 +3062,17 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec
|
||||
const usageBaseUrl =
|
||||
region === "us-east-1" ? CODEWHISPERER_BASE_URL : `https://q.${region}.amazonaws.com`;
|
||||
|
||||
// IAM Identity Center logins and kiro-cli imports frequently don't persist a profileArn, which
|
||||
// previously caused the quota card to show nothing ("0 used"). Discover it on demand from
|
||||
// ListAvailableProfiles (region-matched) so usage still resolves for those accounts.
|
||||
if (!profileArn && accessToken) {
|
||||
profileArn = await discoverKiroProfileArn(accessToken, usageBaseUrl, region);
|
||||
}
|
||||
|
||||
if (!profileArn) {
|
||||
return { message: "Kiro connected. Profile ARN not available for quota tracking." };
|
||||
}
|
||||
|
||||
// Kiro uses AWS CodeWhisperer GetUsageLimits API
|
||||
const payload = {
|
||||
origin: "AI_EDITOR",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -816,7 +816,7 @@ register(
|
||||
FORMATS.GEMINI,
|
||||
(model, body, stream = false, credentials = null) =>
|
||||
openaiToGeminiRequest(model, body, stream, credentials, {
|
||||
signaturelessToolCallMode: "native",
|
||||
signaturelessToolCallMode: "context",
|
||||
}),
|
||||
null
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"checkJs": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,11 @@ function normalizeResponsesSseIds(payload: JsonRecord): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) {
|
||||
if (
|
||||
payload.response &&
|
||||
typeof payload.response === "object" &&
|
||||
!Array.isArray(payload.response)
|
||||
) {
|
||||
const response = payload.response as JsonRecord;
|
||||
let responseChanged = false;
|
||||
const normalizedResponse = { ...response };
|
||||
@@ -1016,6 +1020,32 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const emitClaudeEmptyStreamErrorAndAbort = (
|
||||
controller: TransformStreamDefaultController,
|
||||
decrementPendingRequest = true
|
||||
) => {
|
||||
clearIdleTimer();
|
||||
const msg = "Claude returned an empty response (no content block)";
|
||||
console.warn(
|
||||
`[STREAM] Empty Claude stream at flush - emitting error (${provider || "provider"}:${model || "unknown"})`
|
||||
);
|
||||
const errorBody = buildErrorBody(502, msg);
|
||||
const errorEvent: Record<string, unknown> = { type: "error", error: errorBody.error };
|
||||
const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE);
|
||||
reqLogger?.appendConvertedChunk?.(errOutput);
|
||||
clientPayloadCollector.push(errorEvent);
|
||||
controller.enqueue(encoder.encode(errOutput));
|
||||
if (onFailure) {
|
||||
try {
|
||||
void onFailure({ status: 502, message: msg, code: "empty_response" });
|
||||
} catch {}
|
||||
}
|
||||
if (decrementPendingRequest) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
}
|
||||
controller.error(markPendingRequestCleared(new Error(msg)));
|
||||
};
|
||||
|
||||
const emitTranslatedClientItem = (
|
||||
controller: TransformStreamDefaultController,
|
||||
item: Record<string, unknown>
|
||||
@@ -1059,13 +1089,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
sourceFormat === FORMATS.CLAUDE &&
|
||||
shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, itemSanitized)
|
||||
) {
|
||||
const eventType = getClaudeEventType(itemSanitized);
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta:
|
||||
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: false,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceFormat === FORMATS.CLAUDE && isClaudeEventPayload(itemSanitized)) {
|
||||
@@ -1300,12 +1325,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
type: eventType,
|
||||
})
|
||||
) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta:
|
||||
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: false,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPassthroughEventLine = line;
|
||||
@@ -1337,7 +1358,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// clients like OpenCode, so drop it only for Responses-native consumers.
|
||||
const hasActiveDeltaValue = (value: unknown): boolean => {
|
||||
if (typeof value === "string") return value.length > 0;
|
||||
if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry));
|
||||
if (Array.isArray(value))
|
||||
return value.some((entry) => hasActiveDeltaValue(entry));
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value).some((entry) => hasActiveDeltaValue(entry));
|
||||
}
|
||||
@@ -1605,7 +1627,12 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed,
|
||||
passthroughResponsesOutputItems
|
||||
);
|
||||
if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) {
|
||||
if (
|
||||
stripped ||
|
||||
backfilled ||
|
||||
textualToolCallBackfilled ||
|
||||
responsesIdsNormalized
|
||||
) {
|
||||
output = `data: ${JSON.stringify(parsed)}\n`;
|
||||
injectedUsage = true;
|
||||
}
|
||||
@@ -1632,13 +1659,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
parsed
|
||||
)
|
||||
) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta:
|
||||
parsed.type === "message_stop" &&
|
||||
!claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: false,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller);
|
||||
return;
|
||||
}
|
||||
updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed);
|
||||
const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap);
|
||||
@@ -1708,14 +1730,16 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
!parsed.choices[0].delta.reasoning_content
|
||||
);
|
||||
const hadNonStringToolCallId = Array.isArray(parsed.choices)
|
||||
? parsed.choices.some((choice) =>
|
||||
Array.isArray(choice?.delta?.tool_calls) &&
|
||||
choice.delta.tool_calls.some(
|
||||
(tc) => tc?.id != null && typeof tc.id !== "string"
|
||||
)
|
||||
? parsed.choices.some(
|
||||
(choice) =>
|
||||
Array.isArray(choice?.delta?.tool_calls) &&
|
||||
choice.delta.tool_calls.some(
|
||||
(tc) => tc?.id != null && typeof tc.id !== "string"
|
||||
)
|
||||
)
|
||||
: false;
|
||||
const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string";
|
||||
const hadNonStringTopLevelId =
|
||||
parsed?.id != null && typeof parsed.id !== "string";
|
||||
|
||||
parsed = sanitizeStreamingChunk(parsed);
|
||||
if (
|
||||
@@ -2148,13 +2172,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
bufferedPayload
|
||||
)
|
||||
) {
|
||||
const eventType = getClaudeEventType(bufferedPayload);
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta:
|
||||
eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: false,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller, false);
|
||||
return;
|
||||
}
|
||||
if (isClaudeEventPayload(bufferedPayload)) {
|
||||
updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload);
|
||||
@@ -2164,7 +2183,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// Normalize numeric IDs for final buffered data: chunk (same as transform path)
|
||||
if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) {
|
||||
const flushedParsed = bufferedPayload as JsonRecord;
|
||||
const flushedType = typeof flushedParsed.type === "string" ? flushedParsed.type : "";
|
||||
const flushedType =
|
||||
typeof flushedParsed.type === "string" ? flushedParsed.type : "";
|
||||
const isResponses = flushedType.startsWith("response.");
|
||||
const isClaude = isClaudeEventPayload(flushedParsed);
|
||||
if (isResponses) {
|
||||
@@ -2181,7 +2201,9 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
if (Array.isArray(flushedParsed.choices)) {
|
||||
for (const choice of flushedParsed.choices as JsonRecord[]) {
|
||||
const tcs = (choice as JsonRecord | undefined)?.delta as JsonRecord | undefined;
|
||||
const tcs = (choice as JsonRecord | undefined)?.delta as
|
||||
| JsonRecord
|
||||
| undefined;
|
||||
if (Array.isArray(tcs?.tool_calls)) {
|
||||
for (const tc of tcs.tool_calls as JsonRecord[]) {
|
||||
if (tc?.id != null && typeof tc.id !== "string") {
|
||||
@@ -2208,11 +2230,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
}
|
||||
|
||||
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller, false);
|
||||
return;
|
||||
} else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: false,
|
||||
@@ -2489,11 +2508,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
|
||||
if (sourceFormat === FORMATS.CLAUDE) {
|
||||
if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: true,
|
||||
includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta,
|
||||
includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop,
|
||||
});
|
||||
emitClaudeEmptyStreamErrorAndAbort(controller, false);
|
||||
return;
|
||||
} else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) {
|
||||
emitSyntheticClaudeEmptyResponse(controller, {
|
||||
includeContentBlock: false,
|
||||
@@ -2631,7 +2647,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
export default createSSEStream
|
||||
export default createSSEStream;
|
||||
|
||||
// Convenience functions for backward compatibility
|
||||
export function createSSETransformStreamWithLogger(
|
||||
|
||||
BIN
public/audio/ui-notify.mp3
Normal file
BIN
public/audio/ui-notify.mp3
Normal file
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,10 @@
|
||||
// Uses createRoot + act to mount each hook inside a minimal wrapper component
|
||||
// so we test real React hook semantics without a full Next.js server context.
|
||||
|
||||
import React, { act } from "react";
|
||||
import React, { act, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import path from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global mocks required by the extracted hooks
|
||||
@@ -27,10 +28,7 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
||||
if (values) {
|
||||
return Object.entries(values).reduce(
|
||||
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
|
||||
key
|
||||
);
|
||||
return Object.entries(values).reduce((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key);
|
||||
}
|
||||
return key;
|
||||
},
|
||||
@@ -83,10 +81,13 @@ describe("useProviderConnections — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderConnections("openai", true, false);
|
||||
const hookResult = useProviderConnections("openai", true, false);
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return (
|
||||
<span data-testid="loaded">
|
||||
{String(result.connections.length)}|{String(result.batchTesting)}
|
||||
{String(hookResult.connections.length)}|{String(hookResult.batchTesting)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +113,10 @@ describe("useProviderConnections — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderConnections("openai", true, false);
|
||||
const hookResult = useProviderConnections("openai", true, false);
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
@@ -181,7 +185,10 @@ describe("useProviderSettings — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderSettings("openai");
|
||||
const hookResult = useProviderSettings("openai");
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
@@ -207,7 +214,10 @@ describe("useProviderSettings — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderSettings("codex");
|
||||
const hookResult = useProviderSettings("codex");
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
@@ -253,7 +263,10 @@ describe("useProviderModels — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderModels("openai", false);
|
||||
const hookResult = useProviderModels("openai", false);
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
@@ -275,7 +288,10 @@ describe("useProviderModels — initial state", () => {
|
||||
let result: HookResult | null = null;
|
||||
|
||||
function TestWrapper() {
|
||||
result = useProviderModels("openai", false);
|
||||
const hookResult = useProviderModels("openai", false);
|
||||
useEffect(() => {
|
||||
result = hookResult;
|
||||
}, [hookResult]);
|
||||
return <span />;
|
||||
}
|
||||
|
||||
@@ -316,8 +332,13 @@ describe("useProviderModels — initial state", () => {
|
||||
// Cycle-safety: hooks must NOT import from ProviderDetailPageClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HOOKS_DIR =
|
||||
"/home/diegosouzapw/dev/proxys/OmniRoute/.worktrees/fix-3501-phase1f/src/app/(dashboard)/dashboard/providers/[id]/hooks";
|
||||
// Resolve the hooks dir from the repo root (vitest runs from cwd). Was a
|
||||
// hardcoded absolute worktree path that broke the test outside that worktree
|
||||
// (#3501 Phase 1g-1j).
|
||||
const HOOKS_DIR = path.join(
|
||||
process.cwd(),
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks"
|
||||
);
|
||||
|
||||
describe("Cycle-safety — hooks do not import ProviderDetailPageClient", () => {
|
||||
// We allow the name in JSDoc comments; what we forbid is an actual ES import statement.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { Modal } from "@/shared/components";
|
||||
|
||||
type AdaptaTutorialModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Como conectar o Adapta Web" size="md">
|
||||
<div className="flex flex-col gap-5 text-sm">
|
||||
<p className="text-text-muted">
|
||||
O Adapta usa autenticação via Clerk. O token{" "}
|
||||
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> é um JWT
|
||||
de longa duração que permite renovar sessões automaticamente.
|
||||
</p>
|
||||
|
||||
<ol className="flex flex-col gap-4 list-none">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">Acesse o chat do Adapta</p>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Abra{" "}
|
||||
<a
|
||||
href="https://agent.adapta.one/agentic-chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
agent.adapta.one/agentic-chat
|
||||
</a>{" "}
|
||||
e faça login com sua conta Gold ou Business.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">Abra o DevTools</p>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Pressione{" "}
|
||||
<kbd className="bg-surface-2 px-1.5 py-0.5 rounded text-xs font-mono">F12</kbd>{" "}
|
||||
ou{" "}
|
||||
<kbd className="bg-surface-2 px-1.5 py-0.5 rounded text-xs font-mono">
|
||||
Cmd+Option+I
|
||||
</kbd>{" "}
|
||||
para abrir as Ferramentas do Desenvolvedor.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">Vá em Application → Cookies</p>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Na aba <strong>Application</strong> (Chrome/Edge) ou <strong>Storage</strong>{" "}
|
||||
(Firefox), expanda <strong>Cookies</strong> e clique em{" "}
|
||||
<code className="bg-surface-2 px-1 rounded font-mono text-xs">
|
||||
.clerk.agent.adapta.one
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Copie o valor do cookie{" "}
|
||||
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code>
|
||||
</p>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Localize o cookie chamado{" "}
|
||||
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> na
|
||||
lista. Clique nele e copie o conteúdo da coluna <strong>Value</strong> — começa
|
||||
com <code className="bg-surface-2 px-1 rounded font-mono text-xs">eyJ…</code>.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-none w-6 h-6 rounded-full bg-primary text-white text-xs font-bold flex items-center justify-center">
|
||||
5
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium">Cole aqui e salve</p>
|
||||
<p className="text-text-muted mt-0.5">
|
||||
Clique em <strong>Add Connection</strong>, cole o valor do{" "}
|
||||
<code className="bg-surface-2 px-1 rounded font-mono text-xs">__client</code> no
|
||||
campo de API Key e salve. O OmniRoute renovará a sessão automaticamente.
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div
|
||||
className="rounded-lg p-3 text-xs text-text-muted"
|
||||
style={{ backgroundColor: "rgba(110,58,211,0.08)", borderLeft: "3px solid #6E3AD3" }}
|
||||
>
|
||||
<strong>Dica:</strong> O cookie <code className="font-mono">__client</code> tem
|
||||
validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta
|
||||
invalidar a sessão.
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
|
||||
type BatchTestResultsModalProps = {
|
||||
batchTestResults: {
|
||||
error?: any;
|
||||
results?: Array<{
|
||||
connectionId?: string;
|
||||
connectionName?: string;
|
||||
valid?: boolean;
|
||||
latencyMs?: number;
|
||||
diagnosis?: { type?: string };
|
||||
}>;
|
||||
summary?: {
|
||||
passed: number;
|
||||
failed: number;
|
||||
total: number;
|
||||
};
|
||||
} | null;
|
||||
providerInfo: any;
|
||||
providerId: string;
|
||||
emailsVisible: boolean;
|
||||
onClose: () => void;
|
||||
t: any;
|
||||
};
|
||||
|
||||
export default function BatchTestResultsModal({
|
||||
batchTestResults,
|
||||
providerInfo,
|
||||
providerId,
|
||||
emailsVisible,
|
||||
onClose,
|
||||
t,
|
||||
}: BatchTestResultsModalProps) {
|
||||
if (!batchTestResults) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-[600px] max-h-[80vh] overflow-y-auto shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
|
||||
<h3 className="font-semibold">{t("testResults")}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{batchTestResults.error &&
|
||||
(!batchTestResults.results || batchTestResults.results.length === 0) ? (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2 block">
|
||||
error
|
||||
</span>
|
||||
<p className="text-sm text-red-400">{String(batchTestResults.error)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{batchTestResults.summary && (
|
||||
<div className="flex items-center gap-3 text-xs mb-1">
|
||||
<span className="text-text-muted">{providerInfo?.name || providerId}</span>
|
||||
<span className="px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-400 font-medium">
|
||||
{t("passedCount", { count: batchTestResults.summary.passed })}
|
||||
</span>
|
||||
{batchTestResults.summary.failed > 0 && (
|
||||
<span className="px-2 py-0.5 rounded bg-red-500/15 text-red-400 font-medium">
|
||||
{t("failedCount", { count: batchTestResults.summary.failed })}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-muted ml-auto">
|
||||
{t("testedCount", { count: batchTestResults.summary.total })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(batchTestResults.results || []).map((r: any, i: number) => (
|
||||
<div
|
||||
key={r.connectionId || i}
|
||||
className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[16px] ${
|
||||
r.valid ? "text-emerald-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{r.valid ? "check_circle" : "error"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">
|
||||
{pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)}
|
||||
</span>
|
||||
</div>
|
||||
{r.latencyMs !== undefined && (
|
||||
<span className="text-text-muted font-mono tabular-nums">
|
||||
{t("millisecondsAbbr", { value: r.latencyMs })}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded ${
|
||||
r.valid
|
||||
? "bg-emerald-500/15 text-emerald-400"
|
||||
: "bg-red-500/15 text-red-400"
|
||||
}`}
|
||||
>
|
||||
{r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!batchTestResults.results || batchTestResults.results.length === 0) && (
|
||||
<div className="text-center py-4 text-text-muted text-sm">
|
||||
{t("noActiveConnectionsInGroup")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.2 extraction — Issue #3501
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, Button } from "@/shared/components";
|
||||
import { getApiLabel, getApiPath } from "../providerPageHelpers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface ProviderNode {
|
||||
baseUrl?: string;
|
||||
apiType?: string;
|
||||
chatPath?: string;
|
||||
prefix?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CompatibleNodeCardProps {
|
||||
providerId: string;
|
||||
providerNode: ProviderNode;
|
||||
isCcCompatible: boolean;
|
||||
isAnthropicCompatible: boolean;
|
||||
isAnthropicProtocolCompatible: boolean;
|
||||
gateConnectionFlow: (callback: () => void) => void;
|
||||
openApiKeyAddFlow: () => void;
|
||||
onOpenEditNodeModal: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function CompatibleNodeCard({
|
||||
providerId,
|
||||
providerNode,
|
||||
isCcCompatible,
|
||||
isAnthropicCompatible,
|
||||
isAnthropicProtocolCompatible,
|
||||
gateConnectionFlow,
|
||||
openApiKeyAddFlow,
|
||||
onOpenEditNodeModal,
|
||||
t,
|
||||
}: CompatibleNodeCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isCcCompatible
|
||||
? t("ccCompatibleDetailsTitle")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleDetails")
|
||||
: t("openaiCompatibleDetails")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{getApiLabel(t, isAnthropicProtocolCompatible, providerNode?.apiType)} ·{" "}
|
||||
{(providerNode.baseUrl || "").replace(/\/$/, "")}/
|
||||
{getApiPath(
|
||||
isCcCompatible,
|
||||
isAnthropicCompatible,
|
||||
providerNode?.apiType,
|
||||
providerNode?.chatPath
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openApiKeyAddFlow)}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="edit"
|
||||
onClick={onOpenEditNodeModal}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="delete"
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
t("deleteCompatibleNodeConfirm", {
|
||||
type: isCcCompatible
|
||||
? t("ccCompatibleLabel")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropic")
|
||||
: t("openai"),
|
||||
})
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const res = await fetch(`/api/provider-nodes/${providerId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
router.push("/dashboard/providers");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting provider node:", error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isCcCompatible && (
|
||||
<div className="mb-4 rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Toggle } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
|
||||
|
||||
type ConnectionsHeaderToolbarProps = {
|
||||
providerId: string;
|
||||
providerInfo: any; // resolveDashboardProviderInfo result
|
||||
isCompatible: boolean;
|
||||
isCommandCode: boolean;
|
||||
isOAuth: boolean;
|
||||
providerSupportsPat: boolean;
|
||||
connections: any[]; // ConnectionRowConnection[]
|
||||
batchTesting: boolean;
|
||||
batchRetesting: boolean;
|
||||
retestingId: string | null;
|
||||
distributingProxies: boolean;
|
||||
proxyConfig: any;
|
||||
// from useProviderSettings
|
||||
preferClaudeCodeForUnprefixedClaudeModels: boolean;
|
||||
claudeRoutingSettingsLoaded: boolean;
|
||||
claudeRoutingSettingsLoadError: string | null;
|
||||
savingClaudeRoutingPreference: boolean;
|
||||
handleToggleClaudeRoutingPreference: () => void;
|
||||
loadClaudeRoutingSettings: () => Promise<void>;
|
||||
codexGlobalServiceMode: string;
|
||||
codexGlobalServiceModeOptions: Array<{ value: string; label: string }>;
|
||||
codexSettingsLoaded: boolean;
|
||||
codexSettingsLoadError: string | null;
|
||||
savingCodexGlobalServiceMode: boolean;
|
||||
handleChangeCodexGlobalServiceMode: (mode: any) => void;
|
||||
loadCodexSettings: () => Promise<void>;
|
||||
// Modal triggers
|
||||
onSetProxyTarget: (target: { level: string; id: string; label: string }) => void;
|
||||
handleDistributeProxies: () => void;
|
||||
handleBatchTestAll: () => void;
|
||||
gateConnectionFlow: (callback: () => void) => void;
|
||||
openApiKeyAddFlow: () => void;
|
||||
openPrimaryAddFlow: () => void;
|
||||
openExternalLinkFlow: () => void;
|
||||
handleOpenCommandCodeConnect: () => void;
|
||||
commandCodeAuthState: { phase: string };
|
||||
onOpenOAuthModal: () => void;
|
||||
onOpenCodexCliGuide: () => void;
|
||||
onOpenImportCodex: () => void;
|
||||
onOpenImportClaude: () => void;
|
||||
onOpenImportGemini: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export default function ConnectionsHeaderToolbar({
|
||||
providerId,
|
||||
providerInfo,
|
||||
isCompatible,
|
||||
isCommandCode,
|
||||
isOAuth,
|
||||
providerSupportsPat,
|
||||
connections,
|
||||
batchTesting,
|
||||
batchRetesting,
|
||||
retestingId,
|
||||
distributingProxies,
|
||||
proxyConfig,
|
||||
preferClaudeCodeForUnprefixedClaudeModels,
|
||||
claudeRoutingSettingsLoaded,
|
||||
claudeRoutingSettingsLoadError,
|
||||
savingClaudeRoutingPreference,
|
||||
handleToggleClaudeRoutingPreference,
|
||||
loadClaudeRoutingSettings,
|
||||
codexGlobalServiceMode,
|
||||
codexGlobalServiceModeOptions,
|
||||
codexSettingsLoaded,
|
||||
codexSettingsLoadError,
|
||||
savingCodexGlobalServiceMode,
|
||||
handleChangeCodexGlobalServiceMode,
|
||||
loadCodexSettings,
|
||||
onSetProxyTarget,
|
||||
handleDistributeProxies,
|
||||
handleBatchTestAll,
|
||||
gateConnectionFlow,
|
||||
openApiKeyAddFlow,
|
||||
openPrimaryAddFlow,
|
||||
openExternalLinkFlow,
|
||||
handleOpenCommandCodeConnect,
|
||||
commandCodeAuthState,
|
||||
onOpenOAuthModal,
|
||||
onOpenCodexCliGuide,
|
||||
onOpenImportCodex,
|
||||
onOpenImportClaude,
|
||||
onOpenImportGemini,
|
||||
t,
|
||||
}: ConnectionsHeaderToolbarProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{t("connections")}</h2>
|
||||
{providerId === "claude" && (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-orange-500/20 bg-orange-500/5 px-2 py-1 text-xs font-medium text-text-muted"
|
||||
title={providerText(
|
||||
t,
|
||||
"preferClaudeCodeForUnprefixedClaudeModelsTooltip",
|
||||
"Route bare claude-* model IDs from Claude Code clients through the Claude Code account instead of asking for a provider prefix."
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-orange-500">
|
||||
alt_route
|
||||
</span>
|
||||
<span>
|
||||
{providerText(
|
||||
t,
|
||||
"preferClaudeCodeForUnprefixedClaudeModelsLabel",
|
||||
"Claude Code default"
|
||||
)}
|
||||
</span>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={preferClaudeCodeForUnprefixedClaudeModels}
|
||||
onChange={handleToggleClaudeRoutingPreference}
|
||||
disabled={savingClaudeRoutingPreference || !claudeRoutingSettingsLoaded}
|
||||
ariaLabel={providerText(
|
||||
t,
|
||||
"preferClaudeCodeForUnprefixedClaudeModelsAria",
|
||||
"Prefer Claude Code for unprefixed Claude models"
|
||||
)}
|
||||
title={
|
||||
preferClaudeCodeForUnprefixedClaudeModels
|
||||
? providerText(
|
||||
t,
|
||||
"preferClaudeCodeForUnprefixedClaudeModelsDisable",
|
||||
"Disable Claude Code preference for bare claude-* model IDs"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"preferClaudeCodeForUnprefixedClaudeModelsEnable",
|
||||
"Enable Claude Code preference for bare claude-* model IDs"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px] text-text-muted/70">
|
||||
{preferClaudeCodeForUnprefixedClaudeModels
|
||||
? providerText(t, "toggleOnShort", "On")
|
||||
: providerText(t, "toggleOffShort", "Off")}
|
||||
</span>
|
||||
{claudeRoutingSettingsLoadError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadClaudeRoutingSettings()}
|
||||
className="rounded border border-orange-500/30 px-2 py-0.5 text-[11px] font-medium text-orange-600 hover:bg-orange-500/10 dark:text-orange-300"
|
||||
title={claudeRoutingSettingsLoadError}
|
||||
>
|
||||
{providerText(t, "retry", "Retry")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<div
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1 text-xs font-medium text-text-muted"
|
||||
title={providerText(
|
||||
t,
|
||||
"providerDetailServiceModeTooltip",
|
||||
"Set a global Codex service mode, or leave accounts on their individual service-tier setting."
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{providerText(t, "providerDetailServiceModeLabel", "Global service mode:")}
|
||||
</span>
|
||||
<select
|
||||
value={codexGlobalServiceMode}
|
||||
onChange={(event) =>
|
||||
handleChangeCodexGlobalServiceMode(event.target.value as CodexGlobalServiceMode)
|
||||
}
|
||||
disabled={savingCodexGlobalServiceMode || !codexSettingsLoaded}
|
||||
aria-label="Global Codex service mode"
|
||||
className="rounded-md border border-border bg-bg px-2 py-1 text-xs text-text-main outline-none transition-colors focus:border-primary disabled:opacity-60"
|
||||
>
|
||||
{codexGlobalServiceModeOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{codexSettingsLoadError ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCodexSettings()}
|
||||
className="rounded border border-sky-500/30 px-2 py-0.5 text-[11px] font-medium text-sky-600 hover:bg-sky-500/10 dark:text-sky-300"
|
||||
title={codexSettingsLoadError}
|
||||
>
|
||||
{providerText(t, "retry", "Retry")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{/* Provider-level proxy indicator/button */}
|
||||
<button
|
||||
onClick={() =>
|
||||
onSetProxyTarget({
|
||||
level: "provider",
|
||||
id: providerId,
|
||||
label: providerInfo?.name || providerId,
|
||||
})
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all ${
|
||||
proxyConfig?.providers?.[providerId]
|
||||
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title={
|
||||
proxyConfig?.providers?.[providerId]
|
||||
? t("providerProxyTitleConfigured", {
|
||||
host: proxyConfig.providers[providerId].host || t("configured"),
|
||||
})
|
||||
: t("providerProxyConfigureHint")
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">vpn_lock</span>
|
||||
{proxyConfig?.providers?.[providerId]
|
||||
? proxyConfig.providers[providerId].host || t("providerProxy")
|
||||
: t("providerProxy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{connections.length > 0 && (
|
||||
<button
|
||||
onClick={() => handleDistributeProxies()}
|
||||
disabled={distributingProxies || batchTesting || !!retestingId}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
distributingProxies
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("distributeProxies")}
|
||||
aria-label={t("distributeProxies")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{distributingProxies ? "sync" : "swap_horiz"}
|
||||
</span>
|
||||
{distributingProxies ? t("distributing") : t("distributeProxies")}
|
||||
</button>
|
||||
)}
|
||||
{connections.length > 1 && (
|
||||
<button
|
||||
onClick={handleBatchTestAll}
|
||||
disabled={batchTesting || batchRetesting || !!retestingId}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
batchTesting
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("testAll")}
|
||||
aria-label={t("testAll")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{batchTesting ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{batchTesting ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
)}
|
||||
{!isCompatible ? (
|
||||
<>
|
||||
{isCommandCode ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="open_in_new"
|
||||
loading={
|
||||
commandCodeAuthState.phase === "starting" ||
|
||||
commandCodeAuthState.phase === "polling" ||
|
||||
commandCodeAuthState.phase === "applying"
|
||||
}
|
||||
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
|
||||
>
|
||||
Manual API key
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => gateConnectionFlow(openPrimaryAddFlow)}
|
||||
>
|
||||
{providerSupportsPat ? "Add PAT" : t("add")}
|
||||
</Button>
|
||||
{providerId === "qoder" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
|
||||
>
|
||||
Experimental OAuth
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="menu_book"
|
||||
onClick={() => onOpenCodexCliGuide()}
|
||||
>
|
||||
Codex CLI Guide
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="share"
|
||||
onClick={() => gateConnectionFlow(openExternalLinkFlow)}
|
||||
>
|
||||
Adicionar Externo
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportCodex)}
|
||||
>
|
||||
{typeof (t as any).has === "function" && (t as any).has("importCodexAuth")
|
||||
? t("importCodexAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "claude" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportClaude)}
|
||||
>
|
||||
{typeof (t as any).has === "function" && (t as any).has("importClaudeAuth")
|
||||
? t("importClaudeAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "gemini-cli" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportGemini)}
|
||||
>
|
||||
{typeof (t as any).has === "function" && (t as any).has("importGeminiAuth")
|
||||
? t("importGeminiAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
connections.length === 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
icon="add"
|
||||
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
|
||||
>
|
||||
{t("add")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { type ConnectionRowConnection } from "./ConnectionRow";
|
||||
import ConnectionRow from "./ConnectionRow";
|
||||
import { Button } from "@/shared/components";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
|
||||
import { compareTr } from "@/shared/utils/turkishText";
|
||||
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
|
||||
|
||||
type ConnectionsListPanelProps = {
|
||||
connections: ConnectionRowConnection[];
|
||||
providerId: string;
|
||||
isCcCompatible: boolean;
|
||||
isOAuth: boolean;
|
||||
codexGlobalServiceMode: CodexGlobalServiceMode | string;
|
||||
selectedIds: Set<string>;
|
||||
batchUpdating: string | null;
|
||||
batchRetesting: boolean;
|
||||
batchDeleting: boolean;
|
||||
batchTesting: boolean;
|
||||
retestingId: string | null;
|
||||
refreshingId: string | null;
|
||||
distributingProxies: boolean;
|
||||
healthFilter: string;
|
||||
page: number;
|
||||
PAGE_SIZE: number;
|
||||
connProxyMap: Record<string, { proxy?: { host?: string }; level?: string } | undefined>;
|
||||
proxyConfig: any;
|
||||
applyingCodexAuthId: string | null;
|
||||
exportingCodexAuthId: string | null;
|
||||
applyingClaudeAuthId: string | null;
|
||||
exportingClaudeAuthId: string | null;
|
||||
applyingGeminiAuthId: string | null;
|
||||
exportingGeminiAuthId: string | null;
|
||||
emailsVisible: boolean;
|
||||
// Setters
|
||||
setSelectedIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||
setHealthFilter: (v: string) => void;
|
||||
// Callbacks from useProviderConnections
|
||||
handleDelete: (id: string) => void;
|
||||
handleUpdateConnectionStatus: (id: string, isActive: boolean) => void;
|
||||
handleToggleRateLimit: (id: string, enabled: boolean) => void;
|
||||
handleToggleClaudeExtraUsage: (id: string, enabled: boolean) => void;
|
||||
handleToggleCliproxyapiMode: (id: string, enabled: boolean) => void;
|
||||
handleToggleCodexLimit: (id: string, type: "use5h" | "useWeekly", enabled: boolean) => void;
|
||||
handleToggleProxyEnabled: (id: string, enabled: boolean) => void;
|
||||
handleTogglePerKeyProxyEnabled: (id: string, enabled: boolean) => void;
|
||||
handleRetestConnection: (id: string) => void;
|
||||
handleRefreshToken: (id: string) => void;
|
||||
handleSwapPriority: (a: ConnectionRowConnection, b: ConnectionRowConnection) => void;
|
||||
handleBatchSetActive: (active: boolean) => void;
|
||||
handleBatchDeleteOpenModal: () => void;
|
||||
handleBatchRetest: () => void;
|
||||
handleToggleSelectOne: (id: string) => void;
|
||||
handleToggleSelectAll: () => void;
|
||||
handleDistributeProxies: (tag?: string) => void;
|
||||
cpaProviderEnabled: boolean;
|
||||
// Modal triggers (all pass through from client, no closing over client internals)
|
||||
onOpenEditModal: (conn: ConnectionRowConnection) => void;
|
||||
onOpenOAuth: (conn: ConnectionRowConnection) => void;
|
||||
onSetProxyTarget: (target: { level: string; id: string; label: string }) => void;
|
||||
onOpenApplyCodexModal: (connId: string) => void;
|
||||
onExportCodexAuthFile: (connId: string) => void;
|
||||
onOpenApplyClaudeModal: (connId: string) => void;
|
||||
onExportClaudeAuthFile: (connId: string) => void;
|
||||
onOpenApplyGeminiModal: (connId: string) => void;
|
||||
onExportGeminiAuthFile: (connId: string) => void;
|
||||
gateConnectionFlow: (callback: () => void) => void;
|
||||
t: any; // ProviderMessageTranslator
|
||||
};
|
||||
|
||||
export default function ConnectionsListPanel({
|
||||
connections,
|
||||
providerId,
|
||||
isCcCompatible,
|
||||
isOAuth,
|
||||
codexGlobalServiceMode,
|
||||
selectedIds,
|
||||
batchUpdating,
|
||||
batchRetesting,
|
||||
batchDeleting,
|
||||
batchTesting,
|
||||
retestingId,
|
||||
refreshingId,
|
||||
distributingProxies,
|
||||
healthFilter,
|
||||
page,
|
||||
PAGE_SIZE,
|
||||
connProxyMap,
|
||||
proxyConfig,
|
||||
applyingCodexAuthId,
|
||||
exportingCodexAuthId,
|
||||
applyingClaudeAuthId,
|
||||
exportingClaudeAuthId,
|
||||
applyingGeminiAuthId,
|
||||
exportingGeminiAuthId,
|
||||
emailsVisible,
|
||||
setSelectedIds,
|
||||
setPage,
|
||||
setHealthFilter,
|
||||
handleDelete,
|
||||
handleUpdateConnectionStatus,
|
||||
handleToggleRateLimit,
|
||||
handleToggleClaudeExtraUsage,
|
||||
handleToggleCliproxyapiMode,
|
||||
handleToggleCodexLimit,
|
||||
handleToggleProxyEnabled,
|
||||
handleTogglePerKeyProxyEnabled,
|
||||
handleRetestConnection,
|
||||
handleRefreshToken,
|
||||
handleSwapPriority,
|
||||
handleBatchSetActive,
|
||||
handleBatchDeleteOpenModal,
|
||||
handleBatchRetest,
|
||||
handleToggleSelectOne,
|
||||
handleToggleSelectAll,
|
||||
handleDistributeProxies,
|
||||
cpaProviderEnabled,
|
||||
onOpenEditModal,
|
||||
onOpenOAuth,
|
||||
onSetProxyTarget,
|
||||
onOpenApplyCodexModal,
|
||||
onExportCodexAuthFile,
|
||||
onOpenApplyClaudeModal,
|
||||
onExportClaudeAuthFile,
|
||||
onOpenApplyGeminiModal,
|
||||
onExportGeminiAuthFile,
|
||||
gateConnectionFlow,
|
||||
t,
|
||||
}: ConnectionsListPanelProps) {
|
||||
const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
||||
const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined);
|
||||
const allSelected = selectedIds.size === connections.length && connections.length > 0;
|
||||
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
|
||||
const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting;
|
||||
const bulkActions = selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="toggle_on"
|
||||
loading={batchUpdating === "activate"}
|
||||
disabled={bulkBusy && batchUpdating !== "activate"}
|
||||
onClick={() => handleBatchSetActive(true)}
|
||||
>
|
||||
{t("batchActivateSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="toggle_off"
|
||||
loading={batchUpdating === "deactivate"}
|
||||
disabled={bulkBusy && batchUpdating !== "deactivate"}
|
||||
onClick={() => handleBatchSetActive(false)}
|
||||
>
|
||||
{t("batchDeactivateSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="play_arrow"
|
||||
loading={batchRetesting}
|
||||
disabled={(bulkBusy && !batchRetesting) || !!retestingId}
|
||||
onClick={handleBatchRetest}
|
||||
>
|
||||
{t("batchRetestSelected")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="delete"
|
||||
loading={batchDeleting}
|
||||
disabled={bulkBusy && !batchDeleting}
|
||||
onClick={handleBatchDeleteOpenModal}
|
||||
>
|
||||
{t("batchDeleteSelected", { count: selectedIds.size })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isHealthy = (c: ConnectionRowConnection): boolean => {
|
||||
const s = c.testStatus;
|
||||
return c.isActive !== false && (!s || s === "active" || s === "success");
|
||||
};
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: "all", label: t("filterAll", "All") },
|
||||
{ value: "active", label: t("filterActive", "Active") },
|
||||
{ value: "error", label: t("filterError", "Error") },
|
||||
{ value: "banned", label: t("filterBanned", "Banned") },
|
||||
{
|
||||
value: "credits_exhausted",
|
||||
label: t("filterCreditsExhausted", "Credits Exhausted"),
|
||||
},
|
||||
];
|
||||
const filtered =
|
||||
healthFilter === "all"
|
||||
? sorted
|
||||
: sorted.filter((c) => {
|
||||
if (healthFilter === "active") return isHealthy(c);
|
||||
if (healthFilter === "error")
|
||||
return (
|
||||
!isHealthy(c) &&
|
||||
c.testStatus !== "banned" &&
|
||||
c.testStatus !== "credits_exhausted"
|
||||
);
|
||||
return c.testStatus === healthFilter;
|
||||
});
|
||||
|
||||
const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const clampedPage = Math.min(page, totalFilteredPages - 1);
|
||||
const pageStart = clampedPage * PAGE_SIZE;
|
||||
const pageEnd = pageStart + PAGE_SIZE;
|
||||
|
||||
const filterPills = (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{STATUS_FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setHealthFilter(opt.value);
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||
healthFilter === opt.value
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted/60 text-text-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const paginationBar =
|
||||
totalFilteredPages > 1 ? (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t border-border">
|
||||
<span className="text-xs text-text-muted">
|
||||
{pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_left"
|
||||
disabled={clampedPage === 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
/>
|
||||
<span className="text-xs text-text-muted min-w-[4rem] text-center">
|
||||
{clampedPage + 1} / {totalFilteredPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="chevron_right"
|
||||
disabled={clampedPage >= totalFilteredPages - 1}
|
||||
onClick={() => setPage((p) => Math.min(totalFilteredPages - 1, p + 1))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
if (!hasAnyTag) {
|
||||
const pageConnections = filtered.slice(pageStart, pageEnd);
|
||||
const allSelectedPage =
|
||||
pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id));
|
||||
const someSelectedPage = pageConnections.some((c) => selectedIds.has(c.id));
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelectedPage}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelectedPage;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allSelectedPage) {
|
||||
const toRemove = new Set(pageConnections.map((c) => c.id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of toRemove) next.delete(id);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const c of pageConnections) next.add(c.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
filtered.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{filterPills}
|
||||
</div>
|
||||
|
||||
{bulkActions}
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03] border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
{pageConnections.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-text-muted">
|
||||
{t("noFilteredConnections", "No connections match the current filter.")}
|
||||
</div>
|
||||
) : (
|
||||
pageConnections.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexGlobalServiceMode={codexGlobalServiceMode}
|
||||
isFirst={index === 0}
|
||||
isLast={index === pageConnections.length - 1}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])}
|
||||
onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isGeminiCli={providerId === "gemini-cli"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) =>
|
||||
handleToggleCliproxyapiMode(conn.id, enabled)
|
||||
}
|
||||
onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => onOpenEditModal(conn)}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => gateConnectionFlow(() => onOpenOAuth(conn))
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex" ? () => onOpenApplyCodexModal(conn.id) : undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex" ? () => onExportCodexAuthFile(conn.id) : undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onApplyClaudeAuthLocal={
|
||||
providerId === "claude" ? () => onOpenApplyClaudeModal(conn.id) : undefined
|
||||
}
|
||||
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
|
||||
onExportClaudeAuthFile={
|
||||
providerId === "claude" ? () => onExportClaudeAuthFile(conn.id) : undefined
|
||||
}
|
||||
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
|
||||
onApplyGeminiAuthLocal={
|
||||
providerId === "gemini-cli"
|
||||
? () => onOpenApplyGeminiModal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
|
||||
onExportGeminiAuthFile={
|
||||
providerId === "gemini-cli"
|
||||
? () => onExportGeminiAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
onSetProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
|
||||
onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)}
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) =>
|
||||
handleTogglePerKeyProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{paginationBar}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Build ordered tag groups: untagged first, then alphabetically
|
||||
const groupMap = new Map<string, ConnectionRowConnection[]>();
|
||||
for (const conn of filtered) {
|
||||
const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || "";
|
||||
if (!groupMap.has(tag)) groupMap.set(tag, []);
|
||||
groupMap.get(tag)!.push(conn);
|
||||
}
|
||||
const groupKeys = Array.from(groupMap.keys()).sort((a, b) => {
|
||||
if (a === "") return -1;
|
||||
if (b === "") return 1;
|
||||
return compareTr(a, b);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedIds.size > 0 || connections.length > 0 ? (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected;
|
||||
}}
|
||||
onChange={handleToggleSelectAll}
|
||||
className="w-4 h-4 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-muted">
|
||||
{selectedIds.size > 0
|
||||
? providerCountText(
|
||||
t,
|
||||
"selectedCount",
|
||||
selectedIds.size,
|
||||
"{count} selected",
|
||||
"{count} selected"
|
||||
)
|
||||
: providerCountText(
|
||||
t,
|
||||
"accountsCount",
|
||||
filtered.length,
|
||||
"{count} account",
|
||||
"{count} accounts"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
{filterPills}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{/* Distribute Proxies lives in the provider toolbar (top action bar);
|
||||
removed the duplicate here that rendered simultaneously when nothing
|
||||
was selected. Per-tag groups keep their own scoped button. */}
|
||||
{bulkActions}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0 border border-t-0 border-border rounded-b-lg overflow-hidden">
|
||||
{groupKeys.map((tag, gi) => {
|
||||
const groupConns = groupMap.get(tag)!;
|
||||
return (
|
||||
<div
|
||||
key={tag || "__untagged__"}
|
||||
className={
|
||||
gi > 0
|
||||
? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{tag && (
|
||||
<div className="flex items-center gap-2 px-3 pt-2 pb-1">
|
||||
<span className="material-symbols-outlined text-[13px] text-text-muted/50">
|
||||
label
|
||||
</span>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-widest text-text-muted/60 select-none">
|
||||
{tag}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-black/[0.04] dark:bg-white/[0.04]" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="shield"
|
||||
loading={distributingProxies}
|
||||
onClick={() => handleDistributeProxies(tag)}
|
||||
>
|
||||
Distribute Proxies
|
||||
</Button>
|
||||
<span className="text-[10px] text-text-muted/40">{groupConns.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-black/[0.03] dark:divide-white/[0.03]">
|
||||
{groupConns.map((conn, index) => (
|
||||
<ConnectionRow
|
||||
key={conn.id}
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexGlobalServiceMode={codexGlobalServiceMode}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={gi === groupKeys.length - 1 && index === groupConns.length - 1}
|
||||
isSelected={selectedIds.has(conn.id)}
|
||||
onToggleSelect={() => handleToggleSelectOne(conn.id)}
|
||||
onMoveUp={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1])
|
||||
}
|
||||
onMoveDown={() =>
|
||||
handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1])
|
||||
}
|
||||
onToggleActive={(isActive) =>
|
||||
handleUpdateConnectionStatus(conn.id, isActive)
|
||||
}
|
||||
onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)}
|
||||
onToggleClaudeExtraUsage={(enabled) =>
|
||||
handleToggleClaudeExtraUsage(conn.id, enabled)
|
||||
}
|
||||
isCodex={providerId === "codex"}
|
||||
isGeminiCli={providerId === "gemini-cli"}
|
||||
isCcCompatible={isCcCompatible}
|
||||
cliproxyapiEnabled={cpaProviderEnabled}
|
||||
onToggleCliproxyapiMode={(enabled) =>
|
||||
handleToggleCliproxyapiMode(conn.id, enabled)
|
||||
}
|
||||
onToggleCodex5h={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "use5h", enabled)
|
||||
}
|
||||
onToggleCodexWeekly={(enabled) =>
|
||||
handleToggleCodexLimit(conn.id, "useWeekly", enabled)
|
||||
}
|
||||
onRetest={() => handleRetestConnection(conn.id)}
|
||||
isRetesting={retestingId === conn.id}
|
||||
onEdit={() => onOpenEditModal(conn)}
|
||||
onDelete={() => handleDelete(conn.id)}
|
||||
onReauth={
|
||||
conn.authType === "oauth"
|
||||
? () => gateConnectionFlow(() => onOpenOAuth(conn))
|
||||
: undefined
|
||||
}
|
||||
onRefreshToken={
|
||||
conn.authType === "oauth"
|
||||
? () => handleRefreshToken(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isRefreshing={refreshingId === conn.id}
|
||||
onApplyCodexAuthLocal={
|
||||
providerId === "codex"
|
||||
? () => onOpenApplyCodexModal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id}
|
||||
onExportCodexAuthFile={
|
||||
providerId === "codex"
|
||||
? () => onExportCodexAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingCodexAuthFile={exportingCodexAuthId === conn.id}
|
||||
onApplyClaudeAuthLocal={
|
||||
providerId === "claude"
|
||||
? () => onOpenApplyClaudeModal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id}
|
||||
onExportClaudeAuthFile={
|
||||
providerId === "claude"
|
||||
? () => onExportClaudeAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id}
|
||||
onApplyGeminiAuthLocal={
|
||||
providerId === "gemini-cli"
|
||||
? () => onOpenApplyGeminiModal(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id}
|
||||
onExportGeminiAuthFile={
|
||||
providerId === "gemini-cli"
|
||||
? () => onExportGeminiAuthFile(conn.id)
|
||||
: undefined
|
||||
}
|
||||
isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id}
|
||||
onProxy={() =>
|
||||
onSetProxyTarget({
|
||||
level: "key",
|
||||
id: conn.id,
|
||||
label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id),
|
||||
})
|
||||
}
|
||||
hasProxy={!!connProxyMap[conn.id]?.proxy}
|
||||
proxySource={connProxyMap[conn.id]?.level || null}
|
||||
proxyHost={connProxyMap[conn.id]?.proxy?.host || null}
|
||||
proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)}
|
||||
onToggleProxyEnabled={(enabled) =>
|
||||
handleToggleProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)}
|
||||
onTogglePerKeyProxyEnabled={(enabled) =>
|
||||
handleTogglePerKeyProxyEnabled(conn.id, enabled)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.6 extraction — Issue #3501
|
||||
import { Button } from "@/shared/components";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface CommandCodeAuthState {
|
||||
phase: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface EmptyConnectionsPlaceholderProps {
|
||||
isOAuth: boolean;
|
||||
isCompatible: boolean;
|
||||
isCommandCode: boolean;
|
||||
providerId: string;
|
||||
providerSupportsPat: boolean;
|
||||
commandCodeAuthState: CommandCodeAuthState;
|
||||
gateConnectionFlow: (callback: () => void) => void;
|
||||
openApiKeyAddFlow: () => void;
|
||||
openPrimaryAddFlow: () => void;
|
||||
handleOpenCommandCodeConnect: () => void;
|
||||
onOpenOAuthModal: () => void;
|
||||
onOpenImportCodex: () => void;
|
||||
onOpenImportClaude: () => void;
|
||||
onOpenImportGemini: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function EmptyConnectionsPlaceholder({
|
||||
isOAuth,
|
||||
isCompatible,
|
||||
isCommandCode,
|
||||
providerId,
|
||||
providerSupportsPat,
|
||||
commandCodeAuthState,
|
||||
gateConnectionFlow,
|
||||
openApiKeyAddFlow,
|
||||
openPrimaryAddFlow,
|
||||
handleOpenCommandCodeConnect,
|
||||
onOpenOAuthModal,
|
||||
onOpenImportCodex,
|
||||
onOpenImportClaude,
|
||||
onOpenImportGemini,
|
||||
t,
|
||||
}: EmptyConnectionsPlaceholderProps) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 text-primary mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-main font-medium mb-1">{t("noConnectionsYet")}</p>
|
||||
<p className="text-sm text-text-muted mb-4">{t("addFirstConnectionHint")}</p>
|
||||
{!isCompatible && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isCommandCode ? (
|
||||
<>
|
||||
<Button
|
||||
icon="open_in_new"
|
||||
loading={
|
||||
commandCodeAuthState.phase === "starting" ||
|
||||
commandCodeAuthState.phase === "polling" ||
|
||||
commandCodeAuthState.phase === "applying"
|
||||
}
|
||||
onClick={() => gateConnectionFlow(handleOpenCommandCodeConnect)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onClick={() => gateConnectionFlow(openApiKeyAddFlow)}
|
||||
>
|
||||
Manual API key
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
|
||||
{providerSupportsPat ? "Add PAT" : t("addConnection")}
|
||||
</Button>
|
||||
{providerId === "qoder" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => gateConnectionFlow(onOpenOAuthModal)}
|
||||
>
|
||||
Experimental OAuth
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "codex" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportCodex)}
|
||||
>
|
||||
{typeof t.has === "function" && t.has("importCodexAuth")
|
||||
? t("importCodexAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "claude" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportClaude)}
|
||||
>
|
||||
{typeof t.has === "function" && t.has("importClaudeAuth")
|
||||
? t("importClaudeAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "gemini-cli" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="upload_file"
|
||||
onClick={() => gateConnectionFlow(onOpenImportGemini)}
|
||||
>
|
||||
{typeof t.has === "function" && t.has("importGeminiAuth")
|
||||
? t("importGeminiAuth")
|
||||
: "Import auth"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Modal, Button } from "@/shared/components";
|
||||
|
||||
type ExternalLinkModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
url: string;
|
||||
copied: string | false;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
};
|
||||
|
||||
export default function ExternalLinkModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
loading,
|
||||
error,
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
}: ExternalLinkModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Externo — link do Codex">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a
|
||||
página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso
|
||||
único, expira em 15 minutos.
|
||||
</p>
|
||||
{loading ? (
|
||||
<p className="text-sm text-text-muted">Gerando link…</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
) : url ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-border bg-bg-base p-3 break-all text-sm text-text-main">
|
||||
{url}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
icon="open_in_new"
|
||||
onClick={() => window.open(url, "_blank", "noopener")}
|
||||
>
|
||||
Abrir
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="content_copy"
|
||||
onClick={() => onCopy(url, "extlink")}
|
||||
>
|
||||
{copied === "extlink" ? "Copiado" : "Copiar"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]">sync</span>
|
||||
Aguardando a autenticação no navegador da pessoa… esta janela atualiza sozinha.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImportProgressModal — Issue #3501 Phase 1k
|
||||
*
|
||||
* Extracted from the inline Import Progress Modal JSX in ProviderDetailPageClient.
|
||||
* Pure presentational component driven entirely by props.
|
||||
*
|
||||
* Cycle-safe: no import from ProviderDetailPageClient.
|
||||
*/
|
||||
|
||||
import { Modal } from "@/shared/components";
|
||||
import type { ImportProgress } from "../hooks/useModelImportHandlers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface ImportProgressModalProps {
|
||||
importProgress: ImportProgress;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function ImportProgressModal({
|
||||
importProgress,
|
||||
isOpen,
|
||||
onClose,
|
||||
t,
|
||||
}: ImportProgressModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t("importingModelsTitle")}
|
||||
size="md"
|
||||
closeOnOverlay={false}
|
||||
showCloseButton={importProgress.phase === "done" || importProgress.phase === "error"}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Status text */}
|
||||
<div className="flex items-center gap-3">
|
||||
{importProgress.phase === "fetching" && (
|
||||
<span className="material-symbols-outlined text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
{importProgress.phase === "importing" && (
|
||||
<span className="material-symbols-outlined text-primary animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
{importProgress.phase === "done" && (
|
||||
<span className="material-symbols-outlined text-green-500">check_circle</span>
|
||||
)}
|
||||
{importProgress.phase === "error" && (
|
||||
<span className="material-symbols-outlined text-red-500">error</span>
|
||||
)}
|
||||
<span className="text-sm font-medium text-text-main">{importProgress.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{(importProgress.phase === "importing" || importProgress.phase === "done") &&
|
||||
importProgress.total > 0 && (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-text-muted">
|
||||
{importProgress.current} / {importProgress.total}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{Math.round((importProgress.current / importProgress.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${(importProgress.current / importProgress.total) * 100}%`,
|
||||
background:
|
||||
importProgress.phase === "done"
|
||||
? "linear-gradient(90deg, #22c55e, #16a34a)"
|
||||
: "linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetching indeterminate bar */}
|
||||
{importProgress.phase === "fetching" && (
|
||||
<div className="w-full h-2.5 bg-black/10 dark:bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full animate-pulse"
|
||||
style={{
|
||||
width: "60%",
|
||||
background:
|
||||
"linear-gradient(90deg, var(--color-primary), var(--color-primary-hover, var(--color-primary)))",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{importProgress.phase === "error" && importProgress.error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-sm text-red-400">{importProgress.error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log list */}
|
||||
{importProgress.logs.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg bg-black/5 dark:bg-white/5 p-3 border border-black/5 dark:border-white/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
{importProgress.logs.map((log, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={`text-xs font-mono ${
|
||||
typeof log === "string" && log.startsWith("✓")
|
||||
? "text-green-500 font-semibold"
|
||||
: "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{log}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
{importProgress.phase === "done" && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("close")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.5 extraction — Issue #3501
|
||||
// Pure composition of all modal elements rendered by ProviderDetailPageClient.
|
||||
import { ConfirmModal, OAuthModal, KiroOAuthWrapper, CursorAuthModal, TraeAuthModal, ProxyConfigModal } from "@/shared/components";
|
||||
import RiskNoticeModal from "../../components/RiskNoticeModal";
|
||||
import CodexCliGuideModal from "../../components/CodexCliGuideModal";
|
||||
import SiliconFlowEndpointModal from "./SiliconFlowEndpointModal";
|
||||
import AddApiKeyModal from "./modals/AddApiKeyModal";
|
||||
import EditConnectionModal from "./modals/EditConnectionModal";
|
||||
import EditCompatibleNodeModal from "./modals/EditCompatibleNodeModal";
|
||||
import ExternalLinkModal from "./ExternalLinkModal";
|
||||
import BatchTestResultsModal from "./BatchTestResultsModal";
|
||||
import ImportProgressModal from "./ImportProgressModal";
|
||||
import { AdaptaTutorialModal } from "./AdaptaTutorialModal";
|
||||
import {
|
||||
ImportCodexAuthModal,
|
||||
ApplyCodexAuthModal,
|
||||
} from "./modals/ImportCodexAuthModal";
|
||||
import {
|
||||
ImportClaudeAuthModal,
|
||||
ApplyClaudeAuthModal,
|
||||
} from "./modals/ImportClaudeAuthModal";
|
||||
import {
|
||||
ImportGeminiAuthModal,
|
||||
ApplyGeminiAuthModal,
|
||||
} from "./modals/ImportGeminiAuthModal";
|
||||
import { type ConnectionRowConnection } from "./ConnectionRow";
|
||||
import { type BatchTestResults } from "../hooks/useProviderConnections";
|
||||
import { type ImportProgress } from "../hooks/useModelImportHandlers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface ProviderInfo {
|
||||
name: string;
|
||||
riskNoticeVariant?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProxyTarget {
|
||||
level: string;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProviderModalsPanelProps {
|
||||
providerId: string;
|
||||
providerInfo: ProviderInfo;
|
||||
isCompatible: boolean;
|
||||
isAnthropicProtocolCompatible: boolean;
|
||||
isCcCompatible: boolean;
|
||||
isCommandCode: boolean;
|
||||
isUpstreamProxyProvider: boolean;
|
||||
subscriptionRisk: boolean;
|
||||
// Risk notice
|
||||
showRiskNoticeModal: boolean;
|
||||
handleConfirmRiskNotice: () => void;
|
||||
handleCancelRiskNotice: () => void;
|
||||
// OAuth
|
||||
showOAuthModal: boolean;
|
||||
reauthConnection: ConnectionRowConnection | null;
|
||||
handleOAuthSuccess: () => void;
|
||||
setShowOAuthModal: (show: boolean) => void;
|
||||
// SiliconFlow
|
||||
showSiliconFlowEndpointModal: boolean;
|
||||
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
|
||||
setShowSiliconFlowEndpointModal: (open: boolean) => void;
|
||||
setShowAddApiKeyModal: (open: boolean) => void;
|
||||
// AddApiKey
|
||||
showAddApiKeyModal: boolean;
|
||||
siliconFlowInitialBaseUrl: string | undefined;
|
||||
commandCodeAuthState: { phase: string; [key: string]: unknown };
|
||||
handleStartCommandCodeAuth: () => void;
|
||||
handleSaveApiKey: (data: any) => Promise<void>;
|
||||
handleCloseAddApiKeyModal: () => void;
|
||||
// Batch delete confirm
|
||||
batchDeleteConfirmOpen: boolean;
|
||||
setBatchDeleteConfirmOpen: (open: boolean) => void;
|
||||
handleBatchDeleteConfirm: () => void;
|
||||
selectedIds: Set<string>;
|
||||
batchDeleting: boolean;
|
||||
// Codex auth
|
||||
applyCodexModalConnectionId: string | null;
|
||||
setApplyCodexModalConnectionId: (id: string | null) => void;
|
||||
applyingCodexAuthId: string | null;
|
||||
handleApplyCodexAuthLocal: (id: string) => Promise<void>;
|
||||
importCodexModalOpen: boolean;
|
||||
setImportCodexModalOpen: (open: boolean) => void;
|
||||
fetchConnections: () => Promise<void>;
|
||||
// External link
|
||||
externalLinkModalOpen: boolean;
|
||||
setExternalLinkModalOpen: (open: boolean) => void;
|
||||
externalLinkLoading: boolean;
|
||||
externalLinkError: string | null;
|
||||
externalLinkUrl: string | null;
|
||||
externalLinkCopied: boolean;
|
||||
externalLinkCopy: () => void;
|
||||
// Edit connection
|
||||
showEditModal: boolean;
|
||||
setShowEditModal: (open: boolean) => void;
|
||||
selectedConnection: { id: string } | null;
|
||||
handleUpdateConnection: (data: any) => Promise<string | null>;
|
||||
// Edit compatible node
|
||||
showEditNodeModal: boolean;
|
||||
setShowEditNodeModal: (open: boolean) => void;
|
||||
providerNode: any;
|
||||
handleUpdateNode: (data: any) => Promise<void>;
|
||||
// Codex CLI guide
|
||||
codexCliGuideOpen: boolean;
|
||||
setCodexCliGuideOpen: (open: boolean) => void;
|
||||
// Claude auth
|
||||
applyClaudeModalConnectionId: string | null;
|
||||
setApplyClaudeModalConnectionId: (id: string | null) => void;
|
||||
applyingClaudeAuthId: string | null;
|
||||
handleApplyClaudeAuthLocal: (id: string) => Promise<void>;
|
||||
importClaudeModalOpen: boolean;
|
||||
setImportClaudeModalOpen: (open: boolean) => void;
|
||||
// Gemini auth
|
||||
applyGeminiModalConnectionId: string | null;
|
||||
setApplyGeminiModalConnectionId: (id: string | null) => void;
|
||||
applyingGeminiAuthId: string | null;
|
||||
handleApplyGeminiAuthLocal: (id: string) => Promise<void>;
|
||||
importGeminiModalOpen: boolean;
|
||||
setImportGeminiModalOpen: (open: boolean) => void;
|
||||
// Batch test results
|
||||
batchTestResults: BatchTestResults | null;
|
||||
setBatchTestResults: (r: BatchTestResults | null) => void;
|
||||
emailsVisible: boolean;
|
||||
// Proxy config
|
||||
proxyTarget: ProxyTarget | null;
|
||||
setProxyTarget: (t: ProxyTarget | null) => void;
|
||||
fetchProxyConfig: () => Promise<void>;
|
||||
// Import progress
|
||||
importProgress: ImportProgress;
|
||||
showImportModal: boolean;
|
||||
setShowImportModal: (open: boolean) => void;
|
||||
// Tutorial
|
||||
showTutorialModal: boolean;
|
||||
setShowTutorialModal: (open: boolean) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function ProviderModalsPanel({
|
||||
providerId,
|
||||
providerInfo,
|
||||
isCompatible,
|
||||
isAnthropicProtocolCompatible,
|
||||
isCcCompatible,
|
||||
isUpstreamProxyProvider,
|
||||
subscriptionRisk,
|
||||
showRiskNoticeModal,
|
||||
handleConfirmRiskNotice,
|
||||
handleCancelRiskNotice,
|
||||
showOAuthModal,
|
||||
reauthConnection,
|
||||
handleOAuthSuccess,
|
||||
setShowOAuthModal,
|
||||
showSiliconFlowEndpointModal,
|
||||
setSiliconFlowInitialBaseUrl,
|
||||
setShowSiliconFlowEndpointModal,
|
||||
setShowAddApiKeyModal,
|
||||
showAddApiKeyModal,
|
||||
siliconFlowInitialBaseUrl,
|
||||
commandCodeAuthState,
|
||||
handleStartCommandCodeAuth,
|
||||
handleSaveApiKey,
|
||||
handleCloseAddApiKeyModal,
|
||||
isCommandCode,
|
||||
batchDeleteConfirmOpen,
|
||||
setBatchDeleteConfirmOpen,
|
||||
handleBatchDeleteConfirm,
|
||||
selectedIds,
|
||||
batchDeleting,
|
||||
applyCodexModalConnectionId,
|
||||
setApplyCodexModalConnectionId,
|
||||
applyingCodexAuthId,
|
||||
handleApplyCodexAuthLocal,
|
||||
importCodexModalOpen,
|
||||
setImportCodexModalOpen,
|
||||
fetchConnections,
|
||||
externalLinkModalOpen,
|
||||
setExternalLinkModalOpen,
|
||||
externalLinkLoading,
|
||||
externalLinkError,
|
||||
externalLinkUrl,
|
||||
externalLinkCopied,
|
||||
externalLinkCopy,
|
||||
showEditModal,
|
||||
setShowEditModal,
|
||||
selectedConnection,
|
||||
handleUpdateConnection,
|
||||
showEditNodeModal,
|
||||
setShowEditNodeModal,
|
||||
providerNode,
|
||||
handleUpdateNode,
|
||||
codexCliGuideOpen,
|
||||
setCodexCliGuideOpen,
|
||||
applyClaudeModalConnectionId,
|
||||
setApplyClaudeModalConnectionId,
|
||||
applyingClaudeAuthId,
|
||||
handleApplyClaudeAuthLocal,
|
||||
importClaudeModalOpen,
|
||||
setImportClaudeModalOpen,
|
||||
applyGeminiModalConnectionId,
|
||||
setApplyGeminiModalConnectionId,
|
||||
applyingGeminiAuthId,
|
||||
handleApplyGeminiAuthLocal,
|
||||
importGeminiModalOpen,
|
||||
setImportGeminiModalOpen,
|
||||
batchTestResults,
|
||||
setBatchTestResults,
|
||||
emailsVisible,
|
||||
proxyTarget,
|
||||
setProxyTarget,
|
||||
fetchProxyConfig,
|
||||
importProgress,
|
||||
showImportModal,
|
||||
setShowImportModal,
|
||||
showTutorialModal,
|
||||
setShowTutorialModal,
|
||||
t,
|
||||
}: ProviderModalsPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{showRiskNoticeModal && subscriptionRisk && (
|
||||
<RiskNoticeModal
|
||||
variant={(providerInfo.riskNoticeVariant as string) ?? "oauth"}
|
||||
providerId={providerId}
|
||||
providerName={providerInfo.name}
|
||||
onConfirm={handleConfirmRiskNotice}
|
||||
onCancel={handleCancelRiskNotice}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider &&
|
||||
(providerId === "kiro" || providerId === "amazon-q" ? (
|
||||
<KiroOAuthWrapper
|
||||
isOpen={showOAuthModal}
|
||||
reauthConnection={reauthConnection}
|
||||
providerInfo={{ ...providerInfo, id: providerId }}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
/>
|
||||
) : providerId === "cursor" ? (
|
||||
<CursorAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
reauthConnection={reauthConnection}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
/>
|
||||
) : providerId === "trae" ? (
|
||||
<TraeAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
reauthConnection={reauthConnection}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
/>
|
||||
) : (
|
||||
<OAuthModal
|
||||
isOpen={showOAuthModal}
|
||||
reauthConnection={reauthConnection}
|
||||
provider={providerId}
|
||||
providerInfo={providerInfo}
|
||||
onSuccess={handleOAuthSuccess}
|
||||
onClose={() => setShowOAuthModal(false)}
|
||||
/>
|
||||
))}
|
||||
{providerId === "siliconflow" && (
|
||||
<SiliconFlowEndpointModal
|
||||
isOpen={showSiliconFlowEndpointModal}
|
||||
onSelect={(baseUrl) => {
|
||||
setSiliconFlowInitialBaseUrl(baseUrl);
|
||||
setShowSiliconFlowEndpointModal(false);
|
||||
setShowAddApiKeyModal(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowSiliconFlowEndpointModal(false);
|
||||
setSiliconFlowInitialBaseUrl(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && (
|
||||
<AddApiKeyModal
|
||||
isOpen={showAddApiKeyModal}
|
||||
provider={providerId}
|
||||
providerName={providerInfo.name}
|
||||
initialBaseUrl={siliconFlowInitialBaseUrl}
|
||||
isCompatible={isCompatible}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
isCcCompatible={isCcCompatible}
|
||||
isCommandCode={isCommandCode}
|
||||
commandCodeAuthState={commandCodeAuthState}
|
||||
onStartCommandCodeAuth={handleStartCommandCodeAuth}
|
||||
onSave={handleSaveApiKey}
|
||||
onClose={handleCloseAddApiKeyModal}
|
||||
/>
|
||||
)}
|
||||
<ConfirmModal
|
||||
isOpen={batchDeleteConfirmOpen}
|
||||
onClose={() => setBatchDeleteConfirmOpen(false)}
|
||||
onConfirm={handleBatchDeleteConfirm}
|
||||
title={t("batchDeleteConfirmTitle", "Delete connections")}
|
||||
message={t("batchDeleteConfirm", { count: selectedIds.size })}
|
||||
confirmText={t("batchDeleteConfirmButton", "Delete")}
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
loading={batchDeleting}
|
||||
/>
|
||||
{providerId === "codex" && applyCodexModalConnectionId && (
|
||||
<ApplyCodexAuthModal
|
||||
key={applyCodexModalConnectionId}
|
||||
connectionId={applyCodexModalConnectionId}
|
||||
inProgress={!!applyingCodexAuthId}
|
||||
onConfirm={handleApplyCodexAuthLocal}
|
||||
onClose={() => setApplyCodexModalConnectionId(null)}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && (
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
onSave={handleUpdateConnection}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{!isUpstreamProxyProvider && isCompatible && (
|
||||
<EditCompatibleNodeModal
|
||||
isOpen={showEditNodeModal}
|
||||
node={providerNode}
|
||||
onSave={handleUpdateNode}
|
||||
onClose={() => setShowEditNodeModal(false)}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
isCcCompatible={isCcCompatible}
|
||||
/>
|
||||
)}
|
||||
<CodexCliGuideModal isOpen={codexCliGuideOpen} onClose={() => setCodexCliGuideOpen(false)} />
|
||||
{providerId === "codex" && importCodexModalOpen && (
|
||||
<ImportCodexAuthModal
|
||||
key="import-codex-modal"
|
||||
onClose={() => setImportCodexModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportCodexModalOpen(false);
|
||||
void fetchConnections();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providerId === "codex" && externalLinkModalOpen && (
|
||||
<ExternalLinkModal
|
||||
isOpen={externalLinkModalOpen}
|
||||
onClose={() => setExternalLinkModalOpen(false)}
|
||||
loading={externalLinkLoading}
|
||||
error={externalLinkError}
|
||||
url={externalLinkUrl}
|
||||
copied={externalLinkCopied}
|
||||
onCopy={externalLinkCopy}
|
||||
/>
|
||||
)}
|
||||
{providerId === "claude" && applyClaudeModalConnectionId && (
|
||||
<ApplyClaudeAuthModal
|
||||
key={applyClaudeModalConnectionId}
|
||||
connectionId={applyClaudeModalConnectionId}
|
||||
inProgress={!!applyingClaudeAuthId}
|
||||
onConfirm={handleApplyClaudeAuthLocal}
|
||||
onClose={() => setApplyClaudeModalConnectionId(null)}
|
||||
/>
|
||||
)}
|
||||
{providerId === "claude" && importClaudeModalOpen && (
|
||||
<ImportClaudeAuthModal
|
||||
key="import-claude-modal"
|
||||
onClose={() => setImportClaudeModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportClaudeModalOpen(false);
|
||||
void fetchConnections();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providerId === "gemini-cli" && applyGeminiModalConnectionId && (
|
||||
<ApplyGeminiAuthModal
|
||||
key={applyGeminiModalConnectionId}
|
||||
connectionId={applyGeminiModalConnectionId}
|
||||
inProgress={!!applyingGeminiAuthId}
|
||||
onConfirm={handleApplyGeminiAuthLocal}
|
||||
onClose={() => setApplyGeminiModalConnectionId(null)}
|
||||
/>
|
||||
)}
|
||||
{providerId === "gemini-cli" && importGeminiModalOpen && (
|
||||
<ImportGeminiAuthModal
|
||||
key="import-gemini-modal"
|
||||
onClose={() => setImportGeminiModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setImportGeminiModalOpen(false);
|
||||
void fetchConnections();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<BatchTestResultsModal
|
||||
batchTestResults={batchTestResults}
|
||||
providerInfo={providerInfo}
|
||||
providerId={providerId}
|
||||
emailsVisible={emailsVisible}
|
||||
onClose={() => setBatchTestResults(null)}
|
||||
t={t}
|
||||
/>
|
||||
{proxyTarget && (
|
||||
<ProxyConfigModal
|
||||
isOpen={!!proxyTarget}
|
||||
onClose={() => setProxyTarget(null)}
|
||||
level={proxyTarget.level}
|
||||
levelId={proxyTarget.id}
|
||||
levelLabel={proxyTarget.label}
|
||||
onSaved={() => {
|
||||
void fetchProxyConfig();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ImportProgressModal
|
||||
importProgress={importProgress}
|
||||
isOpen={showImportModal}
|
||||
onClose={() => {
|
||||
if (importProgress.phase === "done" || importProgress.phase === "error") {
|
||||
setShowImportModal(false);
|
||||
}
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
{providerId === "adapta-web" && (
|
||||
<AdaptaTutorialModal
|
||||
isOpen={showTutorialModal}
|
||||
onClose={() => setShowTutorialModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderModelsSection — Issue #3501 Phase 1m
|
||||
*
|
||||
* Extracted from the renderModelsSection() inline function in
|
||||
* ProviderDetailPageClient. Receives all model/compat state + handlers
|
||||
* as props (from useModelImportHandlers, useModelVisibilityHandlers,
|
||||
* useModelCompatState, useProviderModels).
|
||||
*
|
||||
* Cycle-safe: no import from ProviderDetailPageClient.
|
||||
*/
|
||||
|
||||
import { Button } from "@/shared/components";
|
||||
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import ModelRow, { ModelVisibilityToolbar } from "./ModelRow";
|
||||
import PassthroughModelsSection from "./PassthroughModelsSection";
|
||||
import CompatibleModelsSection from "./CompatibleModelsSection";
|
||||
import type { ModelCompatSavePatch } from "../hooks/useModelVisibilityHandlers";
|
||||
|
||||
export interface ProviderModelsSectionProps {
|
||||
// Provider identity
|
||||
providerId: string;
|
||||
providerAlias: string;
|
||||
providerStorageAlias: string;
|
||||
providerDisplayAlias: string;
|
||||
providerInfo: {
|
||||
name?: string;
|
||||
passthroughModels?: boolean;
|
||||
} | null;
|
||||
|
||||
// Provider-type flags
|
||||
isCcCompatible: boolean;
|
||||
isAnthropicCompatible: boolean;
|
||||
isAnthropicProtocolCompatible: boolean;
|
||||
isManagedAvailableModelsProvider: boolean;
|
||||
compatibleSupportsModelImport: boolean;
|
||||
|
||||
// Models data
|
||||
models: Array<{ id: string; name?: string; source?: string }>;
|
||||
modelMeta: { customModels: any[]; modelCompatOverrides?: any[] };
|
||||
modelAliases: Record<string, string>;
|
||||
syncedAvailableModels: any[];
|
||||
compatibleFallbackModels: any[];
|
||||
|
||||
// Clipboard
|
||||
copied: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
|
||||
// Model alias handlers
|
||||
onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
|
||||
onDeleteAlias: (alias: string) => Promise<void>;
|
||||
fetchProviderModelMeta: () => Promise<void>;
|
||||
|
||||
// Connections
|
||||
connections: any[];
|
||||
selectedConnection: any;
|
||||
|
||||
// Phase 1k: import handlers
|
||||
canImportModels: boolean;
|
||||
importingModels: boolean;
|
||||
handleImportModels: () => Promise<void>;
|
||||
isAutoSyncEnabled: boolean;
|
||||
togglingAutoSync: boolean;
|
||||
handleToggleAutoSync: () => Promise<void>;
|
||||
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
|
||||
|
||||
// Phase 1l: visibility handlers
|
||||
compatSavingModelId: string | null;
|
||||
togglingModelId: string | null;
|
||||
bulkVisibilityAction: "select" | "deselect" | null;
|
||||
clearingModels: boolean;
|
||||
modelFilter: string;
|
||||
testingModelId: string | null;
|
||||
modelTestStatus: Record<string, "ok" | "error">;
|
||||
testingAll: boolean;
|
||||
testProgress: { done: number; total: number } | null;
|
||||
autoHideFailed: boolean;
|
||||
visibilityFilter: "all" | "visible" | "hidden";
|
||||
providerAliasEntries: [string, string][];
|
||||
setModelFilter: (v: string) => void;
|
||||
setAutoHideFailed: (v: boolean) => void;
|
||||
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
|
||||
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
|
||||
handleToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelId: string,
|
||||
hidden: boolean
|
||||
) => Promise<void>;
|
||||
handleBulkToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelIds: string[],
|
||||
hidden: boolean
|
||||
) => Promise<void>;
|
||||
handleClearAllModels: () => Promise<void>;
|
||||
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
|
||||
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
|
||||
|
||||
// Compat state (from useModelCompatState)
|
||||
effectiveModelNormalize: (modelId: string, protocol?: string) => boolean;
|
||||
effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean;
|
||||
effectiveModelHidden: (modelId: string) => boolean;
|
||||
getUpstreamHeadersRecordForModel: (modelId: string, protocol: string) => Record<string, string>;
|
||||
|
||||
// Translation
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function ProviderModelsSection({
|
||||
providerId,
|
||||
providerAlias,
|
||||
providerStorageAlias,
|
||||
providerDisplayAlias,
|
||||
providerInfo,
|
||||
isCcCompatible,
|
||||
isAnthropicCompatible,
|
||||
isAnthropicProtocolCompatible,
|
||||
isManagedAvailableModelsProvider,
|
||||
compatibleSupportsModelImport,
|
||||
models,
|
||||
modelMeta,
|
||||
modelAliases,
|
||||
syncedAvailableModels,
|
||||
compatibleFallbackModels,
|
||||
copied,
|
||||
onCopy,
|
||||
onSetAlias,
|
||||
onDeleteAlias,
|
||||
fetchProviderModelMeta,
|
||||
connections,
|
||||
selectedConnection,
|
||||
canImportModels,
|
||||
importingModels,
|
||||
handleImportModels,
|
||||
isAutoSyncEnabled,
|
||||
togglingAutoSync,
|
||||
handleToggleAutoSync,
|
||||
handleCompatibleImportWithProgress,
|
||||
compatSavingModelId,
|
||||
togglingModelId,
|
||||
bulkVisibilityAction,
|
||||
clearingModels,
|
||||
modelFilter,
|
||||
testingModelId,
|
||||
modelTestStatus,
|
||||
testingAll,
|
||||
testProgress,
|
||||
autoHideFailed,
|
||||
visibilityFilter,
|
||||
providerAliasEntries,
|
||||
setModelFilter,
|
||||
setAutoHideFailed,
|
||||
setVisibilityFilter,
|
||||
saveModelCompatFlags,
|
||||
handleToggleModelHidden,
|
||||
handleBulkToggleModelHidden,
|
||||
handleClearAllModels,
|
||||
onTestModel,
|
||||
handleTestAll,
|
||||
effectiveModelNormalize,
|
||||
effectiveModelPreserveDeveloper,
|
||||
effectiveModelHidden,
|
||||
getUpstreamHeadersRecordForModel,
|
||||
t,
|
||||
}: ProviderModelsSectionProps) {
|
||||
const autoSyncToggle = compatibleSupportsModelImport && canImportModels && (
|
||||
<button
|
||||
onClick={handleToggleAutoSync}
|
||||
disabled={togglingAutoSync}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-border bg-transparent cursor-pointer text-[12px] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("autoSyncTooltip")}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-outlined text-[16px]"
|
||||
style={{ color: isAutoSyncEnabled ? "#22c55e" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{isAutoSyncEnabled ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
<span className="text-text-main">{t("autoSync")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const clearAllButton = (modelMeta.customModels.length > 0 ||
|
||||
providerAliasEntries.length > 0) && (
|
||||
<button
|
||||
onClick={handleClearAllModels}
|
||||
disabled={clearingModels}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-red-300 dark:border-red-800 bg-transparent cursor-pointer text-[12px] text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("clearAllModels")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">delete_sweep</span>
|
||||
<span>{t("clearAllModels")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isManagedAvailableModelsProvider) {
|
||||
const description =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterAnyModelHint")
|
||||
: isCcCompatible
|
||||
? t("ccCompatibleModelsDescription")
|
||||
: t("compatibleModelsDescription", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
});
|
||||
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
|
||||
const inputPlaceholder =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterModelPlaceholder")
|
||||
: isCcCompatible
|
||||
? "claude-sonnet-4-6"
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{autoSyncToggle}
|
||||
{clearAllButton}
|
||||
</div>
|
||||
<CompatibleModelsSection
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
modelAliases={modelAliases}
|
||||
availableModels={syncedAvailableModels}
|
||||
customModels={modelMeta.customModels}
|
||||
fallbackModels={compatibleFallbackModels}
|
||||
description={description}
|
||||
inputLabel={inputLabel}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onSetAlias={onSetAlias}
|
||||
onDeleteAlias={onDeleteAlias}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicProtocolCompatible}
|
||||
onImportWithProgress={handleCompatibleImportWithProgress}
|
||||
t={t}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatSavingModelId={compatSavingModelId}
|
||||
onModelsChanged={fetchProviderModelMeta}
|
||||
allowImport={compatibleSupportsModelImport}
|
||||
isModelHidden={effectiveModelHidden}
|
||||
onToggleHidden={(modelId, hidden) =>
|
||||
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
|
||||
}
|
||||
onBulkToggleHidden={(modelIds, hidden) =>
|
||||
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
|
||||
}
|
||||
bulkTogglePending={bulkVisibilityAction !== null}
|
||||
togglingModelId={togglingModelId}
|
||||
onTestModel={onTestModel}
|
||||
modelTestStatus={modelTestStatus}
|
||||
testingModelId={testingModelId}
|
||||
onTestAll={handleTestAll}
|
||||
testingAll={testingAll}
|
||||
testProgress={testProgress}
|
||||
autoHideFailed={autoHideFailed}
|
||||
onAutoHideFailedChange={setAutoHideFailed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (providerInfo?.passthroughModels) {
|
||||
const passthroughDescription =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterAnyModelHint")
|
||||
: providerId === "bedrock"
|
||||
? t("bedrockModelsDescription")
|
||||
: t("passthroughModelsDescription", { provider: providerInfo?.name || providerId });
|
||||
const passthroughInputLabel =
|
||||
providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
|
||||
const passthroughInputPlaceholder =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterModelPlaceholder")
|
||||
: providerId === "bedrock"
|
||||
? t("bedrockModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{clearAllButton}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
availableModels={syncedAvailableModels}
|
||||
customModels={modelMeta.customModels}
|
||||
description={passthroughDescription}
|
||||
inputLabel={passthroughInputLabel}
|
||||
inputPlaceholder={passthroughInputPlaceholder}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onSetAlias={onSetAlias}
|
||||
onDeleteAlias={onDeleteAlias}
|
||||
t={t}
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecordForModel}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatSavingModelId={compatSavingModelId}
|
||||
isModelHidden={effectiveModelHidden}
|
||||
onToggleHidden={(modelId, hidden) =>
|
||||
handleToggleModelHidden(providerStorageAlias, modelId, hidden)
|
||||
}
|
||||
onBulkToggleHidden={(modelIds, hidden) =>
|
||||
handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden)
|
||||
}
|
||||
bulkTogglePending={bulkVisibilityAction !== null}
|
||||
togglingModelId={togglingModelId}
|
||||
onTestModel={onTestModel}
|
||||
modelTestStatus={modelTestStatus}
|
||||
testingModelId={testingModelId}
|
||||
providerId={providerId}
|
||||
connectionId={selectedConnection?.id ?? ""}
|
||||
autoHideFailed={autoHideFailed}
|
||||
onAutoHideFailedChange={setAutoHideFailed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const importButton = (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="download"
|
||||
onClick={handleImportModels}
|
||||
disabled={!canImportModels || importingModels}
|
||||
>
|
||||
{importingModels ? t("importingModels") : t("importFromModels")}
|
||||
</Button>
|
||||
{autoSyncToggle}
|
||||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{importButton}
|
||||
<p className="text-sm text-text-muted">{t("noModelsConfigured")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modelsWithVisibility = models.map((model) => ({
|
||||
...model,
|
||||
isHidden: effectiveModelHidden(model.id),
|
||||
}));
|
||||
const filteredModels = modelsWithVisibility.filter((model) => {
|
||||
const matchesQuery = matchesModelCatalogQuery(modelFilter, {
|
||||
modelId: model.id,
|
||||
modelName: model.name,
|
||||
source: model.source,
|
||||
});
|
||||
const matchesVisibility =
|
||||
visibilityFilter === "all"
|
||||
? true
|
||||
: visibilityFilter === "visible"
|
||||
? !model.isHidden
|
||||
: model.isHidden;
|
||||
return matchesQuery && matchesVisibility;
|
||||
});
|
||||
const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length;
|
||||
const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length;
|
||||
const visibleFilteredCount = filteredModels.length - hiddenFilteredCount;
|
||||
const testAllTargets = filteredModels
|
||||
.filter((m) => !m.isHidden)
|
||||
.map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{importButton}
|
||||
{modelsWithVisibility.length > 0 && (
|
||||
<ModelVisibilityToolbar
|
||||
t={t}
|
||||
filterValue={modelFilter}
|
||||
onFilterChange={setModelFilter}
|
||||
activeCount={activeCount}
|
||||
totalCount={modelsWithVisibility.length}
|
||||
onSelectAll={() =>
|
||||
handleBulkToggleModelHidden(
|
||||
providerId,
|
||||
filteredModels.map((model) => model.id),
|
||||
false
|
||||
)
|
||||
}
|
||||
onDeselectAll={() =>
|
||||
handleBulkToggleModelHidden(
|
||||
providerId,
|
||||
filteredModels.map((model) => model.id),
|
||||
true
|
||||
)
|
||||
}
|
||||
selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null}
|
||||
deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null}
|
||||
onTestAll={() => handleTestAll(testAllTargets)}
|
||||
testingAll={testingAll}
|
||||
testProgress={testProgress}
|
||||
visibilityFilter={visibilityFilter}
|
||||
onVisibilityFilterChange={setVisibilityFilter}
|
||||
autoHideFailed={autoHideFailed}
|
||||
onAutoHideFailedChange={setAutoHideFailed}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{filteredModels.map((model) => {
|
||||
return (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
model={model}
|
||||
fullModel={`${providerDisplayAlias}/${model.id}`}
|
||||
provider={providerId}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
t={t}
|
||||
showDeveloperToggle
|
||||
effectiveModelNormalize={effectiveModelNormalize}
|
||||
effectiveModelPreserveDeveloper={effectiveModelPreserveDeveloper}
|
||||
getUpstreamHeadersRecord={(p) => getUpstreamHeadersRecordForModel(model.id, p)}
|
||||
saveModelCompatFlags={saveModelCompatFlags}
|
||||
compatDisabled={compatSavingModelId === model.id}
|
||||
onToggleHidden={(modelId, hidden) =>
|
||||
handleToggleModelHidden(providerId, modelId, hidden)
|
||||
}
|
||||
togglingHidden={togglingModelId === model.id}
|
||||
onTestModel={onTestModel}
|
||||
testStatus={modelTestStatus[model.id] || null}
|
||||
testingModel={testingModelId === model.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && modelFilter && (
|
||||
<p className="text-sm text-text-muted py-2">
|
||||
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
|
||||
filter: modelFilter,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.1 extraction — Issue #3501
|
||||
import Link from "next/link";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
|
||||
import { getHeaderIconProviderId } from "../providerPageHelpers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface ProviderInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
website?: string;
|
||||
color: string;
|
||||
apiType?: string;
|
||||
}
|
||||
|
||||
interface ProviderPageHeaderProps {
|
||||
providerId: string;
|
||||
providerInfo: ProviderInfo;
|
||||
connectionsCount: number;
|
||||
isOpenAICompatible: boolean;
|
||||
isAnthropicProtocolCompatible: boolean;
|
||||
onOpenTutorial: () => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function ProviderPageHeader({
|
||||
providerId,
|
||||
providerInfo,
|
||||
connectionsCount,
|
||||
isOpenAICompatible,
|
||||
isAnthropicProtocolCompatible,
|
||||
onOpenTutorial,
|
||||
t,
|
||||
}: ProviderPageHeaderProps) {
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/providers"
|
||||
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg">arrow_back</span>
|
||||
{t("backToProviders")}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: `${providerInfo.color}15` }}
|
||||
>
|
||||
<ProviderIcon
|
||||
providerId={getHeaderIconProviderId(
|
||||
isOpenAICompatible,
|
||||
isAnthropicProtocolCompatible,
|
||||
providerInfo.id,
|
||||
providerInfo.apiType
|
||||
)}
|
||||
size={48}
|
||||
type="color"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{providerInfo.website ? (
|
||||
<a
|
||||
href={providerInfo.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-3xl font-semibold tracking-tight hover:underline inline-flex items-center gap-2"
|
||||
style={{ color: providerInfo.color }}
|
||||
>
|
||||
{providerInfo.name}
|
||||
<span className="material-symbols-outlined text-lg opacity-60">open_in_new</span>
|
||||
</a>
|
||||
) : (
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-text-muted">
|
||||
{t("connectionCountLabel", { count: connectionsCount })}
|
||||
</p>
|
||||
<EmailPrivacyToggle size="md" />
|
||||
{providerId === "adapta-web" && (
|
||||
<button
|
||||
onClick={onOpenTutorial}
|
||||
className="text-sm font-medium underline underline-offset-2 opacity-70 hover:opacity-100 transition-opacity"
|
||||
style={{ color: providerInfo.color }}
|
||||
>
|
||||
Tutorial
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1g extraction — Issue #3501
|
||||
// Renders a playground section on the individual provider page.
|
||||
// Shows ServiceKindTabs if the provider declares multiple kinds; falls back to
|
||||
// a single-kind panel or the LlmChatCard for standard LLM providers.
|
||||
|
||||
import { useState } from "react";
|
||||
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
||||
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
|
||||
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
|
||||
import { ImageExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/ImageExampleCard";
|
||||
import { TtsExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/TtsExampleCard";
|
||||
import { SttExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/SttExampleCard";
|
||||
import { WebSearchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebSearchExampleCard";
|
||||
import { WebFetchExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/WebFetchExampleCard";
|
||||
import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/VideoExampleCard";
|
||||
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
|
||||
import type { ServiceKind } from "@/shared/constants/providers";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
|
||||
"embedding",
|
||||
"image",
|
||||
"tts",
|
||||
"stt",
|
||||
"webSearch",
|
||||
"webFetch",
|
||||
"video",
|
||||
"music",
|
||||
];
|
||||
|
||||
export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Element | null {
|
||||
switch (kind) {
|
||||
case "llm":
|
||||
return <LlmChatCard providerId={providerId} />;
|
||||
case "embedding":
|
||||
return <EmbeddingExampleCard providerId={providerId} />;
|
||||
case "image":
|
||||
return <ImageExampleCard providerId={providerId} />;
|
||||
case "tts":
|
||||
return <TtsExampleCard providerId={providerId} />;
|
||||
case "stt":
|
||||
return <SttExampleCard providerId={providerId} />;
|
||||
case "webSearch":
|
||||
return <WebSearchExampleCard providerId={providerId} />;
|
||||
case "webFetch":
|
||||
return <WebFetchExampleCard providerId={providerId} />;
|
||||
case "video":
|
||||
return <VideoExampleCard providerId={providerId} />;
|
||||
case "music":
|
||||
return <MusicExampleCard providerId={providerId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
|
||||
// Resolve serviceKinds from AI_PROVIDERS.
|
||||
// For providers without explicit serviceKinds (most LLM providers), we infer
|
||||
// "llm" as the default.
|
||||
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
|
||||
| (Record<string, unknown> & { serviceKinds?: string[] })
|
||||
| undefined;
|
||||
|
||||
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
|
||||
|
||||
const ALL_VALID_KINDS = [
|
||||
"llm",
|
||||
"embedding",
|
||||
"image",
|
||||
"imageToText",
|
||||
"tts",
|
||||
"stt",
|
||||
"webSearch",
|
||||
"webFetch",
|
||||
"video",
|
||||
"music",
|
||||
] as const;
|
||||
|
||||
const kinds: ServiceKind[] =
|
||||
rawKinds.length > 0
|
||||
? rawKinds.filter((k): k is ServiceKind => (ALL_VALID_KINDS as readonly string[]).includes(k))
|
||||
: ["llm"];
|
||||
|
||||
// Filter out kinds that have no playground implementation yet
|
||||
const playgroundableKinds = kinds.filter((k) => k !== "imageToText");
|
||||
|
||||
// useState must be called unconditionally (Rules of Hooks)
|
||||
const [activeKind, setActiveKind] = useState<ServiceKind>(playgroundableKinds[0] ?? "llm");
|
||||
|
||||
if (playgroundableKinds.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">Playground</h2>
|
||||
<ServiceKindTabs
|
||||
kinds={playgroundableKinds}
|
||||
activeKind={activeKind}
|
||||
onSelect={setActiveKind}
|
||||
/>
|
||||
{renderKindPanel(activeKind, providerId)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.7 extraction — Issue #3501
|
||||
import { Card } from "@/shared/components";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface SearchProviderCardProps {
|
||||
providerId: string;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function SearchProviderCard({ providerId, t }: SearchProviderCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("searchProvider")}</h2>
|
||||
<p className="text-sm text-text-muted">{t("searchProviderDesc")}</p>
|
||||
{providerId === "perplexity-search" && (
|
||||
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<span className="material-symbols-outlined text-sm text-blue-400">link</span>
|
||||
<p className="text-xs text-blue-300">{t("perplexitySearchSharedKeyInfo")}</p>
|
||||
</div>
|
||||
)}
|
||||
{providerId === "google-pse-search" && (
|
||||
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<span className="material-symbols-outlined text-sm text-amber-300">tune</span>
|
||||
<p className="text-xs text-amber-200">{t("googlePseInfo")}</p>
|
||||
</div>
|
||||
)}
|
||||
{providerId === "searxng-search" && (
|
||||
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20">
|
||||
<span className="material-symbols-outlined text-sm text-emerald-300">dns</span>
|
||||
<p className="text-xs text-emerald-200">{t("searxngInfo")}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1t.7 extraction — Issue #3501
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
import { providerText } from "../providerPageHelpers";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface UpstreamProxyCardProps {
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export default function UpstreamProxyCard({ t }: UpstreamProxyCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{providerText(t, "upstreamProxyManagedTitle", "Managed via Upstream Proxy Settings")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{providerText(
|
||||
t,
|
||||
"upstreamProxyManagedDescription",
|
||||
"CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/dashboard/cli-code"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">terminal</span>
|
||||
{t("openCliTools")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/settings"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-text-main hover:border-primary/40 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">settings</span>
|
||||
{t("openSettings")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button, Card } from "@/shared/components";
|
||||
|
||||
type ZedImportCardProps = {
|
||||
fetchConnections: () => Promise<void>;
|
||||
notify: { success: (msg: string) => void; error: (msg: string) => void; info: (msg: string) => void };
|
||||
};
|
||||
|
||||
export default function ZedImportCard({ fetchConnections, notify }: ZedImportCardProps) {
|
||||
const [importingZed, setImportingZed] = useState(false);
|
||||
const [showZedManual, setShowZedManual] = useState(false);
|
||||
const [zedManualProvider, setZedManualProvider] = useState("openai");
|
||||
const [zedManualToken, setZedManualToken] = useState("");
|
||||
const [importingZedManual, setImportingZedManual] = useState(false);
|
||||
|
||||
const handleZedImport = useCallback(async () => {
|
||||
if (importingZed) return;
|
||||
setImportingZed(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers/zed/import", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
if (data.zedDockerEnvironment) {
|
||||
setShowZedManual(true);
|
||||
}
|
||||
notify.error(data.error || "Zed import failed");
|
||||
} else if (!data.count) {
|
||||
const found = data.credentials?.length ?? 0;
|
||||
if (found === 0) {
|
||||
notify.info("No Zed credentials found in keychain");
|
||||
} else {
|
||||
notify.info(
|
||||
`Found ${found} keychain credential(s), but none matched supported providers`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notify.success(
|
||||
`Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)`
|
||||
);
|
||||
await fetchConnections();
|
||||
}
|
||||
} catch (e: any) {
|
||||
notify.error(e?.message || "Zed import failed");
|
||||
} finally {
|
||||
setImportingZed(false);
|
||||
}
|
||||
}, [importingZed, notify, fetchConnections]);
|
||||
|
||||
const handleZedManualImport = useCallback(async () => {
|
||||
if (importingZedManual || !zedManualToken.trim()) return;
|
||||
setImportingZedManual(true);
|
||||
try {
|
||||
const res = await fetch("/api/providers/zed/manual-import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
notify.error(data.error?.message ?? data.error ?? "Manual import failed");
|
||||
} else {
|
||||
notify.success(`Imported ${zedManualProvider} token from Zed`);
|
||||
setZedManualToken("");
|
||||
await fetchConnections();
|
||||
}
|
||||
} catch (e: any) {
|
||||
notify.error(e?.message || "Manual import failed");
|
||||
} finally {
|
||||
setImportingZedManual(false);
|
||||
}
|
||||
}, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">download</span>
|
||||
Import from Zed Keychain
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that
|
||||
Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE
|
||||
installed on this machine.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={importingZed ? "sync" : "download"}
|
||||
onClick={handleZedImport}
|
||||
disabled={importingZed}
|
||||
>
|
||||
{importingZed ? "Importing…" : "Import from Zed"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
onClick={() => setShowZedManual((v) => !v)}
|
||||
>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">edit</span>
|
||||
Manual Token Import
|
||||
</h2>
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{showZedManual ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{showZedManual && (
|
||||
<div className="flex flex-col gap-3 mt-1">
|
||||
<p className="text-sm text-text-muted">
|
||||
Use this when OmniRoute runs in Docker or the keychain is unavailable. Paste the
|
||||
API key that Zed stored under{" "}
|
||||
<code className="font-mono text-xs">~/.config/zed/settings.json</code> or copy
|
||||
it from the Zed AI settings panel.
|
||||
</p>
|
||||
<div className="flex gap-2 flex-col sm:flex-row">
|
||||
<select
|
||||
className="input input-sm"
|
||||
value={zedManualProvider}
|
||||
onChange={(e) => setZedManualProvider(e.target.value)}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="mistral">Mistral</option>
|
||||
<option value="xai">xAI</option>
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
</select>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-sm flex-1"
|
||||
placeholder="Paste API key…"
|
||||
value={zedManualToken}
|
||||
onChange={(e) => setZedManualToken(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon={importingZedManual ? "sync" : "upload"}
|
||||
onClick={handleZedManualImport}
|
||||
disabled={importingZedManual || !zedManualToken.trim()}
|
||||
>
|
||||
{importingZedManual ? "Saving…" : "Import"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useApiKeySave — Issue #3501 Phase 1s
|
||||
*
|
||||
* Owns the handleSaveApiKey async function that was previously inline in
|
||||
* ProviderDetailPageClient. Extracts it into a custom hook so the client
|
||||
* can simply destructure the callback.
|
||||
*
|
||||
* Cycle-safe: no import from ProviderDetailPageClient.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type React from "react";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import type { ImportProgress } from "./useModelImportHandlers";
|
||||
|
||||
type UseApiKeySaveParams = {
|
||||
providerId: string;
|
||||
fetchConnections: () => Promise<void>;
|
||||
fetchProviderModelMeta: () => Promise<void>;
|
||||
setImportProgress: React.Dispatch<React.SetStateAction<ImportProgress>>;
|
||||
setShowImportModal: (open: boolean) => void;
|
||||
setShowAddApiKeyModal: (open: boolean) => void;
|
||||
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
|
||||
notify: { success: (msg: string) => void; error: (msg: string) => void; info?: (msg: string) => void };
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export function useApiKeySave({
|
||||
providerId,
|
||||
fetchConnections,
|
||||
fetchProviderModelMeta,
|
||||
setImportProgress,
|
||||
setShowImportModal,
|
||||
setShowAddApiKeyModal,
|
||||
setSiliconFlowInitialBaseUrl,
|
||||
t,
|
||||
}: UseApiKeySaveParams) {
|
||||
const handleSaveApiKey = useCallback(
|
||||
async (formData: Record<string, unknown>) => {
|
||||
try {
|
||||
const res = await fetch("/api/providers", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider: providerId, ...formData }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const connectionData = await res.json();
|
||||
const newConnection = connectionData?.connection;
|
||||
await fetchConnections();
|
||||
setShowAddApiKeyModal(false);
|
||||
setSiliconFlowInitialBaseUrl(undefined);
|
||||
|
||||
// Universal: sync models from the provider endpoint on every new connection
|
||||
// (was previously Gemini-only). Do NOT re-introduce a providerId guard here.
|
||||
if (newConnection?.id) {
|
||||
setShowImportModal(true);
|
||||
setImportProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "fetching",
|
||||
status: t("fetchingModels"),
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang
|
||||
});
|
||||
const syncData = await syncRes.json();
|
||||
|
||||
if (!syncRes.ok || syncData.error) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: t("failedFetchModels"),
|
||||
error: syncData.error?.message || syncData.error || t("failedImportModels"),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
const syncedCount = syncData.syncedModels || 0;
|
||||
const availableCount =
|
||||
typeof syncData.availableModelsCount === "number"
|
||||
? syncData.availableModelsCount
|
||||
: Array.isArray(syncData.models)
|
||||
? syncData.models.length
|
||||
: syncedCount;
|
||||
const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || [];
|
||||
const logs: string[] = [];
|
||||
if (syncedModelList.length > 0) {
|
||||
logs.push(`✓ ${availableCount} models available`);
|
||||
logs.push("");
|
||||
for (const m of syncedModelList) {
|
||||
logs.push(` ${m.name || m.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status: t("modelsImported", { count: availableCount }),
|
||||
total: availableCount,
|
||||
current: availableCount,
|
||||
importedCount: availableCount,
|
||||
logs,
|
||||
}));
|
||||
|
||||
await fetchProviderModelMeta();
|
||||
} catch (syncError) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: t("failedFetchModels"),
|
||||
error: String(syncError),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const errorMsg = data.error?.message || data.error || t("failedSaveConnection");
|
||||
return errorMsg;
|
||||
} catch (error) {
|
||||
console.log("Error saving connection:", error);
|
||||
return t("failedSaveConnectionRetry");
|
||||
}
|
||||
},
|
||||
[
|
||||
providerId,
|
||||
fetchConnections,
|
||||
fetchProviderModelMeta,
|
||||
setImportProgress,
|
||||
setShowImportModal,
|
||||
setShowAddApiKeyModal,
|
||||
setSiliconFlowInitialBaseUrl,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
return { handleSaveApiKey };
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
// Phase 1j extraction — Issue #3501
|
||||
// Manages Codex / Claude / Gemini auth-file apply+export state and handlers.
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Notify = { success: (msg: string) => void; error: (msg: string) => void };
|
||||
|
||||
type UseAuthFileHandlersParams = {
|
||||
parseApiErrorMessage: (res: Response, fallback: string) => Promise<string>;
|
||||
getAttachmentFilename: (res: Response, fallback: string) => string;
|
||||
notify: Notify;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useAuthFileHandlers({
|
||||
parseApiErrorMessage,
|
||||
getAttachmentFilename,
|
||||
notify,
|
||||
t,
|
||||
}: UseAuthFileHandlersParams) {
|
||||
// ── Codex ──────────────────────────────────────────────────────────────────
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
|
||||
// ── Claude ─────────────────────────────────────────────────────────────────
|
||||
const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState<string | null>(null);
|
||||
const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState<string | null>(null);
|
||||
|
||||
// ── Gemini ─────────────────────────────────────────────────────────────────
|
||||
const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState<string | null>(null);
|
||||
const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState<string | null>(null);
|
||||
|
||||
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleApplyCodexAuthLocal = async (connectionId: string) => {
|
||||
if (applyingCodexAuthId) return;
|
||||
setApplyingCodexAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("codexAuthAppliedLocal")
|
||||
? t("codexAuthAppliedLocal")
|
||||
: "Codex auth.json applied locally";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("codexAuthApplyFailed")
|
||||
? t("codexAuthApplyFailed")
|
||||
: "Failed to apply Codex auth.json locally";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
setApplyCodexModalConnectionId(null);
|
||||
} catch (error) {
|
||||
console.error("Error applying Codex auth locally:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setApplyingCodexAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCodexAuthFile = async (connectionId: string) => {
|
||||
if (exportingCodexAuthId) return;
|
||||
setExportingCodexAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("codexAuthExported")
|
||||
? t("codexAuthExported")
|
||||
: "Codex auth.json exported";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("codexAuthExportFailed")
|
||||
? t("codexAuthExportFailed")
|
||||
: "Failed to export Codex auth.json";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename = getAttachmentFilename(res, "codex-auth.json");
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
} catch (error) {
|
||||
console.error("Error exporting Codex auth file:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setExportingCodexAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyClaudeAuthLocal = async (connectionId: string) => {
|
||||
if (applyingClaudeAuthId) return;
|
||||
setApplyingClaudeAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("claudeAuthAppliedLocal")
|
||||
? t("claudeAuthAppliedLocal")
|
||||
: "Claude auth applied locally";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("claudeAuthApplyFailed")
|
||||
? t("claudeAuthApplyFailed")
|
||||
: "Failed to apply Claude auth locally";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
setApplyClaudeModalConnectionId(null);
|
||||
} catch (error) {
|
||||
console.error("Error applying Claude auth locally:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setApplyingClaudeAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportClaudeAuthFile = async (connectionId: string) => {
|
||||
if (exportingClaudeAuthId) return;
|
||||
setExportingClaudeAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("claudeAuthExported")
|
||||
? t("claudeAuthExported")
|
||||
: "Claude auth file exported";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("claudeAuthExportFailed")
|
||||
? t("claudeAuthExportFailed")
|
||||
: "Failed to export Claude auth file";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename = getAttachmentFilename(res, "claude-auth.json");
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
} catch (error) {
|
||||
console.error("Error exporting Claude auth file:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setExportingClaudeAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGeminiAuthLocal = async (connectionId: string) => {
|
||||
if (applyingGeminiAuthId) return;
|
||||
setApplyingGeminiAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("geminiAuthAppliedLocal")
|
||||
? t("geminiAuthAppliedLocal")
|
||||
: "Gemini auth applied locally";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("geminiAuthApplyFailed")
|
||||
? t("geminiAuthApplyFailed")
|
||||
: "Failed to apply Gemini auth locally";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
setApplyGeminiModalConnectionId(null);
|
||||
} catch (error) {
|
||||
console.error("Error applying Gemini auth locally:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setApplyingGeminiAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportGeminiAuthFile = async (connectionId: string) => {
|
||||
if (exportingGeminiAuthId) return;
|
||||
setExportingGeminiAuthId(connectionId);
|
||||
|
||||
const defaultSuccess =
|
||||
typeof (t as any).has === "function" && (t as any).has("geminiAuthExported")
|
||||
? t("geminiAuthExported")
|
||||
: "Gemini auth file exported";
|
||||
const defaultError =
|
||||
typeof (t as any).has === "function" && (t as any).has("geminiAuthExportFailed")
|
||||
? t("geminiAuthExportFailed")
|
||||
: "Failed to export Gemini auth file";
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
notify.error(await parseApiErrorMessage(res, defaultError));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const filename = getAttachmentFilename(res, "gemini-auth.json");
|
||||
const objectUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
notify.success(defaultSuccess);
|
||||
} catch (error) {
|
||||
console.error("Error exporting Gemini auth file:", error);
|
||||
notify.error(defaultError);
|
||||
} finally {
|
||||
setExportingGeminiAuthId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// Codex
|
||||
applyingCodexAuthId,
|
||||
applyCodexModalConnectionId,
|
||||
setApplyCodexModalConnectionId,
|
||||
exportingCodexAuthId,
|
||||
handleApplyCodexAuthLocal,
|
||||
handleExportCodexAuthFile,
|
||||
// Claude
|
||||
applyingClaudeAuthId,
|
||||
applyClaudeModalConnectionId,
|
||||
setApplyClaudeModalConnectionId,
|
||||
exportingClaudeAuthId,
|
||||
handleApplyClaudeAuthLocal,
|
||||
handleExportClaudeAuthFile,
|
||||
// Gemini
|
||||
applyingGeminiAuthId,
|
||||
applyGeminiModalConnectionId,
|
||||
setApplyGeminiModalConnectionId,
|
||||
exportingGeminiAuthId,
|
||||
handleApplyGeminiAuthLocal,
|
||||
handleExportGeminiAuthFile,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
|
||||
|
||||
export type UseCommandCodeAuthParams = {
|
||||
providerId: string;
|
||||
fetchConnections: () => Promise<void> | void;
|
||||
setSiliconFlowInitialBaseUrl: (url: string | undefined) => void;
|
||||
setShowAddApiKeyModal: (show: boolean) => void;
|
||||
notify: { success: (msg: string) => void; error: (msg: string) => void };
|
||||
};
|
||||
|
||||
export function useCommandCodeAuth({
|
||||
fetchConnections,
|
||||
setSiliconFlowInitialBaseUrl,
|
||||
setShowAddApiKeyModal,
|
||||
notify,
|
||||
}: UseCommandCodeAuthParams) {
|
||||
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
|
||||
phase: "idle",
|
||||
state: "",
|
||||
authUrl: "",
|
||||
callbackUrl: "",
|
||||
expiresAt: null,
|
||||
message: "",
|
||||
});
|
||||
|
||||
const commandCodeAuthWindowRef = useRef<Window | null>(null);
|
||||
const commandCodeAuthTimerRef = useRef<number | null>(null);
|
||||
|
||||
const clearCommandCodeAuthTimer = useCallback(() => {
|
||||
if (commandCodeAuthTimerRef.current !== null) {
|
||||
window.clearTimeout(commandCodeAuthTimerRef.current);
|
||||
commandCodeAuthTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearCommandCodeAuthTimer();
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
};
|
||||
}, [clearCommandCodeAuthTimer]);
|
||||
|
||||
const handleCloseAddApiKeyModal = useCallback(() => {
|
||||
clearCommandCodeAuthTimer();
|
||||
setSiliconFlowInitialBaseUrl(undefined);
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
setCommandCodeAuthState({
|
||||
phase: "idle",
|
||||
state: "",
|
||||
authUrl: "",
|
||||
callbackUrl: "",
|
||||
expiresAt: null,
|
||||
message: "",
|
||||
});
|
||||
setShowAddApiKeyModal(false);
|
||||
}, [clearCommandCodeAuthTimer, setSiliconFlowInitialBaseUrl, setShowAddApiKeyModal]);
|
||||
|
||||
const handleCommandCodeAuthApply = useCallback(
|
||||
async (state: string, connectionId?: string, name?: string, setDefault?: boolean) => {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applying",
|
||||
message: "Applying browser-approved key…",
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/providers/command-code/auth/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state, connectionId, name, setDefault }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = data.error || "Failed to apply Command Code auth";
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: errorMessage,
|
||||
}));
|
||||
notify.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error applying Command Code auth:", error);
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to apply Command Code auth",
|
||||
}));
|
||||
notify.error("Failed to apply Command Code auth");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[fetchConnections, handleCloseAddApiKeyModal, notify]
|
||||
);
|
||||
|
||||
const handleStartCommandCodeAuth = useCallback(async () => {
|
||||
if (commandCodeAuthState.phase === "starting" || commandCodeAuthState.phase === "polling") {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCommandCodeAuthTimer();
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
|
||||
const popup = window.open("about:blank", "_blank");
|
||||
setCommandCodeAuthState({
|
||||
phase: "starting",
|
||||
state: "",
|
||||
authUrl: "",
|
||||
callbackUrl: "",
|
||||
expiresAt: null,
|
||||
message: "Opening Command Code Studio…",
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/providers/command-code/auth/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok || !data.state || !data.authUrl) {
|
||||
const errorMessage = data.error || "Failed to start Command Code auth";
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: errorMessage,
|
||||
}));
|
||||
notify.error(errorMessage);
|
||||
popup?.close?.();
|
||||
return;
|
||||
}
|
||||
|
||||
setCommandCodeAuthState({
|
||||
phase: "polling",
|
||||
state: data.state,
|
||||
authUrl: data.authUrl,
|
||||
callbackUrl: data.callbackUrl || "",
|
||||
expiresAt: data.expiresAt || null,
|
||||
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
|
||||
});
|
||||
|
||||
if (popup) {
|
||||
try {
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
// Ignore opener cleanup failures.
|
||||
}
|
||||
popup.location.href = data.authUrl;
|
||||
commandCodeAuthWindowRef.current = popup;
|
||||
} else {
|
||||
const fallbackPopup = window.open(data.authUrl, "_blank", "noopener,noreferrer");
|
||||
if (!fallbackPopup) {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
|
||||
}));
|
||||
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
|
||||
return;
|
||||
}
|
||||
commandCodeAuthWindowRef.current = fallbackPopup;
|
||||
}
|
||||
|
||||
const deadline = data.expiresAt ? new Date(data.expiresAt).getTime() : Date.now() + 180000;
|
||||
const poll = async () => {
|
||||
if (Date.now() >= deadline) {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const statusRes = await fetch(
|
||||
`/api/providers/command-code/auth/status?state=${encodeURIComponent(data.state)}`,
|
||||
{ method: "GET", cache: "no-store" }
|
||||
);
|
||||
const statusData = await statusRes.json().catch(() => ({}));
|
||||
const status = String(statusData.status || statusData.state || statusData.phase || "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
|
||||
if (status === "expired") {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "expired",
|
||||
message: "Command Code link expired",
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
notify.error("Command Code auth expired");
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "applied") {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "applied",
|
||||
message: "Command Code connected",
|
||||
}));
|
||||
commandCodeAuthWindowRef.current?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
await fetchConnections();
|
||||
handleCloseAddApiKeyModal();
|
||||
notify.success("Command Code connection added");
|
||||
clearCommandCodeAuthTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "received") {
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "received",
|
||||
message: "Browser approved, applying…",
|
||||
}));
|
||||
clearCommandCodeAuthTimer();
|
||||
await handleCommandCodeAuthApply(
|
||||
data.state,
|
||||
statusData.connectionId,
|
||||
statusData.name,
|
||||
statusData.setDefault
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until the contract reports a terminal state or timeout.
|
||||
}
|
||||
|
||||
commandCodeAuthTimerRef.current = window.setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
commandCodeAuthTimerRef.current = window.setTimeout(poll, 1000);
|
||||
} catch (error) {
|
||||
console.error("Error starting Command Code auth:", error);
|
||||
setCommandCodeAuthState((current) => ({
|
||||
...current,
|
||||
phase: "error",
|
||||
message: "Failed to start Command Code auth",
|
||||
}));
|
||||
notify.error("Failed to start Command Code auth");
|
||||
popup?.close?.();
|
||||
commandCodeAuthWindowRef.current = null;
|
||||
clearCommandCodeAuthTimer();
|
||||
}
|
||||
}, [
|
||||
clearCommandCodeAuthTimer,
|
||||
handleCloseAddApiKeyModal,
|
||||
commandCodeAuthState.phase,
|
||||
fetchConnections,
|
||||
handleCommandCodeAuthApply,
|
||||
notify,
|
||||
]);
|
||||
|
||||
const handleOpenCommandCodeConnect = useCallback(() => {
|
||||
setShowAddApiKeyModal(true);
|
||||
void handleStartCommandCodeAuth();
|
||||
}, [handleStartCommandCodeAuth, setShowAddApiKeyModal]);
|
||||
|
||||
return {
|
||||
commandCodeAuthState,
|
||||
clearCommandCodeAuthTimer,
|
||||
handleCloseAddApiKeyModal,
|
||||
handleCommandCodeAuthApply,
|
||||
handleStartCommandCodeAuth,
|
||||
handleOpenCommandCodeConnect,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
type UseExternalLinkFlowParams = {
|
||||
providerId: string;
|
||||
notify: { success: (msg: string) => void; error: (msg: string) => void };
|
||||
fetchConnections: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function useExternalLinkFlow({
|
||||
providerId,
|
||||
notify,
|
||||
fetchConnections,
|
||||
}: UseExternalLinkFlowParams) {
|
||||
const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false);
|
||||
const [externalLinkUrl, setExternalLinkUrl] = useState("");
|
||||
const [externalLinkToken, setExternalLinkToken] = useState<string | null>(null);
|
||||
const [externalLinkLoading, setExternalLinkLoading] = useState(false);
|
||||
const [externalLinkError, setExternalLinkError] = useState<string | null>(null);
|
||||
const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard();
|
||||
|
||||
// "Adicionar Externo": generate a single-use public link so a third party can
|
||||
// complete the Codex device flow in their own browser.
|
||||
const openExternalLinkFlow = useCallback(async () => {
|
||||
setExternalLinkModalOpen(true);
|
||||
setExternalLinkUrl("");
|
||||
setExternalLinkToken(null);
|
||||
setExternalLinkError(null);
|
||||
setExternalLinkLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/oauth/${providerId}/public-link`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok && data?.url) {
|
||||
setExternalLinkUrl(data.url);
|
||||
setExternalLinkToken(data.token || null);
|
||||
} else {
|
||||
setExternalLinkError(data?.error || "Falha ao gerar o link.");
|
||||
}
|
||||
} catch {
|
||||
setExternalLinkError("Não foi possível contatar o servidor.");
|
||||
} finally {
|
||||
setExternalLinkLoading(false);
|
||||
}
|
||||
}, [providerId]);
|
||||
|
||||
// While the share popup is open, poll the ticket status so the dashboard can
|
||||
// notify + refresh the connections the moment the external visitor finishes.
|
||||
useEffect(() => {
|
||||
if (!externalLinkModalOpen || !externalLinkToken) return;
|
||||
let active = true;
|
||||
const interval = setInterval(async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}`
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!active) return;
|
||||
if (data?.status === "completed") {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
notify.success("Conta Codex conectada pelo link externo.");
|
||||
fetchConnections();
|
||||
setExternalLinkModalOpen(false);
|
||||
setExternalLinkToken(null);
|
||||
} else if (data?.status === "expired") {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
setExternalLinkError("O link expirou sem ser concluído.");
|
||||
}
|
||||
} catch {
|
||||
/* transient network error — keep polling */
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]);
|
||||
|
||||
return {
|
||||
externalLinkModalOpen,
|
||||
setExternalLinkModalOpen,
|
||||
externalLinkUrl,
|
||||
externalLinkToken,
|
||||
externalLinkLoading,
|
||||
externalLinkError,
|
||||
externalLinkCopied,
|
||||
externalLinkCopy,
|
||||
openExternalLinkFlow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useModelImportHandlers — Issue #3501 Phase 1k
|
||||
*
|
||||
* Owns import-progress state and handlers that were previously inline in
|
||||
* ProviderDetailPageClient:
|
||||
* - importingModels, showImportModal, importProgress, togglingAutoSync
|
||||
* - handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync
|
||||
* - canImportModels (derived), isAutoSyncEnabled (derived), autoSyncConnection (derived)
|
||||
*
|
||||
* Cycle-safe: imports only from leaf modules and React.
|
||||
* No import from ProviderDetailPageClient.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
type NotifyStore = ReturnType<typeof useNotificationStore>;
|
||||
|
||||
// ──── types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ImportProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
phase: "idle" | "fetching" | "importing" | "done" | "error";
|
||||
status: string;
|
||||
logs: string[];
|
||||
error: string;
|
||||
importedCount: number;
|
||||
}
|
||||
|
||||
export interface UseModelImportHandlersParams {
|
||||
providerId: string;
|
||||
models: Array<{ id: string; name?: string }>;
|
||||
modelMeta: { customModels: Array<{ id: string }>; modelCompatOverrides?: unknown[] };
|
||||
modelAliases: Record<string, string>;
|
||||
connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record<string, unknown> }>;
|
||||
isFreeNoAuth: boolean;
|
||||
handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise<void>;
|
||||
fetchAliases: () => Promise<void>;
|
||||
fetchProviderModelMeta: () => Promise<void>;
|
||||
fetchConnections: () => Promise<void>;
|
||||
notify: NotifyStore;
|
||||
t: ProviderMessageTranslator;
|
||||
providerStorageAlias: string;
|
||||
}
|
||||
|
||||
export interface UseModelImportHandlersReturn {
|
||||
importingModels: boolean;
|
||||
showImportModal: boolean;
|
||||
importProgress: ImportProgress;
|
||||
togglingAutoSync: boolean;
|
||||
canImportModels: boolean;
|
||||
isAutoSyncEnabled: boolean;
|
||||
autoSyncConnection: UseModelImportHandlersParams["connections"][number] | undefined;
|
||||
setShowImportModal: (v: boolean) => void;
|
||||
setImportProgress: React.Dispatch<React.SetStateAction<ImportProgress>>;
|
||||
handleImportModels: () => Promise<void>;
|
||||
handleCompatibleImportWithProgress: (connectionId: string) => Promise<void>;
|
||||
handleToggleAutoSync: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ──── hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useModelImportHandlers({
|
||||
providerId,
|
||||
models,
|
||||
modelMeta,
|
||||
modelAliases,
|
||||
connections,
|
||||
isFreeNoAuth,
|
||||
handleSetAlias,
|
||||
fetchAliases,
|
||||
fetchProviderModelMeta,
|
||||
fetchConnections,
|
||||
notify,
|
||||
t,
|
||||
providerStorageAlias,
|
||||
}: UseModelImportHandlersParams): UseModelImportHandlersReturn {
|
||||
const [importingModels, setImportingModels] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState<ImportProgress>({
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "idle",
|
||||
status: "",
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
});
|
||||
const [togglingAutoSync, setTogglingAutoSync] = useState(false);
|
||||
|
||||
// Derived
|
||||
const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false);
|
||||
const autoSyncConnection = connections.find((conn) => conn.isActive !== false);
|
||||
const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync;
|
||||
|
||||
const handleImportModels = async () => {
|
||||
if (importingModels) return;
|
||||
const activeConnection = connections.find((conn) => conn.isActive !== false);
|
||||
if (!activeConnection && !isFreeNoAuth) return;
|
||||
const importTargetId = activeConnection?.id ?? providerId;
|
||||
|
||||
setImportingModels(true);
|
||||
setShowImportModal(true);
|
||||
setImportProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "fetching",
|
||||
status: t("fetchingModels"),
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: t("failedFetchModels"),
|
||||
error: data.error || t("failedImportModels"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const fetchedModels = data.models || [];
|
||||
if (fetchedModels.length === 0) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status: t("noModelsFound"),
|
||||
logs: [t("noModelsReturnedFromEndpoint")],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set([
|
||||
...(modelMeta.customModels || []).map((m: any) => m.id),
|
||||
...models.map((m: any) => m.id),
|
||||
]);
|
||||
const newModels = fetchedModels.filter(
|
||||
(model: any) => !existingIds.has(model.id || model.name || model.model)
|
||||
);
|
||||
|
||||
if (newModels.length === 0) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status: t("allModelsAlreadyImported") || "All models already imported",
|
||||
logs: [t("noNewModelsToImport") || "No new models to import"],
|
||||
importedCount: 0,
|
||||
total: 0,
|
||||
current: 0,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "importing",
|
||||
total: newModels.length,
|
||||
current: 0,
|
||||
status: t("importingModelsProgress", { current: 0, total: newModels.length }),
|
||||
logs: [
|
||||
t("foundModelsStartingImport", { count: newModels.length }),
|
||||
...(newModels.length < fetchedModels.length
|
||||
? [
|
||||
t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) ||
|
||||
`Skipping ${fetchedModels.length - newModels.length} existing models`,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}));
|
||||
|
||||
let importedCount = 0;
|
||||
for (let i = 0; i < newModels.length; i++) {
|
||||
const model = newModels[i];
|
||||
const modelId = model.id || model.name || model.model;
|
||||
if (!modelId) continue;
|
||||
const parts = modelId.split("/");
|
||||
const baseAlias = parts[parts.length - 1];
|
||||
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
current: i + 1,
|
||||
status: t("importingModelsProgress", { current: i + 1, total: newModels.length }),
|
||||
logs: [...prev.logs, t("importingModelById", { modelId })],
|
||||
}));
|
||||
|
||||
await fetch("/api/provider-models", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: model.name || modelId,
|
||||
source: "imported",
|
||||
...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}),
|
||||
...(Array.isArray(model.supportedEndpoints)
|
||||
? { supportedEndpoints: model.supportedEndpoints }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (!modelAliases[baseAlias]) {
|
||||
await handleSetAlias(modelId, baseAlias, providerStorageAlias);
|
||||
}
|
||||
importedCount += 1;
|
||||
}
|
||||
|
||||
await fetchAliases();
|
||||
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
current: newModels.length,
|
||||
status:
|
||||
importedCount > 0
|
||||
? t("importSuccessCount", { count: importedCount })
|
||||
: t("noNewModelsAddedExisting"),
|
||||
logs: [
|
||||
...prev.logs,
|
||||
importedCount > 0
|
||||
? t("importDoneCount", { count: importedCount })
|
||||
: t("noNewModelsAdded"),
|
||||
],
|
||||
importedCount,
|
||||
}));
|
||||
|
||||
if (importedCount > 0) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error importing models:", error);
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: t("importFailed"),
|
||||
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
|
||||
}));
|
||||
} finally {
|
||||
setImportingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompatibleImportWithProgress = async (connectionId: string) => {
|
||||
setShowImportModal(true);
|
||||
setImportProgress({
|
||||
current: 0,
|
||||
total: 0,
|
||||
phase: "fetching",
|
||||
status: t("fetchingModels"),
|
||||
logs: [],
|
||||
error: "",
|
||||
importedCount: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t("failedImportModels"));
|
||||
}
|
||||
|
||||
const importedModels = Array.isArray(data.importedModels) ? data.importedModels : [];
|
||||
const importedCount =
|
||||
typeof data.importedCount === "number" ? data.importedCount : importedModels.length;
|
||||
const changedCount =
|
||||
typeof data.importedChanges?.total === "number"
|
||||
? data.importedChanges.total
|
||||
: importedCount;
|
||||
const totalChangedCount =
|
||||
changedCount +
|
||||
(typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0);
|
||||
|
||||
if (importedModels.length === 0) {
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
status:
|
||||
importedCount > 0
|
||||
? t("importSuccessCount", { count: importedCount })
|
||||
: t("noNewModelsAdded"),
|
||||
logs: [
|
||||
importedCount > 0
|
||||
? t("importDoneCount", { count: importedCount })
|
||||
: t("noNewModelsAdded"),
|
||||
],
|
||||
importedCount,
|
||||
}));
|
||||
if (totalChangedCount > 0) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "done",
|
||||
total: importedModels.length,
|
||||
current: importedModels.length,
|
||||
status:
|
||||
importedCount > 0
|
||||
? t("importSuccessCount", { count: importedCount })
|
||||
: t("noNewModelsAdded"),
|
||||
logs: [
|
||||
t("foundModelsStartingImport", { count: importedModels.length }),
|
||||
...importedModels.map((model: any) =>
|
||||
t("importingModelById", { modelId: model.id || model.name || model.model })
|
||||
),
|
||||
importedCount > 0
|
||||
? t("importDoneCount", { count: importedCount })
|
||||
: t("noNewModelsAdded"),
|
||||
],
|
||||
importedCount,
|
||||
}));
|
||||
|
||||
if (totalChangedCount > 0) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error importing models:", error);
|
||||
setImportProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "error",
|
||||
status: t("importFailed"),
|
||||
error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoSync = async () => {
|
||||
if (!autoSyncConnection || togglingAutoSync) return;
|
||||
setTogglingAutoSync(true);
|
||||
try {
|
||||
const newValue = !isAutoSyncEnabled;
|
||||
await fetch(`/api/providers/${(autoSyncConnection as any).id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerSpecificData: { autoSync: newValue },
|
||||
}),
|
||||
});
|
||||
await fetchConnections();
|
||||
notify[newValue ? "success" : "info"](
|
||||
newValue ? t("autoSyncEnabled") : t("autoSyncDisabled")
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("Error toggling auto-sync:", error);
|
||||
notify.error(t("autoSyncToggleFailed"));
|
||||
} finally {
|
||||
setTogglingAutoSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
importingModels,
|
||||
showImportModal,
|
||||
importProgress,
|
||||
togglingAutoSync,
|
||||
canImportModels,
|
||||
isAutoSyncEnabled,
|
||||
autoSyncConnection,
|
||||
setShowImportModal,
|
||||
setImportProgress,
|
||||
handleImportModels,
|
||||
handleCompatibleImportWithProgress,
|
||||
handleToggleAutoSync,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useModelVisibilityHandlers — Issue #3501 Phase 1l
|
||||
*
|
||||
* Owns model-visibility/compat state and handlers previously inline in
|
||||
* ProviderDetailPageClient:
|
||||
* - State: compatSavingModelId, togglingModelId, bulkVisibilityAction,
|
||||
* clearingModels, modelFilter, testingModelId, modelTestStatus,
|
||||
* testingAll, testProgress, autoHideFailed, visibilityFilter
|
||||
* - Derived: providerAliasEntries
|
||||
* - Handlers: saveModelCompatFlags, handleToggleModelHidden,
|
||||
* handleBulkToggleModelHidden, handleClearAllModels,
|
||||
* onTestModel, handleTestAll
|
||||
*
|
||||
* onTestModel and handleTestAll share handleToggleModelHidden — kept in the
|
||||
* same hook to avoid cross-hook cycles.
|
||||
*
|
||||
* Cycle-safe: imports only from leaf modules. No import from
|
||||
* ProviderDetailPageClient.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
formatProviderModelsErrorResponse,
|
||||
providerText,
|
||||
type ProviderMessageTranslator,
|
||||
type CompatByProtocolMap,
|
||||
} from "../providerPageHelpers";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
type NotifyStore = ReturnType<typeof useNotificationStore>;
|
||||
|
||||
// ──── types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Subset of ModelCompatSavePatch fields needed by this hook. */
|
||||
export interface ModelCompatSavePatch {
|
||||
normalizeToolCallId?: boolean;
|
||||
preserveOpenAIDeveloperRole?: boolean;
|
||||
upstreamHeaders?: Record<string, string>;
|
||||
compatByProtocol?: CompatByProtocolMap;
|
||||
isHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface UseModelVisibilityHandlersParams {
|
||||
providerId: string;
|
||||
modelAliases: Record<string, string>;
|
||||
/** The computed custom-model map from useModelCompatState. */
|
||||
customMap: Map<string, unknown>;
|
||||
providerStorageAlias: string;
|
||||
fetchProviderModelMeta: () => Promise<void>;
|
||||
fetchAliases: () => Promise<void>;
|
||||
notify: NotifyStore;
|
||||
t: ProviderMessageTranslator;
|
||||
formatProviderModelsErrorResponse?: typeof formatProviderModelsErrorResponse;
|
||||
/** The current selected connection (may be null). */
|
||||
selectedConnection: any;
|
||||
/** The provider node (may be null). */
|
||||
providerNode: any;
|
||||
}
|
||||
|
||||
export interface UseModelVisibilityHandlersReturn {
|
||||
compatSavingModelId: string | null;
|
||||
togglingModelId: string | null;
|
||||
bulkVisibilityAction: "select" | "deselect" | null;
|
||||
clearingModels: boolean;
|
||||
modelFilter: string;
|
||||
testingModelId: string | null;
|
||||
modelTestStatus: Record<string, "ok" | "error">;
|
||||
testingAll: boolean;
|
||||
testProgress: { done: number; total: number } | null;
|
||||
autoHideFailed: boolean;
|
||||
visibilityFilter: "all" | "visible" | "hidden";
|
||||
providerAliasEntries: [string, string][];
|
||||
setModelFilter: (v: string) => void;
|
||||
setAutoHideFailed: (v: boolean) => void;
|
||||
setVisibilityFilter: (v: "all" | "visible" | "hidden") => void;
|
||||
saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise<void>;
|
||||
handleToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelId: string,
|
||||
hidden: boolean
|
||||
) => Promise<void>;
|
||||
handleBulkToggleModelHidden: (
|
||||
providerKey: string,
|
||||
modelIds: string[],
|
||||
hidden: boolean
|
||||
) => Promise<void>;
|
||||
handleClearAllModels: () => Promise<void>;
|
||||
onTestModel: (modelId: string, fullModel: string) => Promise<void>;
|
||||
handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
// ──── hook ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useModelVisibilityHandlers({
|
||||
providerId,
|
||||
modelAliases,
|
||||
customMap,
|
||||
providerStorageAlias,
|
||||
fetchProviderModelMeta,
|
||||
fetchAliases,
|
||||
notify,
|
||||
t,
|
||||
selectedConnection,
|
||||
providerNode,
|
||||
}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn {
|
||||
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
|
||||
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
|
||||
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<
|
||||
"select" | "deselect" | null
|
||||
>(null);
|
||||
const [clearingModels, setClearingModels] = useState(false);
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null);
|
||||
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [autoHideFailed, setAutoHideFailed] = useState(true);
|
||||
const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all");
|
||||
|
||||
const providerAliasEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(
|
||||
([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`)
|
||||
) as [string, string][],
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
|
||||
const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => {
|
||||
setCompatSavingModelId(modelId);
|
||||
try {
|
||||
const c = customMap.get(modelId) as Record<string, unknown> | undefined;
|
||||
let body: Record<string, unknown>;
|
||||
const onlyCompatByProtocol =
|
||||
patch.compatByProtocol &&
|
||||
patch.normalizeToolCallId === undefined &&
|
||||
patch.preserveOpenAIDeveloperRole === undefined &&
|
||||
!("upstreamHeaders" in patch);
|
||||
|
||||
if (c) {
|
||||
if (onlyCompatByProtocol) {
|
||||
body = {
|
||||
provider: providerId,
|
||||
modelId,
|
||||
compatByProtocol: patch.compatByProtocol,
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
provider: providerId,
|
||||
modelId,
|
||||
modelName: (c.name as string) || modelId,
|
||||
source: (c.source as string) || "manual",
|
||||
apiFormat: (c.apiFormat as string) || "chat-completions",
|
||||
supportedEndpoints:
|
||||
Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length
|
||||
? c.supportedEndpoints
|
||||
: ["chat"],
|
||||
normalizeToolCallId:
|
||||
patch.normalizeToolCallId !== undefined
|
||||
? patch.normalizeToolCallId
|
||||
: Boolean(c.normalizeToolCallId),
|
||||
preserveOpenAIDeveloperRole:
|
||||
patch.preserveOpenAIDeveloperRole !== undefined
|
||||
? patch.preserveOpenAIDeveloperRole
|
||||
: Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole")
|
||||
? Boolean(c.preserveOpenAIDeveloperRole)
|
||||
: true,
|
||||
};
|
||||
if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol;
|
||||
}
|
||||
} else {
|
||||
body = { provider: providerId, modelId, ...patch };
|
||||
}
|
||||
const res = await fetch("/api/provider-models", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await formatProviderModelsErrorResponse(res);
|
||||
notify.error(
|
||||
detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel")
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
return;
|
||||
} finally {
|
||||
setCompatSavingModelId(null);
|
||||
}
|
||||
try {
|
||||
await fetchProviderModelMeta();
|
||||
} catch {
|
||||
/* refresh failure is non-critical — data was already saved */
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleModelHidden = async (
|
||||
providerKey: string,
|
||||
modelId: string,
|
||||
hidden: boolean
|
||||
): Promise<void> => {
|
||||
setTogglingModelId(modelId);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isHidden: hidden }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
notify.error(detail || t("failedSaveCustomModel"));
|
||||
return;
|
||||
}
|
||||
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
|
||||
} catch {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
} finally {
|
||||
setTogglingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkToggleModelHidden = async (
|
||||
providerKey: string,
|
||||
modelIds: string[],
|
||||
hidden: boolean
|
||||
): Promise<void> => {
|
||||
if (modelIds.length === 0) return;
|
||||
setBulkVisibilityAction(hidden ? "deselect" : "select");
|
||||
try {
|
||||
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isHidden: hidden, modelIds }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => "");
|
||||
notify.error(detail || t("failedSaveCustomModel"));
|
||||
return;
|
||||
}
|
||||
await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]);
|
||||
} catch {
|
||||
notify.error(t("failedSaveCustomModel"));
|
||||
} finally {
|
||||
setBulkVisibilityAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllModels = async () => {
|
||||
if (clearingModels) return;
|
||||
if (!confirm(t("clearAllModelsConfirm"))) return;
|
||||
setClearingModels(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
if (res.ok) {
|
||||
// Also delete all aliases that belong to this provider
|
||||
await Promise.all(
|
||||
providerAliasEntries.map(([alias]) =>
|
||||
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
await fetchProviderModelMeta();
|
||||
await fetchAliases();
|
||||
notify.success(t("clearAllModelsSuccess"));
|
||||
} else {
|
||||
notify.error(t("clearAllModelsFailed"));
|
||||
}
|
||||
} catch {
|
||||
notify.error(t("clearAllModelsFailed"));
|
||||
} finally {
|
||||
setClearingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onTestModel = async (modelId: string, fullModel: string) => {
|
||||
setTestingModelId(modelId);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined as any }));
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: selectedConnection?.provider || providerNode?.id || providerId,
|
||||
modelId: fullModel,
|
||||
connectionId: selectedConnection?.id,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.status === "ok") {
|
||||
notify.success(
|
||||
providerText(t, "testModelSuccess", `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, {
|
||||
modelId,
|
||||
latencyMs: data.latencyMs,
|
||||
})
|
||||
);
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" }));
|
||||
} else {
|
||||
notify.error(data.error || "Model test failed");
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
await handleToggleModelHidden(providerStorageAlias, modelId, true);
|
||||
}
|
||||
} catch (err) {
|
||||
notify.error("Network error testing model");
|
||||
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
|
||||
await handleToggleModelHidden(providerStorageAlias, modelId, true);
|
||||
} finally {
|
||||
setTestingModelId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestAll = async (
|
||||
targets: Array<{ modelId: string; fullModel: string }>
|
||||
): Promise<void> => {
|
||||
if (testingAll) return;
|
||||
if (targets.length === 0) {
|
||||
notify.error(providerText(t, "noModelsToTest", "No models to test"));
|
||||
return;
|
||||
}
|
||||
setTestingAll(true);
|
||||
setTestProgress({ done: 0, total: targets.length });
|
||||
|
||||
let ok = 0;
|
||||
let error = 0;
|
||||
let hiddenCount = 0;
|
||||
|
||||
const CHUNK_SIZE = 3;
|
||||
for (let i = 0; i < targets.length; i += CHUNK_SIZE) {
|
||||
const chunk = targets.slice(i, i + CHUNK_SIZE);
|
||||
await Promise.all(
|
||||
chunk.map(async ({ modelId, fullModel }) => {
|
||||
try {
|
||||
const result: {
|
||||
results?: Record<
|
||||
string,
|
||||
{
|
||||
status?: "ok" | "error";
|
||||
rateLimited?: boolean;
|
||||
isTimeout?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
>;
|
||||
} = await fetch("/api/models/test-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providerId: providerId,
|
||||
connectionId: selectedConnection?.id,
|
||||
modelIds: [fullModel],
|
||||
}),
|
||||
}).then((r) => r.json());
|
||||
|
||||
const entry = result.results?.[fullModel];
|
||||
if (entry?.status === "ok") {
|
||||
ok++;
|
||||
} else {
|
||||
error++;
|
||||
if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) {
|
||||
await handleToggleModelHidden(providerStorageAlias, modelId, true);
|
||||
hiddenCount++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error++;
|
||||
}
|
||||
setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error }));
|
||||
if (hiddenCount > 0) {
|
||||
notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount }));
|
||||
}
|
||||
setTestingAll(false);
|
||||
setTestProgress(null);
|
||||
};
|
||||
|
||||
return {
|
||||
compatSavingModelId,
|
||||
togglingModelId,
|
||||
bulkVisibilityAction,
|
||||
clearingModels,
|
||||
modelFilter,
|
||||
testingModelId,
|
||||
modelTestStatus,
|
||||
testingAll,
|
||||
testProgress,
|
||||
autoHideFailed,
|
||||
visibilityFilter,
|
||||
providerAliasEntries,
|
||||
setModelFilter,
|
||||
setAutoHideFailed,
|
||||
setVisibilityFilter,
|
||||
saveModelCompatFlags,
|
||||
handleToggleModelHidden,
|
||||
handleBulkToggleModelHidden,
|
||||
handleClearAllModels,
|
||||
onTestModel,
|
||||
handleTestAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Phase 1t.4 extraction — Issue #3501
|
||||
// Encapsulates handleUpdateNode and handleUpdateConnection async handlers.
|
||||
import type { ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
interface UseProviderNodeActionsParams {
|
||||
providerId: string;
|
||||
fetchConnections: () => Promise<void>;
|
||||
selectedConnection: { id: string } | null;
|
||||
setProviderNode: (node: any) => void;
|
||||
setShowEditNodeModal: (open: boolean) => void;
|
||||
setShowEditModal: (open: boolean) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
}
|
||||
|
||||
export function useProviderNodeActions({
|
||||
providerId,
|
||||
fetchConnections,
|
||||
selectedConnection,
|
||||
setProviderNode,
|
||||
setShowEditNodeModal,
|
||||
setShowEditModal,
|
||||
t,
|
||||
}: UseProviderNodeActionsParams) {
|
||||
const handleUpdateNode = async (formData: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/provider-nodes/${providerId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProviderNode(data.node);
|
||||
await fetchConnections();
|
||||
setShowEditNodeModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error updating provider node:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateConnection = async (formData: any) => {
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${selectedConnection?.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
if (res.ok) {
|
||||
await fetchConnections();
|
||||
setShowEditModal(false);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.error?.message || data.error || t("failedSaveConnection");
|
||||
} catch (error) {
|
||||
console.log("Error updating connection:", error);
|
||||
return t("failedSaveConnectionRetry");
|
||||
}
|
||||
};
|
||||
|
||||
return { handleUpdateNode, handleUpdateConnection };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export default function ComboDefaultsTab() {
|
||||
retryDelayMs: 2000,
|
||||
maxComboDepth: 3,
|
||||
trackMetrics: true,
|
||||
reasoningTokenBufferEnabled: true,
|
||||
handoffThreshold: 0.85,
|
||||
handoffModel: "",
|
||||
maxMessagesForSummary: 30,
|
||||
@@ -556,6 +557,29 @@ export default function ComboDefaultsTab() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{translateOrFallback(t, "reasoningTokenBuffer", "Reasoning token buffer")}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback(
|
||||
t,
|
||||
"reasoningTokenBufferDesc",
|
||||
"Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={comboDefaults.reasoningTokenBufferEnabled !== false}
|
||||
onChange={() =>
|
||||
setComboDefaults((prev) => ({
|
||||
...prev,
|
||||
reasoningTokenBufferEnabled: prev.reasoningTokenBufferEnabled === false,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Card, Toggle } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ModelLockoutSettings = {
|
||||
enabled: boolean;
|
||||
errorCodes: number[];
|
||||
baseCooldownMs: number;
|
||||
maxCooldownMs: number;
|
||||
maxBackoffSteps: number;
|
||||
useExponentialBackoff: boolean;
|
||||
};
|
||||
|
||||
const DEFAULTS: ModelLockoutSettings = {
|
||||
enabled: false,
|
||||
errorCodes: [403, 404, 429, 502, 503, 504],
|
||||
baseCooldownMs: 120_000,
|
||||
maxCooldownMs: 1_800_000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
};
|
||||
|
||||
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
min = 0,
|
||||
max,
|
||||
hint,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
hint?: string;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="flex items-center justify-between text-xs text-text-muted">
|
||||
<span>{label}</span>
|
||||
{hint ? <span className="text-text-soft">{hint}</span> : null}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
if (event.target.value === "") return;
|
||||
const nextValue = Number(event.target.value);
|
||||
if (Number.isFinite(nextValue)) {
|
||||
onChange(nextValue);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm"
|
||||
/>
|
||||
{suffix ? <span className="text-xs text-text-muted">{suffix}</span> : null}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ModelLockoutCard() {
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const [data, setData] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
const [draft, setDraft] = useState<ModelLockoutSettings>(DEFAULTS);
|
||||
const [errorCodesInput, setErrorCodesInput] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
if (!mounted) return;
|
||||
|
||||
const raw = (json as Record<string, unknown>).modelLockout as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
const parsed: ModelLockoutSettings = {
|
||||
enabled:
|
||||
typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled,
|
||||
errorCodes: Array.isArray(raw?.errorCodes)
|
||||
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
|
||||
: [...DEFAULTS.errorCodes].sort((a, b) => a - b),
|
||||
baseCooldownMs:
|
||||
typeof raw?.baseCooldownMs === "number"
|
||||
? raw.baseCooldownMs
|
||||
: DEFAULTS.baseCooldownMs,
|
||||
maxCooldownMs:
|
||||
typeof raw?.maxCooldownMs === "number"
|
||||
? raw.maxCooldownMs
|
||||
: DEFAULTS.maxCooldownMs,
|
||||
maxBackoffSteps:
|
||||
typeof raw?.maxBackoffSteps === "number"
|
||||
? raw.maxBackoffSteps
|
||||
: DEFAULTS.maxBackoffSteps,
|
||||
useExponentialBackoff:
|
||||
typeof raw?.useExponentialBackoff === "boolean"
|
||||
? raw.useExponentialBackoff
|
||||
: DEFAULTS.useExponentialBackoff,
|
||||
};
|
||||
|
||||
setData(parsed);
|
||||
setDraft(parsed);
|
||||
setErrorCodesInput("");
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to load model lockout settings"
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasChanges =
|
||||
draft.enabled !== data.enabled ||
|
||||
JSON.stringify([...draft.errorCodes].sort((a, b) => a - b)) !==
|
||||
JSON.stringify([...data.errorCodes].sort((a, b) => a - b)) ||
|
||||
draft.baseCooldownMs !== data.baseCooldownMs ||
|
||||
draft.maxCooldownMs !== data.maxCooldownMs ||
|
||||
draft.useExponentialBackoff !== data.useExponentialBackoff ||
|
||||
draft.maxBackoffSteps !== data.maxBackoffSteps;
|
||||
|
||||
function validateDraft(d: ModelLockoutSettings): string | null {
|
||||
if (d.baseCooldownMs < 5000 || d.baseCooldownMs > 600000)
|
||||
return `Base Cooldown must be between 5,000ms and 600,000ms`;
|
||||
if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000)
|
||||
return `Max Cooldown must be between 5,000ms and 3,600,000ms`;
|
||||
if (d.maxCooldownMs < d.baseCooldownMs)
|
||||
return `Max Cooldown must be ≥ Base Cooldown`;
|
||||
if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20)
|
||||
return `Max Backoff Steps must be between 0 and 20`;
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const validationError = validateDraft(draft);
|
||||
if (validationError) {
|
||||
notify.error(validationError);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const saveDraft = { ...draft, errorCodes: draft.errorCodes };
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelLockout: saveDraft }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
const issues = err?.error?.issues ?? err?.error?.details;
|
||||
if (Array.isArray(issues) && issues.length > 0) {
|
||||
const fieldLabels: Record<string, string> = {
|
||||
"modelLockout.baseCooldownMs": "Base Cooldown",
|
||||
"modelLockout.maxCooldownMs": "Max Cooldown",
|
||||
"modelLockout.maxBackoffSteps": "Max Backoff Steps",
|
||||
"modelLockout.errorCodes": "Error Codes",
|
||||
};
|
||||
const msg = issues
|
||||
.map(
|
||||
(d: { path?: (string | number)[]; message?: string }) =>
|
||||
`${fieldLabels[String(d.path?.[0])] || String(d.path?.[0] || "")}: ${d.message}`
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (msg) throw new Error(msg);
|
||||
}
|
||||
throw new Error(
|
||||
err?.error?.message || `HTTP ${res.status}`
|
||||
);
|
||||
}
|
||||
const json = await res.json();
|
||||
const raw = (json as Record<string, unknown>).modelLockout as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (raw) {
|
||||
setData({
|
||||
enabled:
|
||||
typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled,
|
||||
errorCodes: Array.isArray(raw.errorCodes)
|
||||
? [...(raw.errorCodes as number[])].sort((a, b) => a - b)
|
||||
: [...saveDraft.errorCodes].sort((a, b) => a - b),
|
||||
baseCooldownMs:
|
||||
typeof raw.baseCooldownMs === "number"
|
||||
? raw.baseCooldownMs
|
||||
: saveDraft.baseCooldownMs,
|
||||
maxCooldownMs:
|
||||
typeof raw.maxCooldownMs === "number"
|
||||
? raw.maxCooldownMs
|
||||
: saveDraft.maxCooldownMs,
|
||||
maxBackoffSteps:
|
||||
typeof raw.maxBackoffSteps === "number"
|
||||
? raw.maxBackoffSteps
|
||||
: saveDraft.maxBackoffSteps,
|
||||
useExponentialBackoff:
|
||||
typeof raw.useExponentialBackoff === "boolean"
|
||||
? raw.useExponentialBackoff
|
||||
: saveDraft.useExponentialBackoff,
|
||||
});
|
||||
} else {
|
||||
setData(saveDraft);
|
||||
}
|
||||
setErrorCodesInput("");
|
||||
notify.success(t("savedSuccessfully") || "Settings saved successfully");
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to save model lockout settings"
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(data);
|
||||
setErrorCodesInput("");
|
||||
};
|
||||
|
||||
const commitErrorCodes = (inputOverride?: string) => {
|
||||
const raw = inputOverride ?? errorCodesInput;
|
||||
const code = Number(raw);
|
||||
if (!Number.isFinite(code) || code < 100 || code > 599) return;
|
||||
if (draft.errorCodes.includes(code)) {
|
||||
setErrorCodesInput("");
|
||||
return;
|
||||
}
|
||||
setDraft((prev) => ({ ...prev, errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b) }));
|
||||
setErrorCodesInput("");
|
||||
};
|
||||
|
||||
const removeErrorCode = (code: number) => {
|
||||
setDraft((prev) => ({ ...prev, errorCodes: prev.errorCodes.filter((c) => c !== code) }));
|
||||
};
|
||||
|
||||
const handleResetDefaults = () => {
|
||||
setDraft({
|
||||
...DEFAULTS,
|
||||
errorCodes: [...DEFAULTS.errorCodes].sort((a, b) => a - b),
|
||||
});
|
||||
setErrorCodesInput("");
|
||||
};
|
||||
|
||||
const fmt = (ms: number) => {
|
||||
if (ms >= 60_000) return `${ms / 1000 / 60}m`;
|
||||
if (ms >= 1_000) return `${ms / 1000}s`;
|
||||
return `${ms}ms`;
|
||||
};
|
||||
|
||||
const notifyRef = useRef<HTMLAudioElement | null>(null);
|
||||
const playNotify = useCallback(() => {
|
||||
try {
|
||||
if (notifyRef.current) {
|
||||
notifyRef.current.pause();
|
||||
notifyRef.current.currentTime = 0;
|
||||
} else {
|
||||
notifyRef.current = new Audio("/audio/ui-notify.mp3");
|
||||
notifyRef.current.volume = 0.3;
|
||||
}
|
||||
void notifyRef.current.play();
|
||||
} catch { /* audio not available */ }
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-2 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading model lockout settings...
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-xl text-primary">
|
||||
gpp_maybe
|
||||
</span>
|
||||
<h2 className="text-lg font-bold">
|
||||
{t("modelLockout") || "Model Lockout"}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("modelLockoutPageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{hasChanges ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleReset}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
icon="save"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? tc("saving") || "Saving..." : tc("save")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleResetDefaults}
|
||||
>
|
||||
Reset defaults
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Master toggle */}
|
||||
<div className="rounded-lg border border-border bg-bg-subtle px-4 py-3">
|
||||
<Toggle
|
||||
checked={draft.enabled}
|
||||
onChange={(checked) => {
|
||||
setDraft((prev) => ({ ...prev, enabled: checked }));
|
||||
playNotify();
|
||||
}}
|
||||
label={t("modelLockoutEnabled")}
|
||||
description={t("modelLockoutEnabledDescription")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error codes — tag input */}
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{t("modelLockoutErrorCodes")}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("modelLockoutErrorCodesDescription")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Chips row */}
|
||||
{draft.errorCodes.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{draft.errorCodes.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 text-primary border border-primary/20 px-2 py-1 text-sm font-medium"
|
||||
>
|
||||
{code}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeErrorCode(code)}
|
||||
className="inline-flex size-4 items-center justify-center rounded-sm hover:bg-primary/20 transition-colors"
|
||||
aria-label={`Remove ${code}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm leading-none">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={errorCodesInput}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, "");
|
||||
if (raw.length <= 3) setErrorCodesInput(raw);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitErrorCodes();
|
||||
}
|
||||
}}
|
||||
placeholder="Add error code..."
|
||||
className="w-32 rounded-lg border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-primary transition-colors placeholder:text-text-muted/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitErrorCodes()}
|
||||
disabled={!errorCodesInput.trim()}
|
||||
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm text-text-muted hover:text-text-main hover:border-primary/40 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggested common codes — chips as clickable suggestions */}
|
||||
{draft.errorCodes.length === 0 && errorCodesInput === "" && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-text-muted mr-1">Suggestions:</span>
|
||||
{[403, 404, 429, 502, 503, 504].map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => commitErrorCodes(String(code))}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full bg-surface text-xs text-text-muted border border-border/40 hover:border-primary/40 hover:text-primary transition-colors"
|
||||
>
|
||||
+{code}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cooldowns - grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-4">
|
||||
<NumberField
|
||||
label={t("modelLockoutBaseCooldown")}
|
||||
value={draft.baseCooldownMs}
|
||||
min={5000}
|
||||
max={600000}
|
||||
suffix="ms"
|
||||
hint="5,000ms — 600,000ms"
|
||||
onChange={(baseCooldownMs) =>
|
||||
setDraft((prev) => ({ ...prev, baseCooldownMs }))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-text-muted">
|
||||
{t("modelLockoutBaseCooldownDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-4">
|
||||
<NumberField
|
||||
label={t("modelLockoutMaxCooldown")}
|
||||
value={draft.maxCooldownMs}
|
||||
min={draft.baseCooldownMs}
|
||||
max={3600000}
|
||||
suffix="ms"
|
||||
hint="≥ Base Cooldown — 3,600,000ms"
|
||||
onChange={(maxCooldownMs) =>
|
||||
setDraft((prev) => ({ ...prev, maxCooldownMs }))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-text-muted">
|
||||
{t("modelLockoutMaxCooldownDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exponential backoff */}
|
||||
<div className="rounded-lg border border-border bg-bg-subtle px-4 py-3">
|
||||
<Toggle
|
||||
checked={draft.useExponentialBackoff}
|
||||
onChange={(checked) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
useExponentialBackoff: checked,
|
||||
}));
|
||||
playNotify();
|
||||
}}
|
||||
label={t("modelLockoutExponentialBackoff")}
|
||||
description={t("modelLockoutExponentialBackoffDescription")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Max backoff steps */}
|
||||
<div className="rounded-lg border border-border bg-bg-subtle p-4">
|
||||
<NumberField
|
||||
label={t("modelLockoutMaxBackoffSteps")}
|
||||
value={draft.maxBackoffSteps}
|
||||
min={0}
|
||||
onChange={(maxBackoffSteps) =>
|
||||
setDraft((prev) => ({ ...prev, maxBackoffSteps }))
|
||||
}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-text-muted">
|
||||
{t("modelLockoutMaxBackoffStepsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import AutoDisableCard from "./AutoDisableCard";
|
||||
import ModelCooldownsCard from "./ModelCooldownsCard";
|
||||
import ModelLockoutCard from "./ModelLockoutCard";
|
||||
|
||||
type RequestQueueSettings = {
|
||||
autoEnableApiKeyProviders: boolean;
|
||||
@@ -975,6 +976,7 @@ export default function ResilienceTab() {
|
||||
saving={savingSection === "providerCooldown"}
|
||||
onSave={(providerCooldown) => savePatch("providerCooldown", { providerCooldown })}
|
||||
/>
|
||||
<ModelLockoutCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ export default function DocumentationTab() {
|
||||
<h3 className="font-semibold mb-2">SOCKS5</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("proxyDocumentationSocks5DescBefore")}{" "}
|
||||
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=true</code> to enable.
|
||||
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code> to disable
|
||||
(ON by default).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
82
src/app/api/intelligence/sync/route.ts
Normal file
82
src/app/api/intelligence/sync/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* API Route: /api/intelligence/sync
|
||||
*
|
||||
* POST — Trigger a manual Arena ELO intelligence sync.
|
||||
* GET — Get current intelligence sync status.
|
||||
* DELETE — Clear all synced arena_elo intelligence data.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { intelligenceSyncRequestSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(intelligenceSyncRequestSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { dryRun = false } = validation.data;
|
||||
|
||||
const { syncArenaElo } = await import("@/lib/arenaEloSync");
|
||||
const result = await syncArenaElo(dryRun);
|
||||
|
||||
return NextResponse.json(result, { status: result.success ? 200 : 502 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { getArenaEloSyncStatus } = await import("@/lib/arenaEloSync");
|
||||
return NextResponse.json(getArenaEloSyncStatus());
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { clearSyncedIntelligence } = await import("@/lib/arenaEloSync");
|
||||
clearSyncedIntelligence();
|
||||
return NextResponse.json({ success: true, message: "Synced intelligence data cleared" });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
isAutoFetchModelsEnabled,
|
||||
persistDiscoveredModels,
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
import { parseGeminiModelsList, type GeminiDiscoveryModel } from "@/lib/providerModels/geminiModelsParser";
|
||||
import { getSyncedAvailableModels } from "@/lib/db/models";
|
||||
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
|
||||
|
||||
@@ -399,50 +400,7 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authQuery: "key", // Use query param for API key
|
||||
parseResponse: (data) => {
|
||||
const METHOD_TO_ENDPOINT: Record<string, string> = {
|
||||
generateContent: "chat",
|
||||
embedContent: "embeddings",
|
||||
predict: "images",
|
||||
predictLongRunning: "images",
|
||||
bidiGenerateContent: "audio",
|
||||
generateAnswer: "chat",
|
||||
};
|
||||
const IGNORED_METHODS = new Set([
|
||||
"countTokens",
|
||||
"countTextTokens",
|
||||
"createCachedContent",
|
||||
"batchGenerateContent",
|
||||
"asyncBatchEmbedContent",
|
||||
]);
|
||||
|
||||
return (data.models || []).map((m: Record<string, unknown>) => {
|
||||
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
|
||||
? m.supportedGenerationMethods
|
||||
: [];
|
||||
const endpoints = [
|
||||
...new Set(
|
||||
methods
|
||||
.filter((method) => !IGNORED_METHODS.has(method))
|
||||
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
|
||||
),
|
||||
];
|
||||
if (endpoints.length === 0) endpoints.push("chat");
|
||||
|
||||
return {
|
||||
...m,
|
||||
id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""),
|
||||
name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""),
|
||||
supportedEndpoints: endpoints,
|
||||
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
|
||||
...(typeof m.outputTokenLimit === "number"
|
||||
? { outputTokenLimit: m.outputTokenLimit }
|
||||
: {}),
|
||||
...(typeof m.description === "string" ? { description: m.description } : {}),
|
||||
...(m.thinking === true ? { supportsThinking: true } : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
parseResponse: (data) => parseGeminiModelsList(data),
|
||||
},
|
||||
// gemini-cli handled via retrieveUserQuota (see GET handler)
|
||||
huggingface: {
|
||||
@@ -2084,6 +2042,130 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "vertex" || provider === "vertex-partner") {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled();
|
||||
if (autoFetchDisabledResponse) return autoFetchDisabledResponse;
|
||||
|
||||
// Vertex AI lists models from the Generative Language `v1beta/models` endpoint, which both
|
||||
// Express-mode API keys (via ?key=) and Service Account JSON (via a minted OAuth Bearer
|
||||
// token) can reach. This surfaces the full live catalog — including image models
|
||||
// (imagen-*, gemini-*-image) absent from the static registry list.
|
||||
const credential = (apiKey || "").trim();
|
||||
let queryKey: string | null = null;
|
||||
let bearerToken: string | null = null;
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } = await import(
|
||||
"@omniroute/open-sse/executors/vertex.ts"
|
||||
);
|
||||
if (accessToken) {
|
||||
bearerToken = accessToken;
|
||||
} else if (credential) {
|
||||
// A Service Account credential is a JSON object; a Vertex AI Express-mode API key is an
|
||||
// opaque (non-JSON) string. Detect locally so this branch has no dependency on optional
|
||||
// executor helpers.
|
||||
let isServiceAccountJson = false;
|
||||
try {
|
||||
const parsed = JSON.parse(credential);
|
||||
isServiceAccountJson =
|
||||
!!parsed && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
isServiceAccountJson = false;
|
||||
}
|
||||
|
||||
if (isServiceAccountJson) {
|
||||
bearerToken = await getAccessToken(parseSAFromApiKey(credential));
|
||||
} else {
|
||||
queryKey = credential;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Couldn't resolve a usable credential (e.g. malformed Service Account JSON).
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error, {
|
||||
cacheWarning: "Vertex credential unavailable — using cached catalog",
|
||||
localWarning: "Vertex credential unavailable — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
|
||||
if (!queryKey && !bearerToken) {
|
||||
const fallback = buildDiscoveryFallbackResponse({
|
||||
cacheWarning: "No usable Vertex credential — using cached catalog",
|
||||
localWarning: "No usable Vertex credential — using local catalog",
|
||||
});
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: "No usable Vertex AI credential configured for model discovery." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`;
|
||||
|
||||
const allModels: GeminiDiscoveryModel[] = [];
|
||||
let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 20;
|
||||
const seenTokens = new Set<string>();
|
||||
|
||||
try {
|
||||
while (pageUrl && pageCount < MAX_PAGES) {
|
||||
pageCount++;
|
||||
const response = await safeOutboundFetch(pageUrl, {
|
||||
...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination,
|
||||
guard: getProviderOutboundGuard(),
|
||||
proxyConfig: proxy,
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Avoid logging the raw upstream body (may contain sensitive data); status is enough.
|
||||
console.log("[models] Vertex model discovery failed", {
|
||||
provider,
|
||||
status: response.status,
|
||||
});
|
||||
const fallback = buildDiscoveryFallbackResponse();
|
||||
if (fallback) return fallback;
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch Vertex models: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
allModels.push(...parseGeminiModelsList(data));
|
||||
|
||||
const nextPageToken = data.nextPageToken;
|
||||
if (!nextPageToken || seenTokens.has(nextPageToken)) break;
|
||||
seenTokens.add(nextPageToken);
|
||||
pageUrl = `${baseUrl}&pageToken=${encodeURIComponent(nextPageToken)}`;
|
||||
if (queryKey) pageUrl += `&key=${encodeURIComponent(queryKey)}`;
|
||||
}
|
||||
} catch (error) {
|
||||
const fallback = buildDiscoveryErrorFallbackResponse(error);
|
||||
if (fallback) return fallback;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (allModels.length > 0) {
|
||||
return buildApiDiscoveryResponse(allModels);
|
||||
}
|
||||
|
||||
const fallback = buildDiscoveryFallbackResponse();
|
||||
if (fallback) return fallback;
|
||||
return buildResponse({
|
||||
provider,
|
||||
connectionId,
|
||||
models: [],
|
||||
source: "api",
|
||||
});
|
||||
}
|
||||
|
||||
if (isAnthropicCompatibleProvider(provider)) {
|
||||
const cachedResponse = maybeReturnCachedDiscovery();
|
||||
if (cachedResponse) return cachedResponse;
|
||||
|
||||
@@ -55,6 +55,7 @@ export async function GET(request: Request) {
|
||||
maxMessagesForSummary: 30,
|
||||
maxComboDepth: 3,
|
||||
trackMetrics: true,
|
||||
reasoningTokenBufferEnabled: true,
|
||||
zeroLatencyOptimizationsEnabled: false,
|
||||
},
|
||||
providerOverrides,
|
||||
|
||||
42
src/app/api/settings/proxies/egress/route.ts
Normal file
42
src/app/api/settings/proxies/egress/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
|
||||
import { diagnoseAllEgressIps, validateProxyPool } from "@/lib/proxyEgress";
|
||||
|
||||
/**
|
||||
* GET /api/settings/proxies/egress — diagnose the egress IP of every OAuth
|
||||
* connection: by which IP each account is entering (clientIp) and leaving
|
||||
* (egressIp), plus warnings for same-rotation-group accounts sharing one
|
||||
* egress IP (the codex anomaly-revocation trigger).
|
||||
*
|
||||
* POST /api/settings/proxies/egress — validate the whole proxy pool by probing
|
||||
* each proxy's real egress IP and persisting status=active/error, so dead
|
||||
* proxies are taken out of rotation automatically.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const diagnostic = await diagnoseAllEgressIps();
|
||||
return NextResponse.json(diagnostic);
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to diagnose egress IPs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
try {
|
||||
const report = await validateProxyPool();
|
||||
const dead = report.filter((r) => !r.alive);
|
||||
return NextResponse.json({
|
||||
validated: report.length,
|
||||
alive: report.length - dead.length,
|
||||
dead: dead.length,
|
||||
report,
|
||||
});
|
||||
} catch (error) {
|
||||
return createErrorResponseFromUnknown(error, "Failed to validate proxy pool");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -31,7 +31,9 @@ const PROXY_LEVEL_TO_REGISTRY_SCOPE = {
|
||||
} as const;
|
||||
|
||||
function isSocks5Enabled() {
|
||||
return process.env.ENABLE_SOCKS5_PROXY === "true";
|
||||
// Default ON (opt-out): only an explicit falsey value disables SOCKS5.
|
||||
const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase();
|
||||
return !["false", "0", "no", "off"].includes(raw);
|
||||
}
|
||||
|
||||
function getSupportedProxyTypes() {
|
||||
@@ -106,7 +108,7 @@ function normalizeAndValidateProxy(
|
||||
const type = String(proxy.type || "http").toLowerCase() as NonNullable<ProxyConfigInput["type"]>;
|
||||
if (type === "socks5" && !isSocks5Enabled()) {
|
||||
throw createInvalidProxyError(
|
||||
"SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)"
|
||||
"SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)"
|
||||
);
|
||||
}
|
||||
if (type.startsWith("socks") && type !== "socks5") {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports";
|
||||
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
|
||||
import {
|
||||
validateProxyUrl,
|
||||
upsertUpstreamProxyConfig,
|
||||
@@ -220,6 +221,14 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
const body: typeof validation.data & { password?: string } = { ...validation.data };
|
||||
|
||||
// Sanitize model lockout settings: clamp values to valid bounds so that
|
||||
// stale DB values or hand-crafted requests don't bypass range validation.
|
||||
if (body.modelLockout) {
|
||||
body.modelLockout = resolveModelLockoutSettings({
|
||||
modelLockout: body.modelLockout as Record<string, unknown>,
|
||||
}) as typeof body.modelLockout;
|
||||
}
|
||||
|
||||
// Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the
|
||||
// VALIDATED body so we never trip on stray unknown keys. If any security
|
||||
// key is present, require currentPassword + verify against the stored
|
||||
|
||||
@@ -983,6 +983,7 @@
|
||||
"settingsAuthz": "Authz",
|
||||
"settingsRouting": "Routing",
|
||||
"settingsResilience": "Resilience",
|
||||
"modelLockout": "Model Lockout",
|
||||
"settingsAdvanced": "Advanced",
|
||||
"omniProxySection": "OmniProxy",
|
||||
"quotaTracker": "Provider Quota",
|
||||
@@ -5871,7 +5872,21 @@
|
||||
"vercelRelayProjectNameLabel": "Vercel Project Name",
|
||||
"vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
|
||||
"vercelRelayDeploying": "Deploying...",
|
||||
"vercelRelayDeploy": "Deploy"
|
||||
"vercelRelayDeploy": "Deploy",
|
||||
"modelLockout": "Model Lockout",
|
||||
"modelLockoutPageDescription": "Configure which HTTP error codes trigger per-model lockout and control the cooldown behavior.",
|
||||
"modelLockoutEnabled": "Enable Model Lockout",
|
||||
"modelLockoutEnabledDescription": "When enabled, models that fail with configured error codes are temporarily locked to prevent retry loops.",
|
||||
"modelLockoutErrorCodes": "Error Codes",
|
||||
"modelLockoutErrorCodesDescription": "HTTP status codes that trigger model lockout. Type a code and click Add, or select from suggestions.",
|
||||
"modelLockoutBaseCooldown": "Base Cooldown (ms)",
|
||||
"modelLockoutBaseCooldownDescription": "Initial cooldown duration in milliseconds before a model can be retried.",
|
||||
"modelLockoutMaxCooldown": "Max Cooldown (ms)",
|
||||
"modelLockoutMaxCooldownDescription": "Maximum cooldown duration in milliseconds. Prevents excessively long lockouts.",
|
||||
"modelLockoutExponentialBackoff": "Exponential Backoff",
|
||||
"modelLockoutExponentialBackoffDescription": "When enabled, each consecutive failure increases the cooldown duration exponentially.",
|
||||
"modelLockoutMaxBackoffSteps": "Max Backoff Steps",
|
||||
"modelLockoutMaxBackoffStepsDescription": "Maximum number of backoff steps before the cooldown stops growing. The Max Cooldown cap is reached first in most configurations, making this a safety ceiling for when Max Cooldown is raised."
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
|
||||
561
src/lib/arenaEloSync.ts
Normal file
561
src/lib/arenaEloSync.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* arenaEloSync.ts — Arena AI leaderboard ELO sync engine.
|
||||
*
|
||||
* Fetches model intelligence data from the Arena AI leaderboard API
|
||||
* (https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard) and stores
|
||||
* normalised task-fit scores in the `model_intelligence` DB table.
|
||||
*
|
||||
* Resolution order: user overrides > synced arena ELO > defaults
|
||||
*
|
||||
* Opt-in via ARENA_ELO_SYNC_ENABLED=true (default: false).
|
||||
*/
|
||||
|
||||
import { backupDbFile } from "./db/backup";
|
||||
import {
|
||||
bulkUpsertModelIntelligence,
|
||||
deleteExpiredIntelligence,
|
||||
deleteModelIntelligenceBySource,
|
||||
type ModelIntelligenceEntry,
|
||||
} from "./db/modelIntelligence";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single model entry from the Arena AI leaderboard.
|
||||
*/
|
||||
export interface ArenaModelEntry {
|
||||
/** Leaderboard rank (1-based). */
|
||||
rank: number;
|
||||
/** Model identifier (may include vendor prefix like "anthropic/claude-opus"). */
|
||||
model: string;
|
||||
/** Vendor / provider name (e.g. "Anthropic", "OpenAI"). */
|
||||
vendor: string;
|
||||
/** ELO score (higher = better). */
|
||||
score: number;
|
||||
/** Confidence interval half-width. */
|
||||
ci: number;
|
||||
/** Total number of human preference votes. */
|
||||
votes: number;
|
||||
/** License type (e.g. "proprietary", "open"). */
|
||||
license: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata + models for a single leaderboard category.
|
||||
*/
|
||||
export interface ArenaLeaderboardData {
|
||||
/** Leaderboard metadata. */
|
||||
meta: {
|
||||
/** Leaderboard category name (e.g. "text", "code"). */
|
||||
leaderboard: string;
|
||||
/** Total number of models in this leaderboard. */
|
||||
model_count: number;
|
||||
};
|
||||
/** Ranked model entries. */
|
||||
models: ArenaModelEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map of leaderboard category → leaderboard data.
|
||||
*/
|
||||
export interface ArenaLeaderboardMap {
|
||||
[category: string]: ArenaLeaderboardData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a sync operation.
|
||||
*/
|
||||
export interface SyncResult {
|
||||
/** Whether the sync completed successfully. */
|
||||
success: boolean;
|
||||
/** Number of model intelligence entries stored. */
|
||||
modelCount: number;
|
||||
/** Source identifier (always "arena_elo"). */
|
||||
source: string;
|
||||
/** Error message if sync failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current status of the Arena ELO sync subsystem.
|
||||
*/
|
||||
export interface SyncStatus {
|
||||
/** Whether periodic sync is enabled via env var. */
|
||||
enabled: boolean;
|
||||
/** ISO timestamp of last successful sync, or null. */
|
||||
lastSync: string | null;
|
||||
/** Number of models stored in last successful sync. */
|
||||
lastSyncModelCount: number;
|
||||
/** ISO timestamp of next scheduled sync, or null. */
|
||||
nextSync: string | null;
|
||||
/** Configured sync interval in milliseconds. */
|
||||
intervalMs: number;
|
||||
/** Active data sources. */
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────
|
||||
|
||||
const ARENA_ELO_API_BASE =
|
||||
"https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard";
|
||||
|
||||
/** Leaderboard categories to fetch from the Arena API. */
|
||||
const FETCH_CATEGORIES = ["text", "code"] as const;
|
||||
|
||||
/**
|
||||
* Maps Arena leaderboard categories to OmniRoute task-type categories.
|
||||
*
|
||||
* - "text" leaderboard → default, review, documentation, debugging
|
||||
* - "code" leaderboard → coding
|
||||
* - "vision" leaderboard is intentionally skipped (not relevant for text fitness)
|
||||
*/
|
||||
const CATEGORY_TASK_MAP: Record<string, string[]> = {
|
||||
text: ["default", "review", "documentation", "debugging"],
|
||||
code: ["coding"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Known vendor prefixes to strip from model names.
|
||||
* E.g. "anthropic/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking"
|
||||
*/
|
||||
const VENDOR_PREFIXES = [
|
||||
"anthropic/",
|
||||
"openai/",
|
||||
"google/",
|
||||
"meta/",
|
||||
"mistral/",
|
||||
"deepseek/",
|
||||
"xai/",
|
||||
"cohere/",
|
||||
"qwen/",
|
||||
"alibaba/",
|
||||
"nvidia/",
|
||||
"01-ai/",
|
||||
"phind/",
|
||||
"zerox/",
|
||||
"together/",
|
||||
"fireworks/",
|
||||
"perplexity/",
|
||||
"ai21/",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* OmniRoute model aliases: canonical name → known aliases.
|
||||
* Creates additional DB entries for each alias so that models
|
||||
* are findable under any name OmniRoute uses internally.
|
||||
*/
|
||||
const MODEL_ALIAS_MAP: Record<string, string[]> = {
|
||||
"claude-opus-4-6-thinking": ["claude-opus-4", "anthropic/claude-opus-4"],
|
||||
"claude-sonnet-4-5": ["claude-sonnet-4.5", "anthropic/claude-sonnet-4.5"],
|
||||
"gpt-5.5": ["openai/gpt-5.5", "gpt-5"],
|
||||
"gemini-3-flash": ["google/gemini-3-flash", "gemini-flash"],
|
||||
"deepseek-r1": ["deepseek/deepseek-r1", "if/deepseek-r1"],
|
||||
"kimi-k2-thinking": ["moonshot/kimi-k2", "qw/kimi-k2"],
|
||||
"qwen3-coder-plus": ["qw/qwen3-coder-plus", "alibaba/qwen3-coder"],
|
||||
"llama-4": ["meta/llama-4", "llama4"],
|
||||
};
|
||||
|
||||
/** Votes threshold for "high" confidence. */
|
||||
const HIGH_CONFIDENCE_VOTES = 5000;
|
||||
|
||||
/** Votes threshold for "medium" confidence. */
|
||||
const MEDIUM_CONFIDENCE_VOTES = 1000;
|
||||
|
||||
/** Intelligence entry expiration: 7 days after sync. */
|
||||
const EXPIRY_DAYS = 7;
|
||||
|
||||
const parsedInterval = parseInt(process.env.ARENA_ELO_SYNC_INTERVAL || "86400", 10);
|
||||
const SYNC_INTERVAL_MS =
|
||||
Number.isFinite(parsedInterval) && parsedInterval > 0
|
||||
? parsedInterval * 1000
|
||||
: 86400 * 1000;
|
||||
|
||||
// ─── Periodic sync state ─────────────────────────────────
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastSyncTime: string | null = null;
|
||||
let lastSyncModelCount = 0;
|
||||
let activeSyncIntervalMs = SYNC_INTERVAL_MS;
|
||||
let firstSyncDone = false;
|
||||
let syncInProgress = false;
|
||||
|
||||
// ─── Model name normalization ────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a model name from the Arena leaderboard.
|
||||
*
|
||||
* Lowercases the name and strips known vendor prefixes
|
||||
* (e.g. "anthropic/claude-opus-4" → "claude-opus-4").
|
||||
*
|
||||
* @param rawName - The raw model name from the API response.
|
||||
* @returns The cleaned, lowercase model name.
|
||||
*/
|
||||
export function normalizeModelName(rawName: string): string {
|
||||
let name = rawName.toLowerCase();
|
||||
for (const prefix of VENDOR_PREFIXES) {
|
||||
if (name.startsWith(prefix)) {
|
||||
name = name.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// ─── Core: Fetch ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch leaderboards from the Arena AI API for all configured categories.
|
||||
*
|
||||
* Fetches "text" and "code" leaderboards concurrently and returns
|
||||
* a map of category → leaderboard data.
|
||||
*
|
||||
* @returns Map of leaderboard category to its data.
|
||||
* @throws If all category fetches fail (individual failures are logged and skipped).
|
||||
*/
|
||||
export async function fetchArenaLeaderboards(): Promise<ArenaLeaderboardMap> {
|
||||
const result: ArenaLeaderboardMap = {};
|
||||
const errors: string[] = [];
|
||||
|
||||
const fetches = FETCH_CATEGORIES.map(async (category) => {
|
||||
const url = `${ARENA_ELO_API_BASE}?name=${category}`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Arena API fetch failed for "${category}" [${response.status}]: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
try {
|
||||
result[category] = JSON.parse(text) as ArenaLeaderboardData;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Arena API returned invalid JSON for "${category}" (${text.slice(0, 100)}...)`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[ARENA_ELO_SYNC] Failed to fetch "${category}" leaderboard: ${message}`);
|
||||
errors.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(fetches);
|
||||
|
||||
if (Object.keys(result).length === 0) {
|
||||
throw new Error(
|
||||
`All Arena leaderboard fetches failed: ${errors.join("; ")}`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Core: Transform ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute confidence level based on vote count.
|
||||
*
|
||||
* @param votes - Number of human preference votes.
|
||||
* @returns "high" (≥5000), "medium" (≥1000), or "low" (<1000).
|
||||
*/
|
||||
function computeConfidence(votes: number): "high" | "medium" | "low" {
|
||||
if (votes >= HIGH_CONFIDENCE_VOTES) return "high";
|
||||
if (votes >= MEDIUM_CONFIDENCE_VOTES) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform raw Arena leaderboard data into model intelligence entries.
|
||||
*
|
||||
* For each leaderboard category, normalizes ELO scores into task-fit values
|
||||
* in the range [0.4, 0.98] using the formula:
|
||||
*
|
||||
* taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo || 1))
|
||||
*
|
||||
* This ensures scores never reach 0 or 1, leaving room for user overrides.
|
||||
* Models with fewer than 100 votes are marked as confidence="low".
|
||||
*
|
||||
* Leaderboard categories are mapped to OmniRoute task types:
|
||||
* - "text" → default, review, documentation, debugging
|
||||
* - "code" → coding
|
||||
*
|
||||
* Known OmniRoute model aliases are also expanded into additional entries.
|
||||
*
|
||||
* @param data - Map of leaderboard category → Arena leaderboard data.
|
||||
* @returns Array of model intelligence entries ready for DB upsert.
|
||||
*/
|
||||
export function transformToModelIntelligence(
|
||||
data: ArenaLeaderboardMap
|
||||
): Array<Omit<ModelIntelligenceEntry, "syncedAt">> {
|
||||
const entries: Array<Omit<ModelIntelligenceEntry, "syncedAt">> = [];
|
||||
const expiresAt = new Date(
|
||||
Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
|
||||
for (const [category, leaderboard] of Object.entries(data)) {
|
||||
const taskCategories = CATEGORY_TASK_MAP[category];
|
||||
if (!taskCategories) continue;
|
||||
|
||||
const models = Array.isArray(leaderboard.models) ? leaderboard.models : [];
|
||||
if (models.length === 0) continue;
|
||||
|
||||
// Compute ELO range for normalization
|
||||
const eloScores = models.map((m) => m.score);
|
||||
const minElo = Math.min(...eloScores);
|
||||
const maxElo = Math.max(...eloScores);
|
||||
const eloRange = maxElo - minElo || 1;
|
||||
|
||||
for (const model of models) {
|
||||
const normalizedModel = normalizeModelName(model.model);
|
||||
const confidence = computeConfidence(model.votes);
|
||||
const taskFit = 0.4 + 0.58 * ((model.score - minElo) / eloRange);
|
||||
|
||||
for (const taskCategory of taskCategories) {
|
||||
const entry: Omit<ModelIntelligenceEntry, "syncedAt"> = {
|
||||
model: normalizedModel,
|
||||
category: taskCategory,
|
||||
source: "arena_elo",
|
||||
score: Math.round(taskFit * 10000) / 10000,
|
||||
eloRaw: model.score,
|
||||
confidence,
|
||||
expiresAt,
|
||||
};
|
||||
entries.push(entry);
|
||||
|
||||
// Expand known aliases
|
||||
const aliases = MODEL_ALIAS_MAP[normalizedModel];
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
entries.push({
|
||||
...entry,
|
||||
model: alias,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── Main sync function ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch, transform, and store Arena ELO intelligence data.
|
||||
*
|
||||
* Pipeline: delete expired → fetch leaderboards → transform → bulk upsert.
|
||||
* All errors are caught and logged — sync is never fatal.
|
||||
*
|
||||
* @param dryRun - If true, fetches and transforms but does not write to DB.
|
||||
* @returns Sync result with model count and success status.
|
||||
*/
|
||||
export async function syncArenaElo(dryRun = false): Promise<SyncResult> {
|
||||
if (syncInProgress) {
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
source: "arena_elo",
|
||||
error: "Sync already in progress",
|
||||
};
|
||||
}
|
||||
syncInProgress = true;
|
||||
try {
|
||||
// Backup DB before first sync (same pattern as pricingSync)
|
||||
if (!firstSyncDone && !dryRun) {
|
||||
backupDbFile("pre-arena-elo-sync");
|
||||
firstSyncDone = true;
|
||||
}
|
||||
|
||||
// Clean up stale entries before writing new ones
|
||||
if (!dryRun) {
|
||||
try {
|
||||
deleteExpiredIntelligence();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[ARENA_ELO_SYNC] Failed to delete expired intelligence: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const leaderboards = await fetchArenaLeaderboards();
|
||||
const entries = transformToModelIntelligence(leaderboards);
|
||||
|
||||
if (!dryRun && entries.length > 0) {
|
||||
try {
|
||||
bulkUpsertModelIntelligence(entries);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[ARENA_ELO_SYNC] Failed to bulk upsert intelligence: ${message}`
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
source: "arena_elo",
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
lastSyncTime = new Date().toISOString();
|
||||
lastSyncModelCount = entries.length;
|
||||
}
|
||||
|
||||
const countLabel = dryRun ? "would sync" : "synced";
|
||||
console.log(
|
||||
`[ARENA_ELO_SYNC] ${countLabel} ${entries.length} model intelligence entries from Arena leaderboards`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
modelCount: entries.length,
|
||||
source: "arena_elo",
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[ARENA_ELO_SYNC] Sync failed:", message);
|
||||
return {
|
||||
success: false,
|
||||
modelCount: 0,
|
||||
source: "arena_elo",
|
||||
error: message,
|
||||
};
|
||||
} finally {
|
||||
syncInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Clear synced data ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clear all synced intelligence data (arena_elo source).
|
||||
*
|
||||
* Iterates through all arena_elo entries and deletes them one by one,
|
||||
* since the DB module provides per-key deletion. This is used by the
|
||||
* DELETE /api/intelligence/sync endpoint.
|
||||
*/
|
||||
export function clearSyncedIntelligence(): void {
|
||||
const deleted = deleteModelIntelligenceBySource("arena_elo");
|
||||
console.log(`[ARENA_ELO_SYNC] Cleared ${deleted} arena_elo intelligence entries`);
|
||||
}
|
||||
|
||||
// ─── Periodic sync ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start periodic Arena ELO sync (non-blocking).
|
||||
*
|
||||
* Performs an initial sync immediately, then schedules periodic syncs
|
||||
* at the configured interval. The timer is unref'd so it won't prevent
|
||||
* the Node.js process from exiting.
|
||||
*
|
||||
* @param intervalMs - Override interval in milliseconds (defaults to env or 86400s).
|
||||
*/
|
||||
function startPeriodicSync(intervalMs?: number): void {
|
||||
if (syncTimer) return; // Already running
|
||||
|
||||
const interval = intervalMs ?? SYNC_INTERVAL_MS;
|
||||
activeSyncIntervalMs = interval;
|
||||
console.log(
|
||||
`[ARENA_ELO_SYNC] Starting periodic sync every ${interval / 1000}s`
|
||||
);
|
||||
|
||||
// Initial sync (non-blocking)
|
||||
syncArenaElo()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[ARENA_ELO_SYNC] Initial sync complete: ${result.modelCount} model intelligence entries`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"[ARENA_ELO_SYNC] Initial sync error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
});
|
||||
|
||||
syncTimer = setInterval(() => {
|
||||
syncArenaElo()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
console.log(
|
||||
`[ARENA_ELO_SYNC] Periodic sync complete: ${result.modelCount} entries`
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"[ARENA_ELO_SYNC] Periodic sync error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
});
|
||||
}, interval);
|
||||
|
||||
// Prevent the timer from keeping the process alive
|
||||
if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) {
|
||||
(syncTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic Arena ELO sync and clean up the timer.
|
||||
*/
|
||||
export function stopArenaEloSync(): void {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
console.log("[ARENA_ELO_SYNC] Periodic sync stopped");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Arena ELO sync status.
|
||||
*
|
||||
* @returns Sync status including enabled flag, last sync time, model count,
|
||||
* next scheduled sync time, interval, and active sources.
|
||||
*/
|
||||
export function getArenaEloSyncStatus(): SyncStatus {
|
||||
const enabled = process.env.ARENA_ELO_SYNC_ENABLED === "true";
|
||||
return {
|
||||
enabled,
|
||||
lastSync: lastSyncTime,
|
||||
lastSyncModelCount,
|
||||
nextSync:
|
||||
syncTimer && lastSyncTime
|
||||
? new Date(
|
||||
new Date(lastSyncTime).getTime() + activeSyncIntervalMs
|
||||
).toISOString()
|
||||
: null,
|
||||
intervalMs: activeSyncIntervalMs,
|
||||
sources: ["arena_elo"],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Init (called from server-init.ts) ───────────────────
|
||||
|
||||
/**
|
||||
* Initialize Arena ELO sync if enabled via environment variable.
|
||||
*
|
||||
* Reads `ARENA_ELO_SYNC_ENABLED` (default: false). When enabled,
|
||||
* starts periodic sync with the interval from `ARENA_ELO_SYNC_INTERVAL`
|
||||
* (default: 86400 seconds / daily).
|
||||
*
|
||||
* All errors during initialization or the initial sync are caught and logged
|
||||
* — initialization is never fatal.
|
||||
*/
|
||||
export async function initArenaEloSync(): Promise<void> {
|
||||
if (process.env.ARENA_ELO_SYNC_ENABLED !== "true") {
|
||||
console.log(
|
||||
"[ARENA_ELO_SYNC] Disabled (set ARENA_ELO_SYNC_ENABLED=true to enable)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
startPeriodicSync();
|
||||
}
|
||||
25
src/lib/db/migrations/097_model_intelligence.sql
Normal file
25
src/lib/db/migrations/097_model_intelligence.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 097_model_intelligence.sql
|
||||
-- Model intelligence scores: per-model task-fitness from arena ELO, models.dev
|
||||
-- tier rankings, and user overrides. Supports resolution chain (user_override
|
||||
-- > arena_elo > models_dev_tier) and auto-expiry for stale synced data.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_intelligence (
|
||||
model TEXT NOT NULL,
|
||||
source TEXT NOT NULL, -- 'arena_elo' | 'models_dev_tier' | 'user_override'
|
||||
category TEXT NOT NULL, -- 'coding' | 'review' | 'planning' | 'analysis' | 'debugging' | 'documentation' | 'default'
|
||||
score REAL NOT NULL, -- [0..1] normalized fitness score
|
||||
elo_raw INTEGER, -- original ELO if source='arena_elo'
|
||||
confidence TEXT, -- 'high' | 'medium' | 'low' or CI string like '+10/-8'
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT, -- TTL for auto-invalidated scores (NULL = never expires)
|
||||
PRIMARY KEY (model, source, category)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mi_model_category
|
||||
ON model_intelligence (model, category);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mi_source
|
||||
ON model_intelligence (source);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mi_expires
|
||||
ON model_intelligence (expires_at) WHERE expires_at IS NOT NULL;
|
||||
232
src/lib/db/modelIntelligence.ts
Normal file
232
src/lib/db/modelIntelligence.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* modelIntelligence.ts — DB domain module for model task-fitness scores.
|
||||
*
|
||||
* Persists per-model intelligence from arena ELO, models.dev tier rankings,
|
||||
* and user overrides. Resolution chain: user_override → arena_elo → models_dev_tier.
|
||||
*
|
||||
* @see Migration 097_model_intelligence.sql
|
||||
*/
|
||||
|
||||
import { getDbInstance, rowToCamel } from "./core";
|
||||
|
||||
// ──────────────── Types ────────────────
|
||||
|
||||
export interface ModelIntelligenceEntry {
|
||||
model: string;
|
||||
source: string;
|
||||
category: string;
|
||||
score: number;
|
||||
eloRaw: number | null;
|
||||
confidence: string | null;
|
||||
syncedAt: string;
|
||||
expiresAt: string | null;
|
||||
votes?: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
// ──────────────── Helpers ────────────────
|
||||
|
||||
function rowToEntry(row: Record<string, unknown>): ModelIntelligenceEntry {
|
||||
const camel = rowToCamel(row) ?? {};
|
||||
return {
|
||||
model: String(camel.model ?? ""),
|
||||
source: String(camel.source ?? ""),
|
||||
category: String(camel.category ?? ""),
|
||||
score: typeof camel.score === "number" ? camel.score : 0,
|
||||
eloRaw: typeof camel.eloRaw === "number" ? camel.eloRaw : null,
|
||||
confidence: typeof camel.confidence === "string" ? camel.confidence : null,
|
||||
syncedAt: String(camel.syncedAt ?? ""),
|
||||
expiresAt: typeof camel.expiresAt === "string" ? camel.expiresAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────── CRUD ────────────────
|
||||
|
||||
export function getModelIntelligence(model: string, category: string): ModelIntelligenceEntry | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT * FROM model_intelligence
|
||||
WHERE model = ? AND category = ?
|
||||
AND source IN ('user_override', 'arena_elo', 'models_dev_tier')
|
||||
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))
|
||||
ORDER BY CASE source
|
||||
WHEN 'user_override' THEN 1
|
||||
WHEN 'arena_elo' THEN 2
|
||||
WHEN 'models_dev_tier' THEN 3
|
||||
END
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(model, category) as Record<string, unknown> | undefined;
|
||||
|
||||
return row ? rowToEntry(row) : null;
|
||||
}
|
||||
|
||||
export function getModelIntelligenceBySource(
|
||||
model: string,
|
||||
source: string,
|
||||
category: string
|
||||
): ModelIntelligenceEntry | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT * FROM model_intelligence
|
||||
WHERE model = ? AND source = ? AND category = ?
|
||||
AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))`
|
||||
)
|
||||
.get(model, source, category) as Record<string, unknown> | undefined;
|
||||
|
||||
return row ? rowToEntry(row) : null;
|
||||
}
|
||||
|
||||
export function upsertModelIntelligence(entry: Omit<ModelIntelligenceEntry, "syncedAt">): void {
|
||||
const db = getDbInstance();
|
||||
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO model_intelligence
|
||||
(model, source, category, score, elo_raw, confidence, synced_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
|
||||
).run(
|
||||
entry.model,
|
||||
entry.source,
|
||||
entry.category,
|
||||
entry.score,
|
||||
entry.eloRaw ?? null,
|
||||
entry.confidence ?? null,
|
||||
entry.expiresAt ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteModelIntelligence(model: string, source: string, category: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(
|
||||
`DELETE FROM model_intelligence
|
||||
WHERE model = ? AND source = ? AND category = ?`
|
||||
)
|
||||
.run(model, source, category);
|
||||
return (result.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function deleteExpiredIntelligence(source?: string): number {
|
||||
const db = getDbInstance();
|
||||
const conditions = ["expires_at IS NOT NULL", "datetime(expires_at) < datetime('now')"];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (source) {
|
||||
conditions.push("source = ?");
|
||||
params.push(source);
|
||||
}
|
||||
|
||||
const where = conditions.join(" AND ");
|
||||
const result = db
|
||||
.prepare(`DELETE FROM model_intelligence WHERE ${where}`)
|
||||
.run(...params);
|
||||
return result.changes ?? 0;
|
||||
}
|
||||
|
||||
export function deleteModelIntelligenceBySource(source: string): number {
|
||||
const db = getDbInstance();
|
||||
const result = db
|
||||
.prepare(`DELETE FROM model_intelligence WHERE source = ?`)
|
||||
.run(source);
|
||||
return result.changes ?? 0;
|
||||
}
|
||||
|
||||
export function listModelIntelligence(filters?: {
|
||||
source?: string;
|
||||
category?: string;
|
||||
}): ModelIntelligenceEntry[] {
|
||||
const db = getDbInstance();
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filters?.source) {
|
||||
conditions.push("source = ?");
|
||||
params.push(filters.source);
|
||||
}
|
||||
if (filters?.category) {
|
||||
conditions.push("category = ?");
|
||||
params.push(filters.category);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `SELECT * FROM model_intelligence ${where} ORDER BY model ASC, source ASC, category ASC`;
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
|
||||
return rows.map(rowToEntry);
|
||||
}
|
||||
|
||||
export function bulkUpsertModelIntelligence(entries: Array<Omit<ModelIntelligenceEntry, "syncedAt">>): number {
|
||||
if (entries.length === 0) return 0;
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare(
|
||||
`INSERT OR REPLACE INTO model_intelligence
|
||||
(model, source, category, score, elo_raw, confidence, synced_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
|
||||
);
|
||||
|
||||
const upsertAll = db.transaction(() => {
|
||||
let count = 0;
|
||||
for (const entry of entries) {
|
||||
stmt.run(
|
||||
entry.model,
|
||||
entry.source,
|
||||
entry.category,
|
||||
entry.score,
|
||||
entry.eloRaw ?? null,
|
||||
entry.confidence ?? null,
|
||||
entry.expiresAt ?? null
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
});
|
||||
|
||||
return upsertAll();
|
||||
}
|
||||
|
||||
export function getResolvedTaskFitness(model: string, category: string): number | null {
|
||||
const entry = getModelIntelligence(model, category);
|
||||
return entry ? entry.score : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a user_override entry for a model × category combination.
|
||||
* Used by taskFitness.ts resolution chain as Layer 1 (highest priority).
|
||||
*
|
||||
* @param model - Model identifier
|
||||
* @param category - Task category
|
||||
* @param score - Fitness score [0..1]
|
||||
*/
|
||||
export function setUserFitnessOverrideEntry(
|
||||
model: string,
|
||||
category: string,
|
||||
score: number,
|
||||
): void {
|
||||
upsertModelIntelligence({
|
||||
model: model.toLowerCase(),
|
||||
source: "user_override",
|
||||
category: category.toLowerCase(),
|
||||
score: Math.max(0, Math.min(1, score)),
|
||||
eloRaw: null,
|
||||
confidence: null,
|
||||
expiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user_override entry for a model × category combination.
|
||||
*
|
||||
* @param model - Model identifier
|
||||
* @param category - Task category
|
||||
* @returns true if an entry was deleted
|
||||
*/
|
||||
export function deleteUserFitnessOverrideEntry(
|
||||
model: string,
|
||||
category: string,
|
||||
): boolean {
|
||||
return deleteModelIntelligence(model.toLowerCase(), "user_override", category.toLowerCase());
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
68
src/lib/providerModels/geminiModelsParser.ts
Normal file
68
src/lib/providerModels/geminiModelsParser.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Parses the Google Generative Language `v1beta/models` listing into discovery models.
|
||||
*
|
||||
* Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints:
|
||||
* - generateContent / generateAnswer → "chat"
|
||||
* - predict / predictLongRunning → "images" (Imagen models)
|
||||
* - embedContent → "embeddings"
|
||||
* - bidiGenerateContent → "audio"
|
||||
*
|
||||
* This is shared by the `gemini` discovery config and the `vertex` discovery branch: Vertex AI
|
||||
* Express keys (and Service Account JSON via a minted OAuth token) list models from the same
|
||||
* endpoint, so image models (imagen-*, gemini-*-image) surface dynamically instead of being
|
||||
* limited to the small static registry list.
|
||||
*/
|
||||
const METHOD_TO_ENDPOINT: Record<string, string> = {
|
||||
generateContent: "chat",
|
||||
embedContent: "embeddings",
|
||||
predict: "images",
|
||||
predictLongRunning: "images",
|
||||
bidiGenerateContent: "audio",
|
||||
generateAnswer: "chat",
|
||||
};
|
||||
|
||||
const IGNORED_METHODS = new Set([
|
||||
"countTokens",
|
||||
"countTextTokens",
|
||||
"createCachedContent",
|
||||
"batchGenerateContent",
|
||||
"asyncBatchEmbedContent",
|
||||
]);
|
||||
|
||||
export interface GeminiDiscoveryModel {
|
||||
id: string;
|
||||
name: string;
|
||||
supportedEndpoints: string[];
|
||||
inputTokenLimit?: number;
|
||||
outputTokenLimit?: number;
|
||||
description?: string;
|
||||
supportsThinking?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
|
||||
return (data?.models || []).map((m: Record<string, unknown>) => {
|
||||
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
|
||||
? (m.supportedGenerationMethods as string[])
|
||||
: [];
|
||||
const endpoints = [
|
||||
...new Set(
|
||||
methods
|
||||
.filter((method) => !IGNORED_METHODS.has(method))
|
||||
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
|
||||
),
|
||||
];
|
||||
if (endpoints.length === 0) endpoints.push("chat");
|
||||
|
||||
return {
|
||||
...m,
|
||||
id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""),
|
||||
name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""),
|
||||
supportedEndpoints: endpoints,
|
||||
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
|
||||
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
|
||||
...(typeof m.description === "string" ? { description: m.description } : {}),
|
||||
...(m.thinking === true ? { supportsThinking: true } : {}),
|
||||
} as GeminiDiscoveryModel;
|
||||
});
|
||||
}
|
||||
@@ -3954,8 +3954,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
},
|
||||
vertex: async ({ apiKey }: any) => {
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } =
|
||||
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
|
||||
await import("@omniroute/open-sse/executors/vertex.ts");
|
||||
// Express-mode API keys are opaque strings sent directly as the ?key= query param — there is
|
||||
// no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it).
|
||||
if (isExpressApiKey(apiKey)) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
const sa = parseSAFromApiKey(apiKey);
|
||||
// Validates credentials by successfully successfully exchanging them for a JWT from Google Identity
|
||||
await getAccessToken(sa);
|
||||
@@ -3966,8 +3971,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
},
|
||||
"vertex-partner": async ({ apiKey }: any) => {
|
||||
try {
|
||||
const { parseSAFromApiKey, getAccessToken } =
|
||||
const { parseSAFromApiKey, getAccessToken, isExpressApiKey } =
|
||||
await import("@omniroute/open-sse/executors/vertex.ts");
|
||||
if (isExpressApiKey(apiKey)) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
const sa = parseSAFromApiKey(apiKey);
|
||||
await getAccessToken(sa);
|
||||
return { valid: true, error: null };
|
||||
|
||||
388
src/lib/proxyEgress.ts
Normal file
388
src/lib/proxyEgress.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Proxy Egress IP visibility.
|
||||
*
|
||||
* The proxy logs already capture the INBOUND client IP (x-forwarded-for), but
|
||||
* NOT the OUTBOUND/egress IP — the address the upstream actually sees. For
|
||||
* rotating providers (codex/openai) this is critical: when several accounts
|
||||
* egress through the SAME IP at high volume, the provider flags it as anomaly
|
||||
* and revokes the tokens ("Your authentication token has been invalidated").
|
||||
*
|
||||
* This module resolves the real egress IP (via an echo-IP service through the
|
||||
* resolved proxy/dispatcher) and detects same-rotation-group accounts sharing
|
||||
* an egress IP, so the operator can confirm exactly which IP each account is
|
||||
* entering and leaving by.
|
||||
*/
|
||||
import { request as undiciRequest } from "undici";
|
||||
import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts";
|
||||
import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts";
|
||||
|
||||
const EGRESS_ECHO_URL = "https://api64.ipify.org?format=json";
|
||||
const EGRESS_PROBE_TIMEOUT_MS = 6000;
|
||||
const EGRESS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export interface EgressProbeResult {
|
||||
ip: string | null;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type EgressProbe = (proxyUrl: string | null) => Promise<EgressProbeResult>;
|
||||
|
||||
const egressCache = new Map<string, { ip: string | null; at: number }>();
|
||||
|
||||
async function defaultEgressProbe(proxyUrl: string | null): Promise<EgressProbeResult> {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), EGRESS_PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const dispatcher = proxyUrl ? createProxyDispatcher(proxyUrl) : undefined;
|
||||
const res = await undiciRequest(EGRESS_ECHO_URL, {
|
||||
method: "GET",
|
||||
dispatcher,
|
||||
signal: controller.signal,
|
||||
headersTimeout: EGRESS_PROBE_TIMEOUT_MS,
|
||||
bodyTimeout: EGRESS_PROBE_TIMEOUT_MS,
|
||||
});
|
||||
const text = await res.body.text();
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
ip = (JSON.parse(text) as { ip?: string }).ip ?? null;
|
||||
} catch {
|
||||
// non-JSON body — leave ip null
|
||||
}
|
||||
return { ip, latencyMs: Date.now() - start };
|
||||
} catch (error) {
|
||||
return {
|
||||
ip: null,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
let probe: EgressProbe = defaultEgressProbe;
|
||||
|
||||
/** Test seam: override the network probe. */
|
||||
export function _setEgressProbeForTests(fn: EgressProbe | null): void {
|
||||
probe = fn ?? defaultEgressProbe;
|
||||
}
|
||||
|
||||
export function clearEgressCache(): void {
|
||||
egressCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous read of the cached egress IP for a proxy URL (null = direct).
|
||||
* Non-blocking — used by the request hot path to log the egress IP without an
|
||||
* echo-IP round-trip. Returns null if not yet probed.
|
||||
*/
|
||||
export function getCachedEgressIp(proxyUrl: string | null): string | null {
|
||||
const cached = egressCache.get(proxyUrl ?? "__direct__");
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.at >= EGRESS_CACHE_TTL_MS) return null;
|
||||
return cached.ip;
|
||||
}
|
||||
|
||||
const warmingInFlight = new Set<string>();
|
||||
|
||||
/**
|
||||
* Fire-and-forget: populate the egress cache for a proxy URL in the background
|
||||
* so subsequent proxy log lines carry the real egress IP. Deduped per URL.
|
||||
*/
|
||||
export function warmEgressIp(proxyUrl: string | null): void {
|
||||
const key = proxyUrl ?? "__direct__";
|
||||
if (warmingInFlight.has(key) || getCachedEgressIp(proxyUrl) !== null) return;
|
||||
warmingInFlight.add(key);
|
||||
void resolveEgressIp(proxyUrl)
|
||||
.catch(() => undefined)
|
||||
.finally(() => warmingInFlight.delete(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the egress IP for a given proxy URL (null = direct/host IP).
|
||||
* Cached per proxyUrl to avoid an echo-IP round-trip on every call.
|
||||
*/
|
||||
export async function resolveEgressIp(
|
||||
proxyUrl: string | null,
|
||||
opts: { cacheTtlMs?: number; force?: boolean } = {}
|
||||
): Promise<EgressProbeResult & { cached: boolean }> {
|
||||
const key = proxyUrl ?? "__direct__";
|
||||
const ttl = opts.cacheTtlMs ?? EGRESS_CACHE_TTL_MS;
|
||||
const cached = egressCache.get(key);
|
||||
if (!opts.force && cached && Date.now() - cached.at < ttl) {
|
||||
return { ip: cached.ip, latencyMs: 0, cached: true };
|
||||
}
|
||||
const result = await probe(proxyUrl);
|
||||
egressCache.set(key, { ip: result.ip, at: Date.now() });
|
||||
return { ...result, cached: false };
|
||||
}
|
||||
|
||||
export interface ConnectionEgress {
|
||||
connectionId: string;
|
||||
provider: string;
|
||||
account: string | null;
|
||||
proxyLevel: string;
|
||||
proxyHost: string | null;
|
||||
egressIp: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EgressSharingWarning {
|
||||
egressIp: string;
|
||||
rotationGroup: string;
|
||||
connections: string[]; // connectionId/account labels sharing this IP within one rotation group
|
||||
}
|
||||
|
||||
export interface EgressDiagnostic {
|
||||
connections: ConnectionEgress[];
|
||||
byEgressIp: Record<string, string[]>;
|
||||
sharedWithinRotationGroup: EgressSharingWarning[];
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: group egress results by IP and flag IPs shared by ≥2 accounts of the
|
||||
* SAME rotation group (codex+openai share one Auth0 family — the exact
|
||||
* condition that triggers anomaly revocation). Direct/unknown IPs are reported
|
||||
* but only same-group sharing is a warning.
|
||||
*/
|
||||
export function analyzeEgressSharing(connections: ConnectionEgress[]): {
|
||||
byEgressIp: Record<string, string[]>;
|
||||
sharedWithinRotationGroup: EgressSharingWarning[];
|
||||
} {
|
||||
const byEgressIp: Record<string, string[]> = {};
|
||||
// ip -> rotationGroup -> labels
|
||||
const byIpGroup = new Map<string, Map<string, string[]>>();
|
||||
|
||||
for (const c of connections) {
|
||||
if (!c.egressIp) continue;
|
||||
const label = c.account || c.connectionId;
|
||||
(byEgressIp[c.egressIp] ??= []).push(label);
|
||||
|
||||
const group = rotationGroupFor(c.provider) || `provider:${c.provider}`;
|
||||
let groups = byIpGroup.get(c.egressIp);
|
||||
if (!groups) {
|
||||
groups = new Map();
|
||||
byIpGroup.set(c.egressIp, groups);
|
||||
}
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push(label);
|
||||
groups.set(group, list);
|
||||
}
|
||||
|
||||
const sharedWithinRotationGroup: EgressSharingWarning[] = [];
|
||||
for (const [egressIp, groups] of byIpGroup) {
|
||||
for (const [rotationGroup, labels] of groups) {
|
||||
if (labels.length >= 2) {
|
||||
sharedWithinRotationGroup.push({ egressIp, rotationGroup, connections: labels });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byEgressIp, sharedWithinRotationGroup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnose egress IPs for every OAuth connection: resolve each connection's
|
||||
* proxy, probe the real egress IP, and flag same-rotation-group IP sharing.
|
||||
*/
|
||||
export async function diagnoseAllEgressIps(deps?: {
|
||||
getConnections?: () => Promise<
|
||||
Array<{ id: string; provider: string; name?: string; email?: string; authType?: string }>
|
||||
>;
|
||||
resolveProxy?: (
|
||||
connectionId: string
|
||||
) => Promise<{ proxy?: unknown; level?: string } | null>;
|
||||
}): Promise<EgressDiagnostic> {
|
||||
const getConnections =
|
||||
deps?.getConnections ??
|
||||
(async () => {
|
||||
const { getProviderConnections } = await import("./localDb");
|
||||
return (await getProviderConnections({ authType: "oauth" })) as Array<{
|
||||
id: string;
|
||||
provider: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}>;
|
||||
});
|
||||
const resolveProxy =
|
||||
deps?.resolveProxy ??
|
||||
(async (connectionId: string) => {
|
||||
const { resolveProxyForConnection } = await import("./db/settings");
|
||||
return resolveProxyForConnection(connectionId);
|
||||
});
|
||||
|
||||
const conns = await getConnections();
|
||||
const results: ConnectionEgress[] = [];
|
||||
|
||||
for (const c of conns) {
|
||||
const resolved = await resolveProxy(c.id);
|
||||
const proxyObj = (resolved?.proxy ?? null) as {
|
||||
type?: string;
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
} | null;
|
||||
const proxyUrl = proxyObj ? proxyConfigToUrl(proxyObj) : null;
|
||||
const egress = await resolveEgressIp(proxyUrl);
|
||||
results.push({
|
||||
connectionId: c.id,
|
||||
provider: c.provider,
|
||||
account: c.email || c.name || c.id.slice(0, 8),
|
||||
proxyLevel: resolved?.level || "direct",
|
||||
proxyHost: proxyObj?.host ?? null,
|
||||
egressIp: egress.ip,
|
||||
...(egress.error ? { error: egress.error } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const { byEgressIp, sharedWithinRotationGroup } = analyzeEgressSharing(results);
|
||||
return { connections: results, byEgressIp, sharedWithinRotationGroup };
|
||||
}
|
||||
|
||||
export interface ProxyValidationResult {
|
||||
proxyId: string;
|
||||
host: string;
|
||||
port: number | string;
|
||||
alive: boolean;
|
||||
egressIp: string | null;
|
||||
latencyMs: number;
|
||||
previousStatus: string | null;
|
||||
newStatus: "active" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every proxy in the registry by probing its real egress IP, and
|
||||
* persist the result to `proxy_registry.status` (active/error). Combined with
|
||||
* PROXY_ALIVE_PREDICATE in resolution, a dead proxy is automatically taken out
|
||||
* of rotation — fixing the "all proxies marked active but actually dead" state
|
||||
* that left codex accounts falling back to the shared host /64 IP.
|
||||
*
|
||||
* Deps are injectable for tests.
|
||||
*/
|
||||
export async function validateProxyPool(deps?: {
|
||||
listProxies?: () => Promise<
|
||||
Array<{ id: string; type: string; host: string; port: number | string; username?: string | null; password?: string | null; status?: string | null }>
|
||||
>;
|
||||
markStatus?: (id: string, status: string, meta: { latencyMs: number; egressIp: string | null }) => Promise<void>;
|
||||
}): Promise<ProxyValidationResult[]> {
|
||||
const listProxies =
|
||||
deps?.listProxies ??
|
||||
(async () => {
|
||||
const { listProxies: real } = await import("./db/proxies");
|
||||
return (await real({ includeSecrets: true })) as Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number | string;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
status?: string | null;
|
||||
}>;
|
||||
});
|
||||
const markStatus =
|
||||
deps?.markStatus ??
|
||||
(async (id: string, status: string) => {
|
||||
const { updateProxy } = await import("./db/proxies");
|
||||
await updateProxy(id, { status });
|
||||
});
|
||||
|
||||
const proxies = await listProxies();
|
||||
const report: ProxyValidationResult[] = [];
|
||||
|
||||
for (const p of proxies) {
|
||||
const url = proxyConfigToUrl({
|
||||
type: p.type,
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
username: p.username ?? undefined,
|
||||
password: p.password ?? undefined,
|
||||
});
|
||||
const probe = await resolveEgressIp(url, { force: true });
|
||||
const alive = !!probe.ip && !probe.error;
|
||||
const newStatus: "active" | "error" = alive ? "active" : "error";
|
||||
await markStatus(p.id, newStatus, { latencyMs: probe.latencyMs, egressIp: probe.ip });
|
||||
report.push({
|
||||
proxyId: p.id,
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
alive,
|
||||
egressIp: probe.ip,
|
||||
latencyMs: probe.latencyMs,
|
||||
previousStatus: p.status ?? null,
|
||||
newStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
export interface DistributionPlan {
|
||||
assignments: Array<{ connectionId: string; account: string; proxyId: string }>;
|
||||
unassigned: Array<{ connectionId: string; account: string }>;
|
||||
sharingRisk: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PURE: plan a 1-proxy-per-connection assignment so no two accounts of the same
|
||||
* rotation group share an egress IP (the codex anomaly trigger). Default is
|
||||
* strict 1:1 — extras are left UNASSIGNED (better unrouted than sharing an IP).
|
||||
* allowSharing=true round-robins instead, flagging sharingRisk.
|
||||
*/
|
||||
export function planProxyDistribution(
|
||||
connections: Array<{ id: string; account?: string }>,
|
||||
liveProxyIds: string[],
|
||||
opts: { allowSharing?: boolean } = {}
|
||||
): DistributionPlan {
|
||||
const assignments: DistributionPlan["assignments"] = [];
|
||||
const unassigned: DistributionPlan["unassigned"] = [];
|
||||
let sharingRisk = false;
|
||||
|
||||
connections.forEach((c, i) => {
|
||||
const account = c.account || c.id.slice(0, 8);
|
||||
if (liveProxyIds.length === 0) {
|
||||
unassigned.push({ connectionId: c.id, account });
|
||||
return;
|
||||
}
|
||||
if (opts.allowSharing) {
|
||||
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] });
|
||||
} else if (i < liveProxyIds.length) {
|
||||
assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] });
|
||||
} else {
|
||||
unassigned.push({ connectionId: c.id, account });
|
||||
}
|
||||
});
|
||||
|
||||
if (opts.allowSharing && liveProxyIds.length < connections.length) sharingRisk = true;
|
||||
|
||||
const note =
|
||||
liveProxyIds.length === 0
|
||||
? "No live proxies available — add working proxies before distributing."
|
||||
: liveProxyIds.length < connections.length && !opts.allowSharing
|
||||
? `Only ${liveProxyIds.length} live proxies for ${connections.length} accounts — ${unassigned.length} left unassigned (avoid shared-IP anomaly).`
|
||||
: "1 distinct proxy per account.";
|
||||
|
||||
return { assignments, unassigned, sharingRisk, note };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a distribution plan: assign each proxy to its connection (account scope).
|
||||
*/
|
||||
export async function applyProxyDistribution(
|
||||
plan: DistributionPlan,
|
||||
deps?: { assign?: (connectionId: string, proxyId: string) => Promise<void> }
|
||||
): Promise<{ applied: number }> {
|
||||
const assign =
|
||||
deps?.assign ??
|
||||
(async (connectionId: string, proxyId: string) => {
|
||||
const { assignProxyToScope } = await import("./db/proxies");
|
||||
await assignProxyToScope("account", connectionId, proxyId);
|
||||
});
|
||||
let applied = 0;
|
||||
for (const a of plan.assignments) {
|
||||
await assign(a.connectionId, a.proxyId);
|
||||
applied++;
|
||||
}
|
||||
return { applied };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
95
src/lib/resilience/modelLockoutSettings.ts
Normal file
95
src/lib/resilience/modelLockoutSettings.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
export interface ModelLockoutSettings {
|
||||
enabled: boolean;
|
||||
errorCodes: number[];
|
||||
baseCooldownMs: number;
|
||||
maxCooldownMs: number;
|
||||
maxBackoffSteps: number;
|
||||
useExponentialBackoff: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_MODEL_LOCKOUT_SETTINGS: ModelLockoutSettings = {
|
||||
enabled: false,
|
||||
errorCodes: [403, 404, 429, 502, 503, 504],
|
||||
baseCooldownMs: 120_000,
|
||||
maxCooldownMs: 1_800_000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toInteger(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
options: { min?: number; max?: number } = {}
|
||||
): number {
|
||||
const min = options.min ?? 0;
|
||||
const max = options.max ?? Number.MAX_SAFE_INTEGER;
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string"
|
||||
? Number(value)
|
||||
: fallback;
|
||||
return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback;
|
||||
}
|
||||
|
||||
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") return value !== 0;
|
||||
if (typeof value === "string") return value === "true" || value === "1";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNumberArray(value: unknown, fallback: number[]): number[] {
|
||||
if (Array.isArray(value)) {
|
||||
const nums = value
|
||||
.map((v) => (typeof v === "number" ? v : Number(v)))
|
||||
.filter((n) => Number.isFinite(n) && n >= 100 && n <= 599);
|
||||
return nums.length > 0 ? nums : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function resolveModelLockoutSettings(
|
||||
settings: Record<string, unknown> | null | undefined
|
||||
): ModelLockoutSettings {
|
||||
const record = asRecord(settings);
|
||||
const raw = asRecord(record.modelLockout);
|
||||
const isTest =
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.execArgv.includes("--test") ||
|
||||
process.argv.some((arg) => typeof arg === "string" && arg.includes("test"));
|
||||
|
||||
const baseCooldownMs = toInteger(raw.baseCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.baseCooldownMs, {
|
||||
min: isTest ? 0 : 5_000,
|
||||
max: 600_000,
|
||||
});
|
||||
const maxCooldownMs = Math.max(
|
||||
toInteger(raw.maxCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs, {
|
||||
min: isTest ? 0 : 5_000,
|
||||
max: 3_600_000,
|
||||
}),
|
||||
baseCooldownMs // cap must be >= base or exponential backoff is meaningless
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: toBoolean(raw.enabled, DEFAULT_MODEL_LOCKOUT_SETTINGS.enabled),
|
||||
errorCodes: toNumberArray(raw.errorCodes, DEFAULT_MODEL_LOCKOUT_SETTINGS.errorCodes),
|
||||
baseCooldownMs,
|
||||
maxCooldownMs,
|
||||
maxBackoffSteps: toInteger(
|
||||
raw.maxBackoffSteps,
|
||||
DEFAULT_MODEL_LOCKOUT_SETTINGS.maxBackoffSteps,
|
||||
{ min: 0, max: 20 }
|
||||
),
|
||||
useExponentialBackoff: toBoolean(
|
||||
raw.useExponentialBackoff,
|
||||
DEFAULT_MODEL_LOCKOUT_SETTINGS.useExponentialBackoff
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -76,10 +76,38 @@ function getEffectiveTokenExpiryMs(conn: any): number {
|
||||
return Number.isFinite(expiryMs) ? expiryMs : 0;
|
||||
}
|
||||
|
||||
// ── Refresh circuit breaker ───────────────────────────────────────────────
|
||||
// A refresh that returns null (network blip, dead proxy, unclassified error)
|
||||
// leaves the connection active, so the next 60s sweep retries immediately —
|
||||
// the production refresh loop (claude/aa5dd5cf 1352×, kimi 270×). We track
|
||||
// consecutive failures and back off exponentially so a stuck connection stops
|
||||
// hammering the upstream (and stops flooding the logs) instead of looping.
|
||||
const REFRESH_CIRCUIT_BASE_MIN = 5;
|
||||
const REFRESH_CIRCUIT_MAX_MIN = 240; // cap at 4h
|
||||
|
||||
export function getRefreshBackoffUntil(streak: number, now: string): string {
|
||||
const steps = Math.max(0, streak - 1);
|
||||
const backoffMin = Math.min(REFRESH_CIRCUIT_BASE_MIN * 2 ** steps, REFRESH_CIRCUIT_MAX_MIN);
|
||||
return new Date(new Date(now).getTime() + backoffMin * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
export function isInRefreshBackoff(conn: any, nowMs: number): boolean {
|
||||
const until = conn?.providerSpecificData?.refreshCircuit?.until;
|
||||
if (typeof until !== "string") return false;
|
||||
const untilMs = new Date(until).getTime();
|
||||
return Number.isFinite(untilMs) && untilMs > nowMs;
|
||||
}
|
||||
|
||||
export function buildRefreshFailureUpdate(conn: any, now: string) {
|
||||
const wasExpired = conn.testStatus === "expired";
|
||||
const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0);
|
||||
|
||||
// Circuit breaker: increment the consecutive-failure streak and set an
|
||||
// exponential backoff window so the next sweep skips this connection instead
|
||||
// of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit).
|
||||
const prevStreak = conn.providerSpecificData?.refreshCircuit?.streak ?? 0;
|
||||
const streak = prevStreak + 1;
|
||||
|
||||
return {
|
||||
lastHealthCheckAt: now,
|
||||
// A failed background refresh should not evict otherwise healthy accounts
|
||||
@@ -91,10 +119,28 @@ export function buildRefreshFailureUpdate(conn: any, now: string) {
|
||||
lastErrorType: "token_refresh_failed",
|
||||
lastErrorSource: "oauth",
|
||||
errorCode: "refresh_failed",
|
||||
providerSpecificData: {
|
||||
...(conn.providerSpecificData || {}),
|
||||
refreshCircuit: { streak, until: getRefreshBackoffUntil(streak, now), lastFailAt: now },
|
||||
},
|
||||
...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the refresh circuit breaker state from providerSpecificData after a
|
||||
* successful refresh, so the streak/backoff resets cleanly.
|
||||
*/
|
||||
export function clearRefreshCircuit(
|
||||
providerSpecificData: Record<string, unknown> | null | undefined
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!providerSpecificData || typeof providerSpecificData !== "object") return undefined;
|
||||
if (!("refreshCircuit" in providerSpecificData)) return undefined;
|
||||
const next = { ...providerSpecificData };
|
||||
delete next.refreshCircuit;
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEnvFlagEnabled(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
if (!value) return false;
|
||||
@@ -355,6 +401,14 @@ export async function checkConnection(conn) {
|
||||
|
||||
if (!isAboutToExpire && !shouldRefreshByInterval) return;
|
||||
|
||||
// Circuit breaker: if recent refreshes for this connection failed, wait out
|
||||
// the exponential backoff window instead of retrying every 60s tick. This is
|
||||
// what stops the refresh loop when getAccessToken keeps returning null
|
||||
// (dead proxy / network blip / unclassified upstream error).
|
||||
if (isInRefreshBackoff(conn, Date.now())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`;
|
||||
log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`);
|
||||
|
||||
@@ -427,11 +481,17 @@ export async function checkConnection(conn) {
|
||||
updateData.expiresAt = expiresAt;
|
||||
updateData.tokenExpiresAt = expiresAt;
|
||||
}
|
||||
if (refreshResult.providerSpecificData) {
|
||||
updateData.providerSpecificData = {
|
||||
...(conn.providerSpecificData || {}),
|
||||
...refreshResult.providerSpecificData,
|
||||
};
|
||||
// Merge new providerSpecificData and ALWAYS clear the refresh circuit
|
||||
// breaker streak on a successful refresh.
|
||||
const mergedProviderData = {
|
||||
...(conn.providerSpecificData || {}),
|
||||
...(refreshResult.providerSpecificData || {}),
|
||||
};
|
||||
const clearedProviderData = clearRefreshCircuit(mergedProviderData);
|
||||
if (clearedProviderData !== undefined) {
|
||||
updateData.providerSpecificData = clearedProviderData;
|
||||
} else if (refreshResult.providerSpecificData) {
|
||||
updateData.providerSpecificData = mergedProviderData;
|
||||
}
|
||||
await updateProviderConnection(conn.id, updateData);
|
||||
persistedResult = refreshResult;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -304,6 +304,32 @@ export const updateSettingsSchema = z.object({
|
||||
cliproxyapi_fallback_codes: z.string().max(200).optional(),
|
||||
// CLIProxyAPI model mapping (Record<string, string>)
|
||||
cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(),
|
||||
// Model lockout settings
|
||||
modelLockout: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
errorCodes: z.array(z.number().int().min(100).max(599)).min(0).max(20).optional(),
|
||||
baseCooldownMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5000, "Must be at least 5,000ms")
|
||||
.max(600000, "Must be at most 600,000ms (10 min)")
|
||||
.optional(),
|
||||
maxCooldownMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5000, "Must be at least 5,000ms")
|
||||
.max(3600000, "Must be at most 3,600,000ms (1 h)")
|
||||
.optional(),
|
||||
maxBackoffSteps: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0, "Must be at least 0")
|
||||
.max(20, "Must be at most 20")
|
||||
.optional(),
|
||||
useExponentialBackoff: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const databaseSettingsSchema = z.object(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface ComboDefaults {
|
||||
fallbackDelayMs?: number;
|
||||
maxComboDepth: number;
|
||||
trackMetrics: boolean;
|
||||
reasoningTokenBufferEnabled?: boolean;
|
||||
concurrencyPerModel?: number;
|
||||
queueTimeoutMs?: number;
|
||||
handoffThreshold?: number;
|
||||
|
||||
152
tests/integration/gemini-live-429-classification.test.ts
Normal file
152
tests/integration/gemini-live-429-classification.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Gemini 429 classification integration tests.
|
||||
*
|
||||
* Tests the end-to-end classification path for Gemini rate-limit errors
|
||||
* through OmniRoute. Sends bursts of requests to try to trigger published
|
||||
* RPM/RPD limits, then verifies the classification is correct.
|
||||
*
|
||||
* The tests are "best effort" — if rate limits aren't triggered (Gemini
|
||||
* may be more generous in practice), the test logs a warning and passes
|
||||
* rather than failing. The unit tests in account-fallback-service.test.ts
|
||||
* provide the definitive coverage of classification logic.
|
||||
*
|
||||
* Env vars:
|
||||
* OMNIROUTE_URL — base URL (default http://localhost:20128)
|
||||
* OMNIROUTE_API_KEY — API key for auth (REQUIRED)
|
||||
* TEST_GEMINI_RPM_MODEL — RPM model (default gemini/gemma-4-31b-it)
|
||||
* TEST_GEMINI_RPD_MODEL — RPD model (default gemini/gemini-2.5-flash)
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const API_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
const BASE_URL = process.env.OMNIROUTE_URL || "http://localhost:20128";
|
||||
const RPM_MODEL = process.env.TEST_GEMINI_RPM_MODEL || "gemini/gemma-4-31b-it";
|
||||
const RPD_MODEL = process.env.TEST_GEMINI_RPD_MODEL || "gemini/gemini-2.5-flash";
|
||||
|
||||
const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined;
|
||||
|
||||
async function chat(model: string, content: string) {
|
||||
const res = await fetch(`${BASE_URL}/api/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||
body: JSON.stringify({ model, stream: false, messages: [{ role: "user", content }] }),
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
}
|
||||
|
||||
// ── Test 1: RPM burst ────────────────────────────────────────────────────────
|
||||
|
||||
test(
|
||||
"Gemma 4 RPM burst: try to hit 15 RPM, verify 429 classification if triggered",
|
||||
{ skip },
|
||||
async () => {
|
||||
const BURST = 30;
|
||||
console.error(`\n[RPM] Sending ${BURST} concurrent requests to ${RPM_MODEL} (15 RPM)...`);
|
||||
const fetches = Array.from({ length: BURST }, (_, i) =>
|
||||
chat(RPM_MODEL, `Count to 3. Only numbers. Request ${i}.`)
|
||||
);
|
||||
const results = await Promise.all(fetches);
|
||||
|
||||
const statuses = results.map((r) => r.status);
|
||||
const successes = results.filter((r) => r.status === 200);
|
||||
const rateLimited = results.filter((r) => r.status === 429);
|
||||
|
||||
console.error(
|
||||
`[RPM] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})`
|
||||
);
|
||||
|
||||
assert.ok(successes.length > 0, "expected at least one successful request");
|
||||
|
||||
if (rateLimited.length > 0) {
|
||||
for (const r of rateLimited) {
|
||||
assert.equal(
|
||||
r.body.includes("quota_exhausted"),
|
||||
false,
|
||||
`RPM 429 should NOT be quota_exhausted: ${r.body.slice(0, 300)}`
|
||||
);
|
||||
assert.ok(
|
||||
r.body.includes("cooling down") || r.body.includes("rate_limit"),
|
||||
`RPM 429 should mention cooldown: ${r.body.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error("[RPM] No 429s received (Gemini may have higher effective RPM for this key)");
|
||||
console.error(
|
||||
"[RPM] Classification logic verified by unit tests in account-fallback-service.test.ts"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("Gemma 4 RPM recovery: after 65s, requests should succeed again", { skip }, async () => {
|
||||
// First send a burst to ensure any cooldown from the previous test has cleared
|
||||
const warmup = await chat(RPM_MODEL, "ping");
|
||||
if (warmup.status === 429) {
|
||||
console.error("[RPM recovery] Previous test left model in cooldown, waiting 65s...");
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
} else {
|
||||
console.error("[RPM recovery] Model is healthy, skipping wait");
|
||||
}
|
||||
|
||||
const results: Array<{ status: number }> = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
results.push(await chat(RPM_MODEL, `Hello ${i}.`));
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const successes = results.filter((r) => r.status === 200);
|
||||
console.error(`[RPM recovery] ${successes.length}/3 success`);
|
||||
assert.ok(
|
||||
successes.length >= 1,
|
||||
`expected at least 1 recovery, got: ${results.map((r) => r.status).join(",")}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 2: RPD burst ────────────────────────────────────────────────────────
|
||||
|
||||
test(
|
||||
"Gemini 2.5 Flash RPD burst: try to hit 20 RPD, verify quota_exhausted if triggered",
|
||||
{ skip },
|
||||
async () => {
|
||||
const BURST = 30;
|
||||
console.error(`\n[RPD] Sending ${BURST} concurrent requests to ${RPD_MODEL} (20 RPD)...`);
|
||||
const fetches = Array.from({ length: BURST }, (_, i) =>
|
||||
chat(RPD_MODEL, `Count to 5. Only numbers. Request ${i}.`)
|
||||
);
|
||||
const results = await Promise.all(fetches);
|
||||
const statuses = results.map((r) => r.status);
|
||||
const successes = results.filter((r) => r.status === 200);
|
||||
const rateLimited = results.filter((r) => r.status === 429);
|
||||
|
||||
console.error(
|
||||
`[RPD] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})`
|
||||
);
|
||||
|
||||
assert.ok(successes.length > 0, "expected at least one successful request");
|
||||
|
||||
const quotaExhausted = rateLimited.filter((r) =>
|
||||
r.body.toLowerCase().includes("quota_exhausted")
|
||||
);
|
||||
|
||||
if (quotaExhausted.length > 0) {
|
||||
console.error(`[RPD] ${quotaExhausted.length} quota_exhausted responses ✓`);
|
||||
} else if (rateLimited.length > 0) {
|
||||
// Check that non-quota-exhausted 429s are still rate_limit, not some other error
|
||||
for (const r of rateLimited) {
|
||||
assert.equal(
|
||||
r.body.includes("quota_exhausted"),
|
||||
false,
|
||||
`RPM 429 should not be quota_exhausted: ${r.body.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
console.error("[RPD] 429s present but none are quota_exhausted (RPD not yet hit)");
|
||||
} else {
|
||||
console.error(
|
||||
"[RPD] No 429s received (daily quota may not have been reached, or limits are higher)"
|
||||
);
|
||||
console.error("[RPD] Classification logic verified by unit tests");
|
||||
}
|
||||
}
|
||||
);
|
||||
253
tests/integration/gemini-rate-limit-classification.test.ts
Normal file
253
tests/integration/gemini-rate-limit-classification.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Gemini rate-limit classification integration tests.
|
||||
*
|
||||
* Tests the full integration between geminiRateLimitTracker (in-memory
|
||||
* daily/minute counters) and accountFallback.checkFallbackError (429
|
||||
* classification). No live Gemini API key needed — the tracker counters
|
||||
* are incremented directly and a synthetic 429 error is passed to
|
||||
* checkFallbackError.
|
||||
*
|
||||
* This validates that the whole pipeline works:
|
||||
* incrementRequestCount → isRpdExhausted / isRpmExhausted → checkFallbackError
|
||||
*
|
||||
* Covers three classification outcomes:
|
||||
* - RPM exhausted → RATE_LIMIT_EXCEEDED (exponential backoff)
|
||||
* - RPD exhausted → QUOTA_EXHAUSTED (midnight lockout)
|
||||
* - Neither exhausted → falls through to generic 429 (RATE_LIMIT_EXCEEDED)
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
const {
|
||||
incrementRequestCount,
|
||||
getDailyRequestCount,
|
||||
getMinuteRequestCount,
|
||||
isRpdExhausted,
|
||||
isRpmExhausted,
|
||||
resetCounters,
|
||||
} = await import("../../open-sse/services/geminiRateLimitTracker.ts");
|
||||
|
||||
const PROFILE = {
|
||||
baseCooldownMs: 125,
|
||||
useUpstreamRetryHints: false,
|
||||
maxBackoffSteps: 3,
|
||||
failureThreshold: 60,
|
||||
degradationThreshold: 40,
|
||||
resetTimeoutMs: 5000,
|
||||
transientCooldown: 125,
|
||||
rateLimitCooldown: 125,
|
||||
maxBackoffLevel: 3,
|
||||
circuitBreakerThreshold: 60,
|
||||
circuitBreakerReset: 5000,
|
||||
providerFailureThreshold: 5,
|
||||
providerFailureWindowMs: 300000,
|
||||
providerCooldownMs: 60000,
|
||||
};
|
||||
|
||||
const GEMINI_429_BODY = "Resource has been exhausted (e.g. check quota).";
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetCounters();
|
||||
});
|
||||
|
||||
// ── Scenario 1: RPM exhausted, RPD not exhausted → RATE_LIMIT_EXCEEDED ────────
|
||||
|
||||
test("Gemini 2.5 Flash 5 RPM hit: 429 classifies as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => {
|
||||
// gemini-2.5-flash: RPM=5, RPD=20
|
||||
for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
|
||||
});
|
||||
|
||||
// ── Scenario 2: RPD exhausted → QUOTA_EXHAUSTED ───────────────────────────────
|
||||
|
||||
test("Gemini 2.5 Flash 20 RPD hit: 429 classifies as QUOTA_EXHAUSTED", () => {
|
||||
for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
|
||||
});
|
||||
|
||||
// ── Scenario 3: Neither RPM nor RPD exhausted → falls through to generic 429 ──
|
||||
|
||||
test("Gemini 2.5 Flash 3 requests (below both): 429 falls through to generic RATE_LIMIT_EXCEEDED", () => {
|
||||
for (let i = 0; i < 3; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), false);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
|
||||
});
|
||||
|
||||
// ── Scenario 4: Both limits exhausted → RPD takes priority → QUOTA_EXHAUSTED ──
|
||||
|
||||
test("Gemini 2.5 Flash both RPM and RPD hit: RPD check runs first → QUOTA_EXHAUSTED", () => {
|
||||
for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
// RPD check is first in the if-chain, so it takes priority
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
// ── Scenario 5: Gemma 4 — 15 RPM hit, 1500 RPD not hit → RATE_LIMIT_EXCEEDED ─
|
||||
|
||||
test("Gemma 4 15 RPM hit (RPD=1500 untouched): 429 classifies as RATE_LIMIT_EXCEEDED", () => {
|
||||
for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true);
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini/gemma-4-31b-it",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
// ── Scenario 6: Non-Gemini provider bypasses the Gemini-specific check ─────────
|
||||
|
||||
test("Non-Gemini provider: tracker state is irrelevant, 429 goes through generic path", () => {
|
||||
for (let i = 0; i < 30; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
|
||||
// Provider is "openai" — Gemini-specific check is skipped
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"openai",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
// Falls through to generic 429 handling → RATE_LIMIT_EXCEEDED
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
// ── Scenario 7: Reset clears state → no longer exhausted ──────────────────────
|
||||
|
||||
test("resetCounters clears both RPM and RPD exhaustion", () => {
|
||||
for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash");
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), true);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), true);
|
||||
|
||||
resetCounters();
|
||||
|
||||
assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0);
|
||||
assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0);
|
||||
assert.equal(isRpmExhausted("gemini-2.5-flash"), false);
|
||||
assert.equal(isRpdExhausted("gemini-2.5-flash"), false);
|
||||
|
||||
// Generic 429 path (no model-specific early return)
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini-2.5-flash",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
|
||||
// ── Scenario 8: RPD exhaustion with Gemma 4 (high RPD, never hit with 15 RPM) ─
|
||||
|
||||
test("Gemma 4 1500 RPD exhaustion overrides RPM classification", () => {
|
||||
// Pump 1500 daily requests to exhaust RPD
|
||||
for (let i = 0; i < 1500; i++) incrementRequestCount("gemini/gemma-4-31b-it");
|
||||
assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true);
|
||||
assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true); // 1500 >> 15 RPM
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini/gemma-4-31b-it",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
// RPD check runs first
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
// ── Scenario 9: Unknown model (no RPM/RPD in JSON) → generic 429 path ─────────
|
||||
|
||||
test("Unknown Gemini model without published limits falls through to generic 429", () => {
|
||||
incrementRequestCount("gemini/unknown-model");
|
||||
assert.equal(isRpmExhausted("gemini/unknown-model"), false);
|
||||
assert.equal(isRpdExhausted("gemini/unknown-model"), false);
|
||||
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
GEMINI_429_BODY,
|
||||
0,
|
||||
"gemini/unknown-model",
|
||||
"gemini",
|
||||
null,
|
||||
PROFILE
|
||||
);
|
||||
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -558,6 +558,7 @@ test("resilience API only exposes configuration, not runtime breaker state", asy
|
||||
"connectionCooldown",
|
||||
"legacy",
|
||||
"providerBreaker",
|
||||
"providerCooldown",
|
||||
"requestQueue",
|
||||
"waitForCooldown",
|
||||
]);
|
||||
|
||||
@@ -30,6 +30,8 @@ const {
|
||||
isProviderFailureCode,
|
||||
getProvidersInCooldown,
|
||||
getProviderBreakerState,
|
||||
isCreditsExhausted,
|
||||
CREDITS_EXHAUSTED_SIGNALS,
|
||||
} = accountFallback;
|
||||
|
||||
const { selectAccount } = accountSelector;
|
||||
@@ -41,7 +43,6 @@ function makeProfile(overrides: Record<string, unknown> = {}): any {
|
||||
useUpstreamRetryHints: false,
|
||||
maxBackoffSteps: 3,
|
||||
failureThreshold: 60,
|
||||
degradationThreshold: 40,
|
||||
resetTimeoutMs: 5000,
|
||||
transientCooldown: 125,
|
||||
rateLimitCooldown: 125,
|
||||
@@ -108,7 +109,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset
|
||||
429,
|
||||
message,
|
||||
0,
|
||||
"gemini-3.5-flash-high",
|
||||
"gemini-3-flash-agent",
|
||||
"antigravity",
|
||||
null,
|
||||
makeProfile()
|
||||
@@ -124,7 +125,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset
|
||||
test("recordModelLockoutFailure honors a multi-day exactCooldownMs (under 30-day cap)", () => {
|
||||
const provider = "antigravity";
|
||||
const connectionId = "conn-quota-window";
|
||||
const model = "gemini-3.5-flash-high";
|
||||
const model = "gemini-3-flash-agent";
|
||||
const exactCooldownMs = (164 * 3600 + 27 * 60 + 24) * 1000;
|
||||
|
||||
clearModelLock(provider, connectionId, model);
|
||||
@@ -384,7 +385,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => {
|
||||
assert.equal(oauthProfile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset);
|
||||
assert.equal(oauthProfile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown);
|
||||
assert.equal(oauthProfile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold);
|
||||
assert.equal(oauthProfile.degradationThreshold, PROVIDER_PROFILES.oauth.degradationThreshold);
|
||||
assert.equal(oauthProfile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset);
|
||||
|
||||
const apiKeyProfile = getProviderProfile("openai");
|
||||
@@ -401,7 +401,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => {
|
||||
assert.equal(apiKeyProfile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset);
|
||||
assert.equal(apiKeyProfile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown);
|
||||
assert.equal(apiKeyProfile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold);
|
||||
assert.equal(apiKeyProfile.degradationThreshold, PROVIDER_PROFILES.apikey.degradationThreshold);
|
||||
assert.equal(apiKeyProfile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset);
|
||||
});
|
||||
|
||||
@@ -610,7 +609,6 @@ test("recordProviderFailure honors runtime provider breaker profile", () => {
|
||||
try {
|
||||
const runtimeProfile = {
|
||||
failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold + 7,
|
||||
degradationThreshold: PROVIDER_PROFILES.apikey.degradationThreshold + 3,
|
||||
resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset + 45_000,
|
||||
};
|
||||
|
||||
@@ -618,13 +616,11 @@ test("recordProviderFailure honors runtime provider breaker profile", () => {
|
||||
|
||||
const breaker = getCircuitBreaker(provider);
|
||||
assert.equal(breaker.failureThreshold, runtimeProfile.failureThreshold);
|
||||
assert.equal(breaker.degradationThreshold, runtimeProfile.degradationThreshold);
|
||||
assert.equal(breaker.resetTimeout, runtimeProfile.resetTimeoutMs);
|
||||
assert.equal(isProviderInCooldown(provider), false);
|
||||
|
||||
const breakerAfterStatusCheck = getCircuitBreaker(provider);
|
||||
assert.equal(breakerAfterStatusCheck.failureThreshold, runtimeProfile.failureThreshold);
|
||||
assert.equal(breakerAfterStatusCheck.degradationThreshold, runtimeProfile.degradationThreshold);
|
||||
assert.equal(breakerAfterStatusCheck.resetTimeout, runtimeProfile.resetTimeoutMs);
|
||||
} finally {
|
||||
clearProviderFailure(provider);
|
||||
@@ -1115,6 +1111,182 @@ test("checkFallbackError ignores structured error with unrelated code on 400", (
|
||||
assert.equal(result.shouldFallback, false);
|
||||
});
|
||||
|
||||
// ─── Gemini RPM 429 Classification (CREDITS_EXHAUSTED_SIGNALS fix) ─────
|
||||
|
||||
test("isCreditsExhausted returns false for Gemini RPM 429 body text", () => {
|
||||
const geminiRpmText = "Resource has been exhausted (e.g. check quota).";
|
||||
assert.equal(isCreditsExhausted(geminiRpmText), false);
|
||||
});
|
||||
|
||||
test("isCreditsExhausted returns true for actual credits-exhausted signals", () => {
|
||||
assert.equal(isCreditsExhausted("insufficient_quota"), true);
|
||||
assert.equal(isCreditsExhausted("credits exhausted"), true);
|
||||
assert.equal(isCreditsExhausted("payment required"), true);
|
||||
assert.equal(isCreditsExhausted("free tier of the model has been exhausted"), true);
|
||||
assert.equal(isCreditsExhausted("exceeded your current usage quota"), true);
|
||||
});
|
||||
|
||||
test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => {
|
||||
// These patterns were removed because they falsely matched Gemini RPM 429 errors
|
||||
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource has been exhausted"), false);
|
||||
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource_exhausted"), false);
|
||||
assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("check quota"), false);
|
||||
});
|
||||
|
||||
test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => {
|
||||
// provider=null → preserveQuota429=true → text quota checks run
|
||||
// isCreditsExhausted must NOT match Gemini's "Resource has been exhausted"
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
"Resource has been exhausted (e.g. check quota).",
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
makeProfile()
|
||||
);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.equal(result.creditsExhausted, undefined);
|
||||
assert.equal(result.dailyQuotaExhausted, undefined);
|
||||
assert.ok(result.cooldownMs > 0, "cooldownMs should be positive");
|
||||
});
|
||||
|
||||
test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED for API-key provider", () => {
|
||||
// provider="gemini" → preserveQuota429=false → status-based rule applies
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
"Resource has been exhausted (e.g. check quota).",
|
||||
0,
|
||||
null,
|
||||
"gemini",
|
||||
null,
|
||||
makeProfile()
|
||||
);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.equal(result.cooldownMs, 125); // makeProfile().baseCooldownMs
|
||||
});
|
||||
|
||||
test("checkFallbackError still classifies genuine OAuth quota-exhausted text as QUOTA_EXHAUSTED", () => {
|
||||
// Regression: OAuth providers must still get QUOTA_EXHAUSTED for actual quota messages
|
||||
const result = checkFallbackError(
|
||||
429,
|
||||
"Coding Plan hour quota has been exceeded",
|
||||
0,
|
||||
null,
|
||||
"codex",
|
||||
null,
|
||||
makeProfile()
|
||||
);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("checkFallbackError preserves daily-quota exhaustion for non-429 status codes", () => {
|
||||
// Non-429 status codes with daily quota text must still be QUOTA_EXHAUSTED
|
||||
const result = checkFallbackError(
|
||||
402,
|
||||
"You have exceeded today's quota, please try again tomorrow"
|
||||
);
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.equal(result.dailyQuotaExhausted, true);
|
||||
});
|
||||
|
||||
// ─── Gemini 429 → Model Lockout: rate_limited (not quota_exhausted) ────
|
||||
|
||||
test("Gemini RPM 429: recordModelLockoutFailure uses exponential backoff for rate_limited reason", () => {
|
||||
const originalNow = Date.now;
|
||||
const now = 1_700_000_000_000;
|
||||
Date.now = () => now;
|
||||
const provider = "gemini";
|
||||
const connectionId = "test-conn-gemini-rpm";
|
||||
const model = "gemini/gemma-4-31b-it";
|
||||
|
||||
try {
|
||||
clearModelLock(provider, connectionId, model);
|
||||
|
||||
const profile = makeProfile({
|
||||
baseCooldownMs: 5000,
|
||||
transientCooldown: 5000,
|
||||
rateLimitCooldown: 5000,
|
||||
});
|
||||
|
||||
// auth.ts flow: 429 + fallbackResult.reason=RATE_LIMIT_EXCEEDED
|
||||
// → reason="rate_limited" → recordModelLockoutFailure
|
||||
const first = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
0,
|
||||
profile
|
||||
);
|
||||
assert.equal(first.failureCount, 1);
|
||||
assert.equal(first.cooldownMs, 5000, "first failure: 5s base cooldown");
|
||||
|
||||
const second = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
0,
|
||||
profile
|
||||
);
|
||||
assert.equal(second.failureCount, 2);
|
||||
assert.equal(second.cooldownMs, 10000, "second failure: 10s exponential backoff");
|
||||
|
||||
assert.equal(isModelLocked(provider, connectionId, model), true);
|
||||
clearModelLock(provider, connectionId, model);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
clearModelLock("gemini", "test-conn-gemini-rpm", "gemini/gemma-4-31b-it");
|
||||
}
|
||||
});
|
||||
|
||||
test("Gemini RPD (quota_exhausted) still triggers midnight lockout in recordModelLockoutFailure", () => {
|
||||
// Regression: real daily quota exhaustion must still produce midnight reset
|
||||
const originalNow = Date.now;
|
||||
const testDate = new Date();
|
||||
testDate.setHours(12, 0, 0, 0);
|
||||
const now = testDate.getTime();
|
||||
Date.now = () => now;
|
||||
const provider = "gemini";
|
||||
const connectionId = "test-conn-gemini-rpd";
|
||||
const model = "gemini/gemma-4-31b-it";
|
||||
|
||||
try {
|
||||
clearModelLock(provider, connectionId, model);
|
||||
const profile = makeProfile();
|
||||
const result = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"quota_exhausted",
|
||||
429,
|
||||
0,
|
||||
profile
|
||||
);
|
||||
|
||||
// Must lock until midnight, NOT exponential backoff
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
const expected = tomorrow.getTime() - now;
|
||||
assert.ok(
|
||||
Math.abs(result.cooldownMs - expected) <= 300_000,
|
||||
`cooldown should be until tomorrow (expected ~${expected}, got ${result.cooldownMs})`
|
||||
);
|
||||
clearModelLock(provider, connectionId, model);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
clearModelLock("gemini", "test-conn-gemini-rpd", "gemini/gemma-4-31b-it");
|
||||
}
|
||||
});
|
||||
|
||||
// ─── G-02: X-Omni-Fallback-Hint: connection_cooldown ─────────────────────────
|
||||
// When 9router executor signals a supervisor-not-running 503, checkFallbackError
|
||||
// must return 5s cooldown with skipProviderBreaker:true — not trip the circuit breaker.
|
||||
@@ -1193,6 +1365,122 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui
|
||||
clearProviderFailure("9router"); // cleanup
|
||||
});
|
||||
|
||||
test("recordModelLockoutFailure caps cooldown at BACKOFF_CONFIG.max to prevent absurdly long lockouts", () => {
|
||||
const originalNow = Date.now;
|
||||
let now = 1_700_000_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
try {
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-capped";
|
||||
const model = "gpt-5-trillium";
|
||||
|
||||
clearModelLock(provider, connectionId, model);
|
||||
|
||||
// Fire 9 consecutive failures so the backoff exceeds the 120s cap
|
||||
// baseCooldownMs=1000 (getQuotaCooldown(0)), failure 9: 1000*2^8=256000 > 120000
|
||||
let lastResult;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
lastResult = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
0,
|
||||
null
|
||||
);
|
||||
now += 50; // each failure within the reset window
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
lastResult.cooldownMs <= 120_000,
|
||||
`cooldown ${lastResult.cooldownMs}ms should not exceed BACKOFF_CONFIG.max (120000ms)`
|
||||
);
|
||||
assert.equal(lastResult.cooldownMs, 120_000);
|
||||
assert.equal(lastResult.failureCount, 9);
|
||||
|
||||
clearModelLock(provider, connectionId, model);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("recordModelLockoutFailure groups provider aliases under canonical provider", () => {
|
||||
const providerAlias = "cx";
|
||||
const providerCanonical = "codex";
|
||||
const connectionId = "conn-alias-test";
|
||||
const model = "gpt-5.5";
|
||||
|
||||
clearModelLock(providerCanonical, connectionId, model);
|
||||
clearModelLock(providerAlias, connectionId, model);
|
||||
|
||||
const result1 = recordModelLockoutFailure(
|
||||
providerAlias,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
1000,
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal(isModelLocked(providerAlias, connectionId, model), true);
|
||||
assert.equal(isModelLocked(providerCanonical, connectionId, model), true);
|
||||
|
||||
clearModelLock(providerCanonical, connectionId, model);
|
||||
});
|
||||
|
||||
test("recordModelLockoutFailure escalates backoff correctly after cooldown expiration (long interval)", () => {
|
||||
const originalNow = Date.now;
|
||||
let now = 1_700_000_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
try {
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-long-interval";
|
||||
const model = "gpt-5-escalate";
|
||||
|
||||
clearModelLock(provider, connectionId, model);
|
||||
|
||||
const profile = makeProfile({
|
||||
baseCooldownMs: 120000,
|
||||
resetTimeoutMs: 30000,
|
||||
});
|
||||
|
||||
const first = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
120000,
|
||||
profile,
|
||||
{ maxCooldownMs: 1800000 }
|
||||
);
|
||||
assert.equal(first.failureCount, 1);
|
||||
assert.equal(first.cooldownMs, 120000);
|
||||
|
||||
now += 130000;
|
||||
|
||||
const second = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
120000,
|
||||
profile,
|
||||
{ maxCooldownMs: 1800000 }
|
||||
);
|
||||
assert.equal(second.failureCount, 2);
|
||||
assert.equal(second.cooldownMs, 240000);
|
||||
|
||||
clearModelLock(provider, connectionId, model);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
// ── Custom banned signals (PR #3454) ──────────────────────────────────────────
|
||||
// Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific
|
||||
// permanent-ban phrasing via Settings → Security. These persist in the
|
||||
|
||||
41
tests/unit/agy-gemini-3696-tier-passthrough.test.ts
Normal file
41
tests/unit/agy-gemini-3696-tier-passthrough.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* #3696 — antigravity/agy gemini-3.1-pro-high / gemini-3.1-pro-low were being collapsed
|
||||
* to the bare upstream id `gemini-3.1-pro`, losing the tier distinction.
|
||||
*
|
||||
* Wire evidence (captured by maintainer via `agy --model gemini-3.1-pro-high --log-file`):
|
||||
* - `gemini-3.1-pro-high` sent literally to `/v1internal:streamGenerateContent` → 200 OK
|
||||
* - `gemini-3.1-pro-low` sent literally → 200 OK
|
||||
*
|
||||
* CONCLUSION: the upstream ACCEPTS the suffixed ids directly. The old assumption in #3229
|
||||
* ("upstream rejects the suffix for gemini-3.x") was refuted by this wire capture.
|
||||
* The collapse aliases must be removed so the tier-specific ids reach the upstream.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_PUBLIC_MODELS,
|
||||
resolveAntigravityModelId,
|
||||
} from "../../open-sse/config/antigravityModelAliases.ts";
|
||||
|
||||
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-high through unchanged", () => {
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high");
|
||||
});
|
||||
|
||||
test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchanged", () => {
|
||||
assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low");
|
||||
});
|
||||
|
||||
test("(#3696) no two ANTIGRAVITY_PUBLIC_MODELS entries resolve to the same upstream id", () => {
|
||||
const seen = new Map<string, string>();
|
||||
const collisions: string[] = [];
|
||||
for (const model of ANTIGRAVITY_PUBLIC_MODELS) {
|
||||
const upstream = resolveAntigravityModelId(model.id);
|
||||
if (seen.has(upstream)) {
|
||||
collisions.push(`${model.id} and ${seen.get(upstream)} both resolve to "${upstream}"`);
|
||||
} else {
|
||||
seen.set(upstream, model.id);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(collisions, [], `upstream-id collisions: ${collisions.join("; ")}`);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
827
tests/unit/arena-elo-sync.test.ts
Normal file
827
tests/unit/arena-elo-sync.test.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Unit tests for src/lib/arenaEloSync.ts
|
||||
*
|
||||
* Uses Node.js native test runner. All external fetch calls are mocked.
|
||||
* DB functions use a real in-memory SQLite instance via node:sqlite (DatabaseSync),
|
||||
* injected through the core module's globalThis.__omnirouteDb singleton.
|
||||
* backupDbFile is called during first sync but safely no-ops (no file on disk).
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-arena-elo-test-"),
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const MIGRATION_SQL = fs.readFileSync(
|
||||
path.resolve(
|
||||
import.meta.dirname ?? __dirname,
|
||||
"../../src/lib/db/migrations/097_model_intelligence.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory";
|
||||
import type { SqliteAdapter } from "../../src/lib/db/adapters/types";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
const {
|
||||
normalizeModelName,
|
||||
transformToModelIntelligence,
|
||||
fetchArenaLeaderboards,
|
||||
syncArenaElo,
|
||||
getArenaEloSyncStatus,
|
||||
stopArenaEloSync,
|
||||
} = await import("../../src/lib/arenaEloSync.ts");
|
||||
|
||||
import type {
|
||||
ArenaLeaderboardData,
|
||||
ArenaLeaderboardMap,
|
||||
ArenaModelEntry,
|
||||
} from "../../src/lib/arenaEloSync.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function mockFetch(
|
||||
impl: (url: string, opts?: RequestInit) => Promise<Response>,
|
||||
): void {
|
||||
globalThis.fetch = impl as typeof fetch;
|
||||
}
|
||||
|
||||
function restoreFetch(): void {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function makeModelEntry(overrides: Partial<ArenaModelEntry> = {}): ArenaModelEntry {
|
||||
return {
|
||||
rank: 1,
|
||||
model: "anthropic/claude-sonnet",
|
||||
vendor: "Anthropic",
|
||||
score: 1350,
|
||||
ci: 10,
|
||||
votes: 5000,
|
||||
license: "proprietary",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLeaderboardData(
|
||||
models: ArenaModelEntry[] = [],
|
||||
category = "text",
|
||||
): ArenaLeaderboardData {
|
||||
return {
|
||||
meta: { leaderboard: category, model_count: models.length },
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLeaderboardMap(
|
||||
categories: Partial<Record<string, ArenaModelEntry[]>>,
|
||||
): ArenaLeaderboardMap {
|
||||
const map: ArenaLeaderboardMap = {};
|
||||
for (const [cat, models] of Object.entries(categories)) {
|
||||
map[cat] = makeLeaderboardData(models ?? [], cat);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let testAdapter: SqliteAdapter;
|
||||
|
||||
function createTestAdapter(): SqliteAdapter {
|
||||
const patchedSql = MIGRATION_SQL.replace(
|
||||
/\n\s*synced_at TEXT NOT NULL DEFAULT \(datetime\('now'\)\)/,
|
||||
"\n synced_at TEXT NOT NULL",
|
||||
);
|
||||
const adapter = tryOpenSync(":memory:")!;
|
||||
adapter.exec(patchedSql);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function countArenaEloEntries(): number {
|
||||
const row = testAdapter
|
||||
.prepare("SELECT COUNT(*) as cnt FROM model_intelligence WHERE source = 'arena_elo'")
|
||||
.get() as Record<string, unknown> | undefined;
|
||||
return Number(row?.cnt ?? 0);
|
||||
}
|
||||
|
||||
function getAllEntries(): Array<Record<string, unknown>> {
|
||||
return testAdapter
|
||||
.prepare("SELECT * FROM model_intelligence WHERE source = 'arena_elo' ORDER BY model, category")
|
||||
.all() as Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
testAdapter = createTestAdapter();
|
||||
globalThis.__omnirouteDb = testAdapter as never;
|
||||
stopArenaEloSync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
stopArenaEloSync();
|
||||
delete globalThis.__omnirouteDb;
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 1. normalizeModelName()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("normalizeModelName()", () => {
|
||||
it("strips 'anthropic/' vendor prefix", () => {
|
||||
assert.strictEqual(
|
||||
normalizeModelName("anthropic/claude-opus-4-6-thinking"),
|
||||
"claude-opus-4-6-thinking",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips 'openai/' vendor prefix", () => {
|
||||
assert.strictEqual(normalizeModelName("openai/gpt-5.5"), "gpt-5.5");
|
||||
});
|
||||
|
||||
it("strips 'google/' vendor prefix", () => {
|
||||
assert.strictEqual(normalizeModelName("google/gemini-3-flash"), "gemini-3-flash");
|
||||
});
|
||||
|
||||
it("strips 'meta/' vendor prefix", () => {
|
||||
assert.strictEqual(normalizeModelName("meta/llama-4"), "llama-4");
|
||||
});
|
||||
|
||||
it("strips 'deepseek/' vendor prefix", () => {
|
||||
assert.strictEqual(normalizeModelName("deepseek/deepseek-r1"), "deepseek-r1");
|
||||
});
|
||||
|
||||
it("strips 'xai/' vendor prefix", () => {
|
||||
assert.strictEqual(normalizeModelName("xai/grok-4"), "grok-4");
|
||||
});
|
||||
|
||||
it("lowercases the model name", () => {
|
||||
assert.strictEqual(normalizeModelName("Claude-Sonnet-4"), "claude-sonnet-4");
|
||||
});
|
||||
|
||||
it("lowercases vendor prefix before matching", () => {
|
||||
assert.strictEqual(normalizeModelName("OpenAI/GPT-5.5"), "gpt-5.5");
|
||||
});
|
||||
|
||||
it("returns name unchanged when no vendor prefix matches", () => {
|
||||
assert.strictEqual(normalizeModelName("my-custom-model"), "my-custom-model");
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 2. transformToModelIntelligence()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("transformToModelIntelligence()", () => {
|
||||
it("ELO normalization: 1500 ELO (max) with range 1000-1500 → score ≈ 0.98", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }),
|
||||
makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const topEntry = entries.find(
|
||||
(e) => e.model === "top-model" && e.category === "default",
|
||||
);
|
||||
|
||||
assert.ok(topEntry);
|
||||
// taskFit = 0.4 + 0.58 * ((1500-1000) / 500) = 0.98
|
||||
assert.ok(Math.abs(topEntry.score - 0.98) < 0.001, `got ${topEntry.score}`);
|
||||
});
|
||||
|
||||
it("ELO normalization: 1000 ELO (min) with range 1000-1500 → score ≈ 0.40", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }),
|
||||
makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const lowEntry = entries.find(
|
||||
(e) => e.model === "low-model" && e.category === "default",
|
||||
);
|
||||
|
||||
assert.ok(lowEntry);
|
||||
// taskFit = 0.4 + 0.58 * ((1000-1000) / 500) = 0.4
|
||||
assert.ok(Math.abs(lowEntry.score - 0.4) < 0.001, `got ${lowEntry.score}`);
|
||||
});
|
||||
|
||||
it("votes < 100 → confidence='low'", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "sparse-model", score: 1200, votes: 50, rank: 5 }),
|
||||
makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "sparse-model" && e.category === "default",
|
||||
);
|
||||
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry.confidence, "low");
|
||||
});
|
||||
|
||||
it("votes >= 1000 → confidence='medium'", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "mid-model", score: 1200, votes: 1500, rank: 3 }),
|
||||
makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "mid-model" && e.category === "default",
|
||||
);
|
||||
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry.confidence, "medium");
|
||||
});
|
||||
|
||||
it("votes >= 5000 → confidence='high'", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "popular-model", score: 1300, votes: 8000, rank: 1 }),
|
||||
makeModelEntry({ model: "baseline", score: 1100, votes: 3000, rank: 5 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "popular-model" && e.category === "default",
|
||||
);
|
||||
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry.confidence, "high");
|
||||
});
|
||||
|
||||
it("category mapping: 'text' → [default, review, documentation, debugging]", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const categories = entries
|
||||
.filter((e) => e.model === "text-model")
|
||||
.map((e) => e.category)
|
||||
.sort();
|
||||
|
||||
assert.deepStrictEqual(categories, [
|
||||
"debugging",
|
||||
"default",
|
||||
"documentation",
|
||||
"review",
|
||||
]);
|
||||
});
|
||||
|
||||
it("category mapping: 'code' → [coding]", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
code: [
|
||||
makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const categories = entries
|
||||
.filter((e) => e.model === "code-model")
|
||||
.map((e) => e.category);
|
||||
|
||||
assert.deepStrictEqual(categories, ["coding"]);
|
||||
});
|
||||
|
||||
it("expires_at is set to ~7 days in the future", () => {
|
||||
const before = Date.now();
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const after = Date.now();
|
||||
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "test-model" && e.category === "default",
|
||||
);
|
||||
assert.ok(entry);
|
||||
assert.ok(entry.expiresAt);
|
||||
|
||||
const expiresMs = new Date(entry.expiresAt).getTime();
|
||||
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
assert.ok(
|
||||
expiresMs >= before + sevenDaysMs - 2000,
|
||||
`expiresAt too early: ${entry.expiresAt}`,
|
||||
);
|
||||
assert.ok(
|
||||
expiresMs <= after + sevenDaysMs + 2000,
|
||||
`expiresAt too late: ${entry.expiresAt}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("source is 'arena_elo' for all entries", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
for (const entry of entries) {
|
||||
assert.strictEqual(entry.source, "arena_elo");
|
||||
}
|
||||
});
|
||||
|
||||
it("expands model aliases for known models", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({
|
||||
model: "anthropic/claude-opus-4-6-thinking",
|
||||
score: 1400,
|
||||
votes: 5000,
|
||||
rank: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const models = entries.map((e) => e.model);
|
||||
|
||||
assert.ok(models.includes("claude-opus-4-6-thinking"));
|
||||
assert.ok(models.includes("claude-opus-4"));
|
||||
assert.ok(models.includes("anthropic/claude-opus-4"));
|
||||
});
|
||||
|
||||
it("empty leaderboard → no entries", () => {
|
||||
const data = makeLeaderboardMap({ text: [] });
|
||||
const entries = transformToModelIntelligence(data);
|
||||
assert.strictEqual(entries.length, 0);
|
||||
});
|
||||
|
||||
it("skips unknown leaderboard categories (e.g. vision)", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
vision: [
|
||||
makeModelEntry({ model: "vision-model", score: 1300, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
assert.strictEqual(entries.length, 0);
|
||||
});
|
||||
|
||||
it("preserves eloRaw from the leaderboard", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "test-model", score: 1337, votes: 5000, rank: 1 }),
|
||||
makeModelEntry({ model: "other-model", score: 1100, votes: 3000, rank: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "test-model" && e.category === "default",
|
||||
);
|
||||
assert.ok(entry);
|
||||
assert.strictEqual(entry.eloRaw, 1337);
|
||||
});
|
||||
|
||||
it("handles single-model leaderboard (eloRange = 1, avoids division by zero)", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "only-model", score: 1200, votes: 5000, rank: 1 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
assert.ok(entries.length > 0);
|
||||
|
||||
const entry = entries.find(
|
||||
(e) => e.model === "only-model" && e.category === "default",
|
||||
);
|
||||
assert.ok(entry);
|
||||
assert.ok(Math.abs(entry.score - 0.4) < 0.001);
|
||||
});
|
||||
|
||||
it("rounds score to 4 decimal places", () => {
|
||||
const data = makeLeaderboardMap({
|
||||
text: [
|
||||
makeModelEntry({ model: "model-a", score: 1300, votes: 200, rank: 1 }),
|
||||
makeModelEntry({ model: "model-b", score: 1000, votes: 200, rank: 2 }),
|
||||
],
|
||||
});
|
||||
|
||||
const entries = transformToModelIntelligence(data);
|
||||
for (const entry of entries) {
|
||||
const str = entry.score.toString();
|
||||
const dot = str.indexOf(".");
|
||||
if (dot !== -1) {
|
||||
assert.ok(str.length - dot - 1 <= 4, `score ${entry.score} > 4 decimals`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 3. fetchArenaLeaderboards()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("fetchArenaLeaderboards()", () => {
|
||||
it("successful fetch with valid JSON returns both leaderboards", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
const codeData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 })],
|
||||
"code",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code")) return jsonResponse(codeData);
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await fetchArenaLeaderboards();
|
||||
|
||||
assert.ok(result.text);
|
||||
assert.ok(result.code);
|
||||
assert.strictEqual(result.text.models.length, 1);
|
||||
assert.strictEqual(result.code.models.length, 1);
|
||||
});
|
||||
|
||||
it("failed fetch (non-200 status) throws with descriptive message", async () => {
|
||||
mockFetch(async () => {
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => fetchArenaLeaderboards(),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("all fetches fail (network error) → throws", async () => {
|
||||
mockFetch(async () => {
|
||||
throw new Error("Network error: ECONNREFUSED");
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => fetchArenaLeaderboards(),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("succeeds when one category fails but another succeeds", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return new Response("Error", { status: 500 });
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await fetchArenaLeaderboards();
|
||||
assert.ok(result.text);
|
||||
assert.strictEqual(result.code, undefined);
|
||||
});
|
||||
|
||||
it("invalid JSON in response → throws when all responses are invalid", async () => {
|
||||
mockFetch(async () => {
|
||||
return new Response("not-json", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => fetchArenaLeaderboards(),
|
||||
(err: unknown) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes("All Arena leaderboard fetches failed"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 4. syncArenaElo()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("syncArenaElo()", () => {
|
||||
it("happy path: returns success=true with correct modelCount", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "gpt-5.5", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
const codeData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "deepseek-r1", score: 1300, votes: 5000, rank: 1 })],
|
||||
"code",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code")) return jsonResponse(codeData);
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await syncArenaElo();
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.strictEqual(result.source, "arena_elo");
|
||||
assert.ok(result.modelCount > 0);
|
||||
|
||||
const dbCount = countArenaEloEntries();
|
||||
assert.ok(dbCount > 0);
|
||||
assert.strictEqual(dbCount, result.modelCount);
|
||||
});
|
||||
|
||||
it("happy path: entries in DB have correct source and categories", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "unique-test-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
await syncArenaElo();
|
||||
|
||||
const entries = getAllEntries().filter((e) => String(e.model) === "unique-test-model");
|
||||
const categories = entries.map((e) => String(e.category)).sort();
|
||||
assert.deepStrictEqual(categories, [
|
||||
"debugging",
|
||||
"default",
|
||||
"documentation",
|
||||
"review",
|
||||
]);
|
||||
|
||||
for (const entry of entries) {
|
||||
assert.strictEqual(entry.source, "arena_elo");
|
||||
}
|
||||
});
|
||||
|
||||
it("dryRun=true → does not call bulkUpsertModelIntelligence", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await syncArenaElo(true);
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.ok(result.modelCount > 0);
|
||||
|
||||
const dbCount = countArenaEloEntries();
|
||||
assert.strictEqual(dbCount, 0, "dryRun should not write to DB");
|
||||
});
|
||||
|
||||
it("dryRun=true does not update lastSyncTime", async () => {
|
||||
// Reset module-level state by observing that before this test's dryRun,
|
||||
// lastSync should remain unchanged from whatever it was.
|
||||
// Since we can't reset module-level vars, we verify that a dryRun
|
||||
// after a non-dryRun doesn't overwrite the model count.
|
||||
const statusBefore = getArenaEloSyncStatus();
|
||||
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
await syncArenaElo(true);
|
||||
|
||||
const statusAfter = getArenaEloSyncStatus();
|
||||
// dryRun should not change the modelCount
|
||||
assert.strictEqual(statusAfter.lastSyncModelCount, statusBefore.lastSyncModelCount);
|
||||
});
|
||||
|
||||
it("API failure → returns success=false with error", async () => {
|
||||
mockFetch(async () => {
|
||||
throw new Error("Network down");
|
||||
});
|
||||
|
||||
const result = await syncArenaElo();
|
||||
|
||||
assert.strictEqual(result.success, false);
|
||||
assert.strictEqual(result.source, "arena_elo");
|
||||
assert.strictEqual(result.modelCount, 0);
|
||||
assert.ok(result.error);
|
||||
assert.ok(
|
||||
result.error!.includes("All Arena leaderboard fetches failed"),
|
||||
`unexpected: ${result.error}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("calls deleteExpiredIntelligence before writing new entries", async () => {
|
||||
testAdapter.prepare(
|
||||
"INSERT INTO model_intelligence (model, source, category, score, elo_raw, confidence, synced_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
).run("old-model", "arena_elo", "default", 0.5, 1000, "low", "2025-01-01T00:00:00Z", "2020-01-01T00:00:00Z");
|
||||
|
||||
assert.strictEqual(countArenaEloEntries(), 1);
|
||||
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "new-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const syncResult = await syncArenaElo();
|
||||
assert.strictEqual(syncResult.success, true);
|
||||
|
||||
const expiredEntry = testAdapter
|
||||
.prepare("SELECT * FROM model_intelligence WHERE model = 'old-model'")
|
||||
.get();
|
||||
assert.strictEqual(expiredEntry, undefined);
|
||||
});
|
||||
|
||||
it("handles empty leaderboard gracefully (0 entries, no DB write)", async () => {
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text"))
|
||||
return jsonResponse(makeLeaderboardData([], "text"));
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
const result = await syncArenaElo();
|
||||
|
||||
assert.strictEqual(result.success, true);
|
||||
assert.strictEqual(result.modelCount, 0);
|
||||
assert.strictEqual(countArenaEloEntries(), 0);
|
||||
});
|
||||
|
||||
it("updates lastSyncTime after successful sync", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
await syncArenaElo();
|
||||
|
||||
const status = getArenaEloSyncStatus();
|
||||
assert.ok(status.lastSync);
|
||||
assert.ok(status.lastSyncModelCount > 0);
|
||||
});
|
||||
|
||||
it("model aliases are stored in DB alongside canonical names", async () => {
|
||||
const textData = makeLeaderboardData(
|
||||
[makeModelEntry({
|
||||
model: "anthropic/claude-opus-4-6-thinking",
|
||||
score: 1400,
|
||||
votes: 5000,
|
||||
rank: 1,
|
||||
})],
|
||||
"text",
|
||||
);
|
||||
|
||||
mockFetch(async (url: string) => {
|
||||
if (url.includes("name=text")) return jsonResponse(textData);
|
||||
if (url.includes("name=code"))
|
||||
return jsonResponse(makeLeaderboardData([], "code"));
|
||||
return new Response("Not found", { status: 404 });
|
||||
});
|
||||
|
||||
await syncArenaElo();
|
||||
|
||||
const entries = getAllEntries();
|
||||
const models = entries.map((e) => String(e.model));
|
||||
|
||||
assert.ok(models.includes("claude-opus-4-6-thinking"));
|
||||
assert.ok(models.includes("claude-opus-4"));
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 5. getArenaEloSyncStatus()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("getArenaEloSyncStatus()", () => {
|
||||
it("returns correct structure with all expected keys", () => {
|
||||
const status = getArenaEloSyncStatus();
|
||||
|
||||
assert.ok("enabled" in status);
|
||||
assert.ok("lastSync" in status);
|
||||
assert.ok("lastSyncModelCount" in status);
|
||||
assert.ok("nextSync" in status);
|
||||
assert.ok("intervalMs" in status);
|
||||
assert.ok("sources" in status);
|
||||
});
|
||||
|
||||
it("returns sources containing 'arena_elo'", () => {
|
||||
const status = getArenaEloSyncStatus();
|
||||
assert.deepStrictEqual(status.sources, ["arena_elo"]);
|
||||
});
|
||||
|
||||
it("returns intervalMs as a positive number", () => {
|
||||
const status = getArenaEloSyncStatus();
|
||||
assert.ok(typeof status.intervalMs === "number");
|
||||
assert.ok(status.intervalMs > 0);
|
||||
});
|
||||
|
||||
it("returns lastSyncModelCount as 0 before any non-dryRun sync completes in this suite context", () => {
|
||||
// Module-level lastSyncTime may leak from earlier tests in the suite,
|
||||
// but the structural fields are always present.
|
||||
const status = getArenaEloSyncStatus();
|
||||
assert.ok(typeof status.lastSync === "string" || status.lastSync === null);
|
||||
assert.ok(typeof status.lastSyncModelCount === "number");
|
||||
assert.ok(typeof status.nextSync === "string" || status.nextSync === null);
|
||||
});
|
||||
|
||||
it("reflects ARENA_ELO_SYNC_ENABLED env var", () => {
|
||||
const original = process.env.ARENA_ELO_SYNC_ENABLED;
|
||||
|
||||
process.env.ARENA_ELO_SYNC_ENABLED = "true";
|
||||
const enabledStatus = getArenaEloSyncStatus();
|
||||
assert.strictEqual(enabledStatus.enabled, true);
|
||||
|
||||
process.env.ARENA_ELO_SYNC_ENABLED = "false";
|
||||
const disabledStatus = getArenaEloSyncStatus();
|
||||
assert.strictEqual(disabledStatus.enabled, false);
|
||||
|
||||
if (original !== undefined) {
|
||||
process.env.ARENA_ELO_SYNC_ENABLED = original;
|
||||
} else {
|
||||
delete process.env.ARENA_ELO_SYNC_ENABLED;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 6. stopArenaEloSync()
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
describe("stopArenaEloSync()", () => {
|
||||
it("does not throw when no timer is running", () => {
|
||||
assert.doesNotThrow(() => stopArenaEloSync());
|
||||
});
|
||||
|
||||
it("can be called multiple times without error", () => {
|
||||
stopArenaEloSync();
|
||||
assert.doesNotThrow(() => stopArenaEloSync());
|
||||
assert.doesNotThrow(() => stopArenaEloSync());
|
||||
});
|
||||
});
|
||||
278
tests/unit/claude-empty-stream-error-3685.test.ts
Normal file
278
tests/unit/claude-empty-stream-error-3685.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// #3685 — When a Claude stream completes with lifecycle events (message_start /
|
||||
// message_delta / message_stop) but zero content_block events, the router was
|
||||
// injecting a synthetic success message instead of failing over. Fix: emit a
|
||||
// real SSE error event and call controller.error() so the combo layer can retry.
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3685-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { getPendingRequests, clearPendingRequests } =
|
||||
await import("../../src/lib/usage/usageHistory.ts");
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
async function readTransformed(chunks: string[], options: Record<string, unknown>) {
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (const chunk of chunks) c.enqueue(enc.encode(chunk));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
return new Response(source.pipeThrough(createSSEStream(options as any))).text();
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
for (const entry of fs.readdirSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Golden path: empty content-block stream (the bug case) should now error ---
|
||||
|
||||
test("#3685 passthrough: empty Claude SSE (no content_block) rejects the stream", async () => {
|
||||
let failurePayload: Record<string, unknown> | null = null;
|
||||
await assert.rejects(
|
||||
readTransformed(
|
||||
[
|
||||
`event: message_start\ndata: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_3685",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 5, output_tokens: 0 },
|
||||
},
|
||||
})}\n\n`,
|
||||
`event: message_delta\ndata: ${JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "content_filter", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
})}\n\n`,
|
||||
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
onFailure(p: Record<string, unknown>) {
|
||||
failurePayload = p;
|
||||
},
|
||||
}
|
||||
),
|
||||
/empty response/i,
|
||||
"stream should reject with empty-response error"
|
||||
);
|
||||
assert.ok(failurePayload, "onFailure callback must be invoked");
|
||||
assert.equal((failurePayload as any).status, 502);
|
||||
assert.match((failurePayload as any).message as string, /empty response/i);
|
||||
});
|
||||
|
||||
test("#3685 passthrough: empty Claude SSE emits event: error SSE line before aborting", async () => {
|
||||
const collected: string[] = [];
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
const chunks = [
|
||||
`event: message_start\ndata: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_3685b",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
usage: { input_tokens: 5, output_tokens: 0 },
|
||||
},
|
||||
})}\n\n`,
|
||||
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
];
|
||||
for (const chunk of chunks) c.enqueue(enc.encode(chunk));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const transformed = source.pipeThrough(
|
||||
createSSEStream({
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
} as any)
|
||||
);
|
||||
const reader = transformed.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let gotError = false;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
collected.push(dec.decode(value));
|
||||
}
|
||||
} catch {
|
||||
gotError = true;
|
||||
}
|
||||
assert.ok(gotError, "stream reader should throw on error");
|
||||
const full = collected.join("");
|
||||
assert.match(full, /event: error/, "SSE error event must be emitted before abort");
|
||||
assert.doesNotMatch(
|
||||
full,
|
||||
/event: content_block_start/,
|
||||
"no synthetic content_block must be emitted"
|
||||
);
|
||||
});
|
||||
|
||||
// --- Regression guards: excluded cases must NOT be turned into errors ---
|
||||
|
||||
test("#3685 regression: stream with content_block events is NOT turned into an error", async () => {
|
||||
// A max_tokens:1 ping returns exactly 1 token → content_block events exist.
|
||||
// hasContentBlock = true → shouldInjectClaudeEmptyResponseOnFlush = false → no error.
|
||||
const text = await readTransformed(
|
||||
[
|
||||
`event: message_start\ndata: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_ping",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-haiku-4-5",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
usage: { input_tokens: 3, output_tokens: 0 },
|
||||
},
|
||||
})}\n\n`,
|
||||
`event: content_block_start\ndata: ${JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
})}\n\n`,
|
||||
`event: content_block_delta\ndata: ${JSON.stringify({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Hi" },
|
||||
})}\n\n`,
|
||||
`event: content_block_stop\ndata: ${JSON.stringify({
|
||||
type: "content_block_stop",
|
||||
index: 0,
|
||||
})}\n\n`,
|
||||
`event: message_delta\ndata: ${JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "max_tokens", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
})}\n\n`,
|
||||
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
provider: "anthropic",
|
||||
model: "claude-haiku-4-5",
|
||||
body: { messages: [{ role: "user", content: "ping" }] },
|
||||
}
|
||||
);
|
||||
assert.match(text, /Hi/, "content must pass through untouched");
|
||||
assert.doesNotMatch(text, /event: error/, "must NOT emit an error event");
|
||||
});
|
||||
|
||||
test("#3685 pending request counter is decremented when empty-stream error fires", async () => {
|
||||
// Regression guard for the bug caught by Cursor/Codex: emitClaudeEmptyStreamErrorAndAbort
|
||||
// was marking the error with PENDING_REQUEST_CLEARED_MARKER but never calling
|
||||
// trackPendingRequest(..., false). streamHandler.clearPendingRequest() trusts the marker
|
||||
// and skips its own decrement, leaving the counter permanently inflated.
|
||||
clearPendingRequests();
|
||||
const { trackPendingRequest } = await import("../../src/lib/usage/usageHistory.ts");
|
||||
|
||||
// Simulate the stream engine incrementing the counter at request start.
|
||||
trackPendingRequest("claude-sonnet-4-6", "anthropic", "conn-test", true);
|
||||
assert.equal(
|
||||
getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"],
|
||||
1,
|
||||
"pending count should start at 1 after request begins"
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
readTransformed(
|
||||
[
|
||||
`event: message_start\ndata: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_pending_test",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
usage: { input_tokens: 3, output_tokens: 0 },
|
||||
},
|
||||
})}\n\n`,
|
||||
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
connectionId: "conn-test",
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
}
|
||||
),
|
||||
/empty response/i
|
||||
);
|
||||
|
||||
// emitClaudeEmptyStreamErrorAndAbort must call trackPendingRequest(..., false) so the
|
||||
// counter is back to 0 after the stream terminates.
|
||||
assert.equal(
|
||||
getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"],
|
||||
0,
|
||||
"pending count must be 0 after empty-stream error — not left inflated"
|
||||
);
|
||||
});
|
||||
|
||||
test("#3685 regression: upstream error event sets hasError=true and does NOT trigger empty-stream path", () => {
|
||||
// If Claude itself emits type:error, lifecycle.hasError=true.
|
||||
// shouldInjectClaudeEmptyResponseBeforeCurrentEvent / shouldInjectClaudeEmptyResponseOnFlush
|
||||
// both check !lifecycle.hasError first — so neither our new error path nor the old synthetic
|
||||
// path is triggered. Verified by inspecting the guard functions directly.
|
||||
const lifecycle = {
|
||||
hasMessageStart: true,
|
||||
hasContentBlock: false,
|
||||
hasMessageDelta: false,
|
||||
hasMessageStop: false,
|
||||
hasError: false,
|
||||
syntheticContentInjected: false,
|
||||
warningLogged: false,
|
||||
};
|
||||
|
||||
// Simulate receiving an error event: sets hasError = true.
|
||||
const lifecycleWithError = { ...lifecycle, hasError: true };
|
||||
|
||||
// shouldInjectClaudeEmptyResponseOnFlush equivalent: hasError blocks it
|
||||
const wouldInjectOnFlush =
|
||||
!lifecycleWithError.hasError &&
|
||||
!lifecycleWithError.hasContentBlock &&
|
||||
(lifecycleWithError.hasMessageStart ||
|
||||
lifecycleWithError.hasMessageDelta ||
|
||||
lifecycleWithError.hasMessageStop);
|
||||
|
||||
assert.equal(
|
||||
wouldInjectOnFlush,
|
||||
false,
|
||||
"hasError=true must prevent the empty-stream error path from firing"
|
||||
);
|
||||
});
|
||||
125
tests/unit/cli-setup-opencode.test.ts
Normal file
125
tests/unit/cli-setup-opencode.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* tests/unit/cli-setup-opencode.test.ts
|
||||
*
|
||||
* `omniroute setup opencode` wires the bundled @omniroute/opencode-plugin into a
|
||||
* local OpenCode install: copies the built plugin into `<config>/plugins/omniroute/`
|
||||
* and registers a tuple entry in `opencode.json` (idempotent, replacing the legacy
|
||||
* `opencode-omniroute-auth` entry from issue #3711).
|
||||
*
|
||||
* The command resolves the bundled plugin at module load, so the
|
||||
* OMNIROUTE_OPENCODE_PLUGIN_DIR fixture override MUST be set before the import.
|
||||
* `opts.configDir` keeps the test off the real OpenCode config dir on every platform.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oc-setup-"));
|
||||
const FAKE_PLUGIN_DIR = path.join(FIXTURE_ROOT, "plugin");
|
||||
const CONFIG_DIR = path.join(FIXTURE_ROOT, "opencode-config");
|
||||
|
||||
// Must be set before the module under test is imported (resolved at load time).
|
||||
process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR = FAKE_PLUGIN_DIR;
|
||||
|
||||
const { runSetupOpenCodeCommand } = await import("../../bin/cli/commands/setup-open-code.mjs");
|
||||
|
||||
function makeFakePluginDist() {
|
||||
fs.mkdirSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(FAKE_PLUGIN_DIR, "package.json"),
|
||||
JSON.stringify({ name: "@omniroute/opencode-plugin", version: "0.0.0-test" })
|
||||
);
|
||||
fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.js"), "export {};\n");
|
||||
fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.cjs"), "module.exports = {};\n");
|
||||
}
|
||||
|
||||
function readConfig() {
|
||||
return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "opencode.json"), "utf8"));
|
||||
}
|
||||
|
||||
describe("omniroute setup opencode", () => {
|
||||
before(() => {
|
||||
makeFakePluginDist();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
try {
|
||||
fs.rmSync(FIXTURE_ROOT, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort temp cleanup
|
||||
}
|
||||
});
|
||||
|
||||
it("installs the plugin and registers a tuple entry honouring --base-url (camelCased by Commander)", async () => {
|
||||
const r = await runSetupOpenCodeCommand({
|
||||
configDir: CONFIG_DIR,
|
||||
// Commander turns `--base-url` into `baseUrl` — the runner must accept it.
|
||||
baseUrl: "http://10.0.0.5:20128",
|
||||
nonInteractive: true,
|
||||
});
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
// dist copied into the OpenCode plugins dir
|
||||
assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "dist", "index.js")));
|
||||
assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "package.json")));
|
||||
|
||||
const cfg = readConfig();
|
||||
assert.ok(Array.isArray(cfg.plugin));
|
||||
assert.equal(cfg.plugin.length, 1);
|
||||
const [modulePath, options] = cfg.plugin[0];
|
||||
assert.equal(modulePath, "./plugins/omniroute/dist/index.js");
|
||||
assert.equal(options.providerId, "omniroute");
|
||||
assert.equal(options.baseURL, "http://10.0.0.5:20128", "--base-url flag must reach the registered entry");
|
||||
});
|
||||
|
||||
it("is idempotent: re-running updates the entry in place instead of duplicating it", async () => {
|
||||
const r = await runSetupOpenCodeCommand({
|
||||
configDir: CONFIG_DIR,
|
||||
baseUrl: "http://10.0.0.9:20128",
|
||||
nonInteractive: true,
|
||||
});
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
const cfg = readConfig();
|
||||
const omniEntries = cfg.plugin.filter(
|
||||
(p: unknown) => Array.isArray(p) && (p[1] as { providerId?: string })?.providerId === "omniroute"
|
||||
);
|
||||
assert.equal(omniEntries.length, 1, "re-run must not duplicate the entry");
|
||||
assert.equal(omniEntries[0][1].baseURL, "http://10.0.0.9:20128", "re-run updates baseURL in place");
|
||||
});
|
||||
|
||||
it("removes the legacy opencode-omniroute-auth entry (#3711) and preserves unrelated plugins", async () => {
|
||||
const cfgPath = path.join(CONFIG_DIR, "opencode.json");
|
||||
fs.writeFileSync(
|
||||
cfgPath,
|
||||
JSON.stringify({
|
||||
plugin: [
|
||||
"opencode-omniroute-auth",
|
||||
["./plugins/other/dist/index.js", { providerId: "other" }],
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
const cfg = readConfig();
|
||||
const flat = JSON.stringify(cfg.plugin);
|
||||
assert.ok(!flat.includes("opencode-omniroute-auth"), "legacy entry must be dropped");
|
||||
assert.ok(flat.includes('"providerId":"other"'), "unrelated plugin entries must survive");
|
||||
assert.equal(cfg.plugin.length, 2, "other + omniroute");
|
||||
});
|
||||
|
||||
it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => {
|
||||
fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true });
|
||||
try {
|
||||
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
|
||||
assert.equal(r.exitCode, 1);
|
||||
} finally {
|
||||
makeFakePluginDist();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user