diff --git a/.env.example b/.env.example index df5703dd70..5bede16ef5 100644 --- a/.env.example +++ b/.env.example @@ -507,6 +507,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000. #OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000 +# Emergency budget-exhaustion fallback (set false or 0 to disable the reroute to +# nvidia/openai/gpt-oss-120b when a request fails with a 402 budget error). +# Used by: open-sse/services/emergencyFallback.ts. Default: enabled. +#OMNIROUTE_EMERGENCY_FALLBACK=true + # Reasoning cache cleanup cadence (ms). Default: 1800000 (30m). Floor: 60000. # Used by: src/lib/jobs/reasoningCacheCleanupJob.ts. #OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS=1800000 @@ -1071,6 +1076,16 @@ APP_LOG_TO_FILE=true # Comma-separated data sources. Default: litellm # PRICING_SYNC_SOURCES=litellm +# ═══════════════════════════════════════════════════════════════════════════════ +# 18b. ARENA ELO SYNC +# ═══════════════════════════════════════════════════════════════════════════════ +# Enable auto-updating model intelligence from Arena AI leaderboard ELO scores. +# Used by: src/lib/arenaEloSync.ts +# ARENA_ELO_SYNC_ENABLED=false + +# Sync interval in seconds. Default: 86400 (24 hours). +# ARENA_ELO_SYNC_INTERVAL=86400 + # ═══════════════════════════════════════════════════════════════════════════════ # 19. MODEL SYNC (Dev) # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000000..1bfb96a016 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,5 @@ +# Funding links for OmniRoute — rendered as the "Sponsor" button on GitHub. +# Docs: https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository +github: diegosouzapw +# Additional platforms (uncomment and fill in before enabling): +# custom: ["https://omniroute.online/donate"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a1edee275..fc0a0a116c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,7 +227,7 @@ jobs: --exclude='.build/next/cache' \ .build/next - name: Upload Next.js build for E2E shards - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: e2e-next-build path: /tmp/e2e-build.tar.gz @@ -639,14 +639,14 @@ jobs: - run: npm ci - run: npm run check:node-runtime - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cache/ms-playwright key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} restore-keys: playwright-chromium-${{ runner.os }}- - run: npx playwright install --with-deps chromium - name: Download Next.js build artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: e2e-next-build path: /tmp/ diff --git a/@omniroute/opencode-plugin/README.md b/@omniroute/opencode-plugin/README.md index f5d52c511d..55329dec8a 100644 --- a/@omniroute/opencode-plugin/README.md +++ b/@omniroute/opencode-plugin/README.md @@ -18,23 +18,50 @@ This plugin solves that by: ## Install -Once published to npm: +The plugin ships **pre-built inside the `omniroute` npm package** since v3.8.23. +If you have OmniRoute installed, the plugin is already on disk: ```sh -npm install @omniroute/opencode-plugin +# 1. One command — copy the plugin into OpenCode and update opencode.json +omniroute setup opencode --auth + +# 2. Follow the interactive prompt to enter your OmniRoute API key +# 3. Restart OpenCode — /models lists the full live catalog ``` -Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`: +The `--auth` flag runs `opencode auth login --provider omniroute` automatically. +Use `--base-url` to point at a non-default OmniRoute address: + +```sh +omniroute setup opencode --base-url https://or.example.com --auth +``` + +### What it does + +1. Locates the bundled plugin inside the omniroute installation +2. Copies `dist/` + `package.json` to `~/.config/opencode/plugins/omniroute/` +3. Writes/updates `opencode.json` with the plugin entry (idempotent, replaces legacy entries) +4. (With `--auth`) runs `opencode auth login` so the API key is stored + +Re-run any time to update the plugin or change the base URL. Older entries for +`@omniroute/opencode-provider` or the legacy `opencode-omniroute-auth` package are +automatically cleaned up. + +### Manual install (without omniroute CLI) + +If you cannot run `omniroute setup opencode` (local dev, CI, air-gapped), reference +the built artifact directly: ```sh -# from inside the OmniRoute repo cd @omniroute/opencode-plugin && npm run build && npm pack # then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/ ``` +And add the entry to `opencode.json` manually (see Quick Start below). + Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install). -## Quick start (single instance) +## Quick start (single instance, manual) ```jsonc // opencode.json @@ -42,7 +69,7 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install). "$schema": "https://opencode.ai/config.json", "plugin": [ [ - "@omniroute/opencode-plugin", + "./plugins/omniroute-opencode-plugin/dist/index.js", { "providerId": "omniroute", "baseURL": "https://or.example.com", diff --git a/@omniroute/opencode-plugin/src/index.ts b/@omniroute/opencode-plugin/src/index.ts index 8ad06edfe6..6c8c92540b 100644 --- a/@omniroute/opencode-plugin/src/index.ts +++ b/@omniroute/opencode-plugin/src/index.ts @@ -2552,17 +2552,31 @@ export function createOmniRouteProviderHook( const apiKey = (auth as { key: string }).key; // baseURL resolution: plugin opts first, then credential-attached - // baseURL (auth backends sometimes stash it next to the key). No - // silent default to localhost: a misconfigured plugin should surface - // a clear error, not phantom /v1/models calls. Cast through unknown - // because the Auth union (OAuth | ApiAuth | WellKnownAuth) doesn't - // declare baseURL on any branch — we duck-type it as a defensive - // extension point. + // baseURL (auth backends sometimes stash it next to the key), then the + // provider config itself — a baseURL set via opencode.json provider + // options (or a config hook) lands on `provider.options` and is not + // visible through either of the first two links. No silent default to + // localhost: a misconfigured plugin should surface a clear warning, + // not phantom /v1/models calls. Cast through unknown because the Auth + // union (OAuth | ApiAuth | WellKnownAuth) doesn't declare baseURL on + // any branch — we duck-type it as a defensive extension point. const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL; + const providerBaseURL = ( + _provider as { options?: { baseURL?: unknown } } | undefined + )?.options?.baseURL; const baseURL = resolved.baseURL ?? - (typeof authBaseURL === "string" ? authBaseURL : ""); + (typeof authBaseURL === "string" && authBaseURL.length > 0 ? authBaseURL : undefined) ?? + (typeof providerBaseURL === "string" && providerBaseURL.length > 0 + ? providerBaseURL + : undefined) ?? + ""; if (!baseURL) { + console.warn( + `[omniroute-plugin] provider.models(${resolved.providerId}): ` + + `no baseURL resolvable — checked plugin opts, auth.json, and provider config. ` + + `Set baseURL in opencode.json plugin options or run \`opencode connect ${resolved.providerId}\` with a baseURL.` + ); return {}; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 942ca20da9..234d26150c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,92 @@ --- +## [3.8.23] — TBD + +### ✨ New Features + +- **Emergency budget fallback: opt-out env switch `OMNIROUTE_EMERGENCY_FALLBACK`** ([#3741](https://github.com/diegosouzapw/OmniRoute/pull/3741) — thanks @zoispag): adds an `OMNIROUTE_EMERGENCY_FALLBACK` environment variable that disables the budget-exhaustion emergency reroute to `nvidia/openai/gpt-oss-120b` entirely when set to `false` or `0`. Default behavior (enabled) is unchanged. + +- **Auto-Combo: live model intelligence scoring via Arena ELO + models.dev** ([#3660](https://github.com/diegosouzapw/OmniRoute/pull/3660) — thanks @pizzav-xyz): replaces the static fitness lookup with a 5-layer resolution chain (user override → Arena ELO → models.dev tiers → hardcoded map → neutral fallback). A sync pipeline auto-fetches Arena AI leaderboard ELO scores and derives intelligence tiers from models.dev capabilities; combo picks now update as leaderboard rankings change without any manual configuration. + +- **Vertex AI: dynamic model discovery** ([#3712](https://github.com/diegosouzapw/OmniRoute/pull/3712) — thanks @artickc): the `vertex` provider now queries the Generative Language models API at runtime to surface the full account catalog — including image-generation models (Imagen, `gemini-*-image`), embeddings, and partner models — instead of returning only the small hardcoded registry list. + +- **Vertex AI: self-tracked USD spend on the Limits page** ([#3724](https://github.com/diegosouzapw/OmniRoute/pull/3724) — thanks @artickc): since the Google Cloud Billing API is inaccessible via the proxy credential, Vertex connections now track their own cumulative USD spend locally (based on token-cost accounting) and display it on the Limits page as "$ used since account added." + +- **Gemini: rate-limit metadata for known per-model RPM/RPD caps** ([#3686](https://github.com/diegosouzapw/OmniRoute/pull/3686) — thanks @hartmark): injects known rate-limit headers (RPM/RPD) for Gemini models that carry per-model limits (e.g. Gemma 4's 15 RPM / generous RPD), so the cooldown engine applies them correctly instead of locking out the whole account on daily-limit hits. + +- **Model Lockout: full settings UI with success-decay recovery** ([#3629](https://github.com/diegosouzapw/OmniRoute/pull/3629) — thanks @Chewji9875): end-to-end wiring of the per-model lockout feature — settings UI (enable/disable, configure thresholds), backend integration, structured error classification, and a success-decay mechanism that gradually recovers a locked model's fitness as successful calls accumulate. Lockout now applies to all providers when enabled, not just per-model-quota providers. + +- **Provider display modes — All / Configured / Compact** ([#3743](https://github.com/diegosouzapw/OmniRoute/pull/3743) — thanks @rdself): adds a three-state display mode control to the Providers page. "All" shows every registered provider; "Configured" shows only providers with at least one connection; "Compact" shows configured providers in a condensed card layout for denser views. + +- **API key cost drilldown + quota % used** ([#3742](https://github.com/diegosouzapw/OmniRoute/pull/3742) — thanks @Witroch4): the API Keys page now shows a per-key cost breakdown and the percentage of quota consumed for each key. + +### 🔧 Bug Fixes + +- **`@omniroute/opencode-plugin` bundled in the npm tarball + `omniroute setup opencode` CLI command** ([#3726](https://github.com/diegosouzapw/OmniRoute/pull/3726) — thanks @herjarsa): the plugin was never compiled as part of the publish pipeline, requiring manual extraction. Now ships pre-built inside the `omniroute` package and installed via `omniroute setup opencode` (copies plugin into `~/.config/opencode/plugins/omniroute/`, updates `opencode.json` idempotently). Also fixes `provider.models` baseURL resolution — checks `_provider.options.baseURL` as a third fallback so partner/tiered providers no longer return zero models. ([#3711](https://github.com/diegosouzapw/OmniRoute/issues/3711)) + +- **MiMoCode 403 "Illegal access" fixed** ([#3728](https://github.com/diegosouzapw/OmniRoute/pull/3728) — thanks @felipesartori): the Xiaomi free endpoint gates requests on a recognized MiMoCode system-prompt signature; OmniRoute forwarded raw requests without the marker, causing 403 on every call. The executor now injects the required anti-abuse signature. + +- **"Test all models" flow: i18n crash, status icons, auto-hide** ([#3729](https://github.com/diegosouzapw/OmniRoute/pull/3729) — thanks @felipesartori): three bugs in the provider-detail test-all-models flow — `providerText()` crash because the `testAllResults` template requires `{ok, total}` but callers passed `{ok, error}`; missing `online`/`offline` status icons on model rows; results panel not auto-hiding after run completes. + +- **OAuth token-refresh invalidation loop fixed** ([#3692](https://github.com/diegosouzapw/OmniRoute/pull/3692) — thanks @diegosouzapw): `refreshClaudeOAuthToken` returned `null` instead of the error sentinel on non-canonical 400 bodies, causing the caller to retry every 60 seconds — observed as 1,352 consecutive refresh attempts on one Claude account. Fixed alongside hardening of `safeResolveProxy` (proxy resolution errors now warn instead of silently falling back to DIRECT) and adding egress-IP visibility to `safeLogEvents`. + +- **`safeLogEvents` async hotfix** (thanks @diegosouzapw): PR #3692 introduced a `lazy await import(proxyEgress)` inside a sync `safeLogEvents` — an ES syntax error that broke every consumer loading `chatHelpers` via `tsx` and caused 14 tests to fail at module load. Made `safeLogEvents` async; `void`-ed the single `chat.ts` call site. + +- **Kiro: quota tracking for IAM Identity Center accounts** ([#3722](https://github.com/diegosouzapw/OmniRoute/pull/3722) — thanks @artickc): `getKiroUsage` returned "0 used" for IAM Identity Center accounts (and `kiro-cli` imports) because those connections frequently lack a persisted `profileArn`. Now falls back to a name-based profile lookup so quota displays correctly. + +- **Empty Claude SSE stream now surfaces a real error** ([#3689](https://github.com/diegosouzapw/OmniRoute/pull/3689) — thanks @TechNickAI): when a Claude stream completed with lifecycle events but no content block, the proxy returned a synthetic `"[Proxy Error] The upstream API returned an empty response"` as a *successful* assistant message. Now emits a proper SSE error event; the missing-finalizer synthetic path is preserved for streams that already produced content. + +- **Vertex AI Express-mode API keys** ([#3690](https://github.com/diegosouzapw/OmniRoute/pull/3690) — thanks @artickc): the Vertex executor rejected every non-JSON credential with "Vertex AI requires a valid Service Account JSON." Now accepts Express-mode API key strings (`AIza*`) alongside Service Account JSON, routing them through the correct token endpoint. + +- **Anthropic: strip `top_p` when `temperature` is set** ([#3691](https://github.com/diegosouzapw/OmniRoute/pull/3691) — thanks @zhiru): Anthropic API rejects requests containing both `temperature` and `top_p`; VS Code's Claude extension sends both in every request, causing 400s on all routed calls. The OpenAI→Claude translator now drops `top_p` when `temperature` is present. + +- **Combo reasoning token buffer: conservative application + feature flag** ([#3700](https://github.com/diegosouzapw/OmniRoute/pull/3700) — thanks @rdself): tightens the #3588 buffer (only applies when the model is explicitly thinking-capable, has a non-default known output cap, and the full buffered value fits inside that cap) and adds a `reasoningTokenBufferEnabled` feature flag in combo defaults so users can fully disable it from Settings. + +- **Emergency budget fallback: cross-provider credential leak fixed** ([#3699](https://github.com/diegosouzapw/OmniRoute/pull/3699) — thanks @diegosouzapw): the executor-level emergency hop re-sent the failing provider's API key to the emergency provider's endpoint (e.g. the OpenAI `Authorization` header going to `integrate.api.nvidia.com`). Now orchestrated exclusively by the routing layer, which resolves credentials for the emergency provider via account selection and no longer fires inside combo targets. + +- **`/v1/messages/count_tokens` now honors the connection's proxy assignment** ([#3699](https://github.com/diegosouzapw/OmniRoute/pull/3699) — thanks @diegosouzapw): token count calls went DIRECT regardless of configured proxies, leaking the host IP for proxy-isolated setups. Now wraps execution in `runWithProxyContext`, exactly like chat execution. + +- **Gemini: context-mode fallback for signatureless tool calls** ([#3688](https://github.com/diegosouzapw/OmniRoute/pull/3688) — thanks @diegosouzapw): fixes HTTP 400 on multi-turn thinking-model tool calls when `thought_signature` is unavailable — standard Gemini provider now falls back to context mode instead of sending the unsigned call. + +- **Antigravity: preserve `gemini-3.1-pro` High/Low budget tiers** ([#3696](https://github.com/diegosouzapw/OmniRoute/pull/3696) — thanks @diegosouzapw): upstream accepts the suffixed ids; stop collapsing to bare `gemini-3.1-pro`. + +- **Stream combo: fail over on empty/content-filtered response** ([#3685](https://github.com/diegosouzapw/OmniRoute/pull/3685) — thanks @diegosouzapw): streaming combos now route to the next target instead of surfacing a blank reply. + +- **Qwen Web: migrated to v2 chat API** ([#3723](https://github.com/diegosouzapw/OmniRoute/pull/3723) — thanks @diegosouzapw): the legacy `/api/chat/completions` endpoint was retired upstream returning `504` HTML from Alibaba's gateway for all requests. The executor now uses the two-step v2 flow (`/api/v2/chats/new` → `/api/v2/chat/completions?chat_id=`), replays the full browser cookie jar (cna + ssxmod_itna/itna2 + token) required by Alibaba's WAF instead of only a Bearer token, parses phase-based SSE (think→reasoning, answer→content), and refreshes the model catalog to current ids (`qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`; legacy ids kept as aliases). 17 unit tests. (Closes [#3288](https://github.com/diegosouzapw/OmniRoute/issues/3288)) + +- **Responses API: `stream` defaults to `false` when omitted (spec compliance)** ([#3708](https://github.com/diegosouzapw/OmniRoute/pull/3708) — thanks @diegosouzapw): `/v1/responses` requests that omit `stream` no longer 502 (`STREAM_EARLY_EOF`) when the upstream returns a valid JSON response. `resolveStreamFlag` now applies the OpenAI Responses API spec default (stream=false) in addition to the existing Anthropic Messages API default — previously only `sourceFormat=claude` triggered this path, leaving `sourceFormat=openai-responses` to fall through to the wildcard-Accept heuristic (`Accept: */*` → streaming intent), which caused spec-compliant upstreams that return JSON to appear as a dead stream. Codex CLI (always sends `stream: true`) and explicit SSE clients (`Accept: text/event-stream`) are unaffected. + +- **Semantic cache: scope to requesting API key** ([#3740](https://github.com/diegosouzapw/OmniRoute/pull/3740) — thanks @diegosouzapw): two callers with different API keys sending the same prompt and model no longer receive each other's cached responses. `generateSignature` now includes the `api_key_id` dimension in the SHA-256 hash; unauthenticated requests (no API key) remain isolated from keyed requests. Existing cache entries (generated without the key dimension) are cleared by migration `098`. + +- **Model-family fallback: dot-notation model IDs now resolve correctly** (thanks @diegosouzapw): `getNextFamilyFallback` normalizes dots to hyphens for the initial lookup but also falls back to the bare model name, supporting IDs like `gemini-3.1-pro-high` whose dots are part of the literal name. Previously, `gemini-3.1-pro-high` silently returned null and bypassed the entire family. + +### ♻️ Code Quality + +- **Dashboard god-component (#3501): Phases 1g → 1t complete — ≤800 LOC target reached** ([#3717](https://github.com/diegosouzapw/OmniRoute/pull/3717), [#3721](https://github.com/diegosouzapw/OmniRoute/pull/3721), [#3725](https://github.com/diegosouzapw/OmniRoute/pull/3725), [#3727](https://github.com/diegosouzapw/OmniRoute/pull/3727) — thanks @diegosouzapw): four extraction phases bring `ProviderDetailPageClient.tsx` from 4,062 to 781 LOC — the ≤800 target set at the start of the refactor. Extracted OAuth flow helpers, quota display, traffic-inspector panel, logs viewer, combo-target editor, and remaining inline UI into standalone components under `providers/[id]/components/`. + +### 🌍 Internationalization + +- **zh-CN: comprehensive Simplified Chinese translation improvements** ([#3736](https://github.com/diegosouzapw/OmniRoute/pull/3736) — thanks @sdfsdfw2): broad pass on Simplified Chinese UI strings for accuracy and consistency. + +### 📝 Maintenance + +- **CI: bump GitHub Actions artifacts/cache actions to latest** (thanks @diegosouzapw): `actions/download-artifact` 4→8 ([#3733](https://github.com/diegosouzapw/OmniRoute/pull/3733)), `actions/cache` 4→5 ([#3734](https://github.com/diegosouzapw/OmniRoute/pull/3734)), `actions/upload-artifact` 4→7 ([#3735](https://github.com/diegosouzapw/OmniRoute/pull/3735)). + +- **File-size ratchet baseline reconciled** ([#3705](https://github.com/diegosouzapw/OmniRoute/pull/3705) — thanks @diegosouzapw): freezes 27 inherited/previously-grown files at their current LOC and registers `providerLimits.ts` in the gate; ongoing shrink tracked via #3501. + +- **docs: add FUNDING.yml and README Support section** ([#3698](https://github.com/diegosouzapw/OmniRoute/pull/3698) — thanks @diegosouzapw) + +- **docs(changelog): restore `#3590` bullet lost on the v3.8.20 release branch** (thanks @diegosouzapw): the fix reached `main` pre-tag via cherry-pick `#3591`, but its changelog bullet only existed on `release/v3.8.20` after the squash-merge; restored per the 2026-06-12 release-branch leftover audit. + +### ✅ Tests + +- **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. + +--- + ## [3.8.22] — 2026-06-11 ### ✨ Added @@ -24,6 +110,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). @@ -31,6 +118,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. @@ -137,6 +225,7 @@ - **fix(translator):** fix OpenAI→Gemini translation of historical tool calls — tool results from earlier turns were being converted to text, causing Gemini to pattern-match the response as prose rather than structured content; they now use the native Gemini `functionResponse` part format. ([#3569](https://github.com/diegosouzapw/OmniRoute/pull/3569) — thanks @hartmark) - **fix(plugins):** forward plugin lifecycle hooks (`onInstall`, `onActivate`, `onDeactivate`, `onUninstall`) via IPC and wrap `onDeactivate`/`onUninstall` in try/catch so a buggy plugin handler can no longer brick teardown; also removes redundant `RegExp()` wrappers in `accountFallback.ts` and fixes indentation in `requestLogger.ts`. ([#3562](https://github.com/diegosouzapw/OmniRoute/pull/3562) — thanks @oyi77) - **fix(auto-update):** use a stable PROJECT_ROOT walker instead of frozen `process.cwd()` — `resolveProjectRoot` now walks up from `__dirname` to find the nearest directory containing `package.json` or `.git` (bounded at 16 levels), preventing ENOENT errors when the working directory is not the project root. ([#3561](https://github.com/diegosouzapw/OmniRoute/pull/3561) — thanks @oyi77 / @ViFigueiredo via [#3423](https://github.com/diegosouzapw/OmniRoute/pull/3423)) +- **fix(resilience):** expose `providerCooldown` in `GET /api/resilience` and accept it in `PATCH` — PR #3556 added the global provider cooldown tracker to the settings model and `ResilienceTab` UI, but the API route never returned the field (causing a crash when the tab loaded) and `updateResilienceSchema` (`.strict()`) rejected PATCH bodies containing it with 400. `providerCooldownSettingsSchema` is now wired into the Zod schema, returned in the GET response, and merged in the PATCH handler. Caught during v3.8.20 VPS homologation (Hard Rule #18 — 5 TDD tests); shipped to main via [#3591](https://github.com/diegosouzapw/OmniRoute/pull/3591). ([#3590](https://github.com/diegosouzapw/OmniRoute/pull/3590) — thanks @diegosouzapw) --- diff --git a/README.md b/README.md index 50e484d2b6..467e920a1e 100644 --- a/README.md +++ b/README.md @@ -1013,6 +1013,14 @@ Special thanks to **[RTK - Rust Token Killer](https://github.com/rtk-ai/rtk)** b Special thanks to **[Troglodita](https://github.com/leninejunior/troglodita)** by **[Lenine Júnior](https://github.com/leninejunior)** — the PT-BR token compression project ("por que gastar muitos tokens quando poucos resolve?") whose Portuguese-native rules power OmniRoute's pt-BR language pack: pleonasm reduction, filler removal tuned for Brazilian Portuguese grammar, and technical abbreviations for the dev BR community. +## ❤️ Support + +OmniRoute is free and open source, built and maintained in the open. If it saves you time or money, consider supporting development: + +- ⭐ **Star the repo** — it genuinely helps visibility +- 💖 **[GitHub Sponsors](https://github.com/sponsors/diegosouzapw)** — fund ongoing maintenance and new providers +- 🐛 **Report bugs and share feedback** in [Discussions](https://github.com/diegosouzapw/OmniRoute/discussions) + ## 📄 License MIT License - see [LICENSE](LICENSE) for details. diff --git a/bin/cli/api-commands/agent-skills.mjs b/bin/cli/api-commands/agent-skills.mjs new file mode 100644 index 0000000000..34b7f9e8fa --- /dev/null +++ b/bin/cli/api-commands/agent-skills.mjs @@ -0,0 +1,70 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_agent_skills(parent) { + const tag = parent.command("agent-skills").description("Agent Skills endpoints"); + tag.command("get-api-agent-skills") + .description("List agent skills catalog") + .option("--category ", "Filter by category (api = REST API skills, cli = CLI skills)") + .option("--area ", "Filter by area slug (e.g. \"providers\", \"models\", \"cli-serve\")") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/agent-skills"; + const qs = new URLSearchParams(); + if (opts.category != null) qs.set("category", String(opts.category)); + if (opts.area != null) qs.set("area", String(opts.area)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-agent-skills-id-") + .description("Get a single agent skill") + .requiredOption("--id ", "Canonical skill ID") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/agent-skills/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-agent-skills-id-raw") + .description("Get raw SKILL.md content") + .requiredOption("--id ", "Canonical skill ID") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/agent-skills/{id}/raw"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-agent-skills-coverage") + .description("Get SKILL.md coverage stats") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/agent-skills/coverage"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-agent-skills-generate") + .description("Trigger SKILL.md generator") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/agent-skills/generate"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/agentbridge.mjs b/bin/cli/api-commands/agentbridge.mjs new file mode 100644 index 0000000000..6622291383 --- /dev/null +++ b/bin/cli/api-commands/agentbridge.mjs @@ -0,0 +1,155 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_agentbridge(parent) { + const tag = parent.command("agentbridge").description("AgentBridge endpoints"); + tag.command("get-api-tools-agent-bridge-agents") + .description("List all 9 IDE agents with current state") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/agents"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-agent-bridge-state") + .description("Get global AgentBridge server state") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/state"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-agent-bridge-server") + .description("Control AgentBridge MITM server") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/server"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-agent-bridge-agents-agent-id-dns") + .description("Enable or disable DNS for one agent") + .requiredOption("--agent-id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/agents/{agentId}/dns"; + url = url.replace("{agentId}", encodeURIComponent(opts.agentId ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-agent-bridge-agents-agent-id-mappings") + .description("Get model mappings for one agent") + .requiredOption("--agent-id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/agents/{agentId}/mappings"; + url = url.replace("{agentId}", encodeURIComponent(opts.agentId ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-tools-agent-bridge-agents-agent-id-mappings") + .description("Update model mappings for one agent") + .requiredOption("--agent-id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/agents/{agentId}/mappings"; + url = url.replace("{agentId}", encodeURIComponent(opts.agentId ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-agent-bridge-bypass") + .description("List bypass patterns (hosts never decrypted)") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/bypass"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-tools-agent-bridge-bypass") + .description("Update user bypass patterns") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/bypass"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-agent-bridge-cert") + .description("Download or regenerate the AgentBridge CA certificate") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/cert"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-agent-bridge-upstream-ca") + .description("Get configured upstream CA cert path") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/upstream-ca"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-agent-bridge-upstream-ca") + .description("Set upstream CA cert path for corporate TLS environments") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/agent-bridge/upstream-ca"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/api-keys.mjs b/bin/cli/api-commands/api-keys.mjs index 3ac9e570af..445df84546 100644 --- a/bin/cli/api-commands/api-keys.mjs +++ b/bin/cli/api-commands/api-keys.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_api_keys(parent) { const tag = parent.command("api-keys").description("API Keys endpoints"); - tag - .command("get-api-keys") + tag.command("get-api-keys") .description("List API keys") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/keys"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-keys") + tag.command("post-api-keys") .description("Create API key") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,26 +26,16 @@ export function register_api_keys(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-keys-id-") + tag.command("delete-api-keys-id-") .description("Delete API key") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/keys/{id}"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/audio.mjs b/bin/cli/api-commands/audio.mjs index 707d9f4e64..4465f65bc3 100644 --- a/bin/cli/api-commands/audio.mjs +++ b/bin/cli/api-commands/audio.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_audio(parent) { const tag = parent.command("audio").description("Audio endpoints"); - tag - .command("post-api-v1-audio-speech") + tag.command("post-api-v1-audio-speech") .description("Generate speech audio") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_audio(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-audio-transcriptions") + tag.command("post-api-v1-audio-transcriptions") .description("Transcribe audio") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -40,12 +33,7 @@ export function register_audio(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/chat.mjs b/bin/cli/api-commands/chat.mjs index 4912dfa079..9b6497beb6 100644 --- a/bin/cli/api-commands/chat.mjs +++ b/bin/cli/api-commands/chat.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_chat(parent) { const tag = parent.command("chat").description("Chat endpoints"); - tag - .command("post-api-v1-chat-completions") + tag.command("post-api-v1-chat-completions") .description("Create chat completion") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_chat(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-providers-provider-chat-completions") + tag.command("post-api-v1-providers-provider-chat-completions") .description("Create chat completion (provider-specific)") .requiredOption("--provider ", "") .option("--body ", "JSON body or @path/to/file.json") @@ -42,17 +35,11 @@ export function register_chat(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-api-chat") + tag.command("post-api-v1-api-chat") .description("Ollama-compatible chat endpoint") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -64,12 +51,7 @@ export function register_chat(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/cli-tools.mjs b/bin/cli/api-commands/cli-tools.mjs index 30f68eb0b0..40c879e825 100644 --- a/bin/cli/api-commands/cli-tools.mjs +++ b/bin/cli/api-commands/cli-tools.mjs @@ -5,82 +5,56 @@ import { readFileSync } from "node:fs"; export function register_cli_tools(parent) { const tag = parent.command("cli-tools").description("CLI Tools endpoints"); - tag - .command("get-api-cli-tools-backups") + tag.command("get-api-cli-tools-backups") .description("List CLI tool backups") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/backups"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-backups") + tag.command("post-api-cli-tools-backups") .description("Create CLI tool backup") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/backups"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-runtime-tool-id-") + tag.command("get-api-cli-tools-runtime-tool-id-") .description("Get runtime status for a CLI tool") .requiredOption("--tool-id ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/runtime/{toolId}"; url = url.replace("{toolId}", encodeURIComponent(opts.toolId ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-guide-settings-tool-id-") + tag.command("get-api-cli-tools-guide-settings-tool-id-") .description("Get guide settings for a tool") .requiredOption("--tool-id ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/guide-settings/{toolId}"; url = url.replace("{toolId}", encodeURIComponent(opts.toolId ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-antigravity-mitm") + tag.command("get-api-cli-tools-antigravity-mitm") .description("Get Antigravity MITM proxy settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/antigravity-mitm"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-antigravity-mitm") + tag.command("post-api-cli-tools-antigravity-mitm") .description("Update Antigravity MITM proxy settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -92,45 +66,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-antigravity-mitm") + tag.command("delete-api-cli-tools-antigravity-mitm") .description("Reset Antigravity MITM proxy settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/antigravity-mitm"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-antigravity-mitm-alias") + tag.command("get-api-cli-tools-antigravity-mitm-alias") .description("Get Antigravity MITM alias configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/antigravity-mitm/alias"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-cli-tools-antigravity-mitm-alias") + tag.command("put-api-cli-tools-antigravity-mitm-alias") .description("Update Antigravity MITM alias configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -142,31 +100,20 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-claude-settings") + tag.command("get-api-cli-tools-claude-settings") .description("Get Claude CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/claude-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-claude-settings") + tag.command("post-api-cli-tools-claude-settings") .description("Apply Claude CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -178,45 +125,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-claude-settings") + tag.command("delete-api-cli-tools-claude-settings") .description("Reset Claude CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/claude-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-cline-settings") + tag.command("get-api-cli-tools-cline-settings") .description("Get Cline CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/cline-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-cline-settings") + tag.command("post-api-cli-tools-cline-settings") .description("Apply Cline CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -228,45 +159,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-cline-settings") + tag.command("delete-api-cli-tools-cline-settings") .description("Reset Cline CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/cline-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-codex-profiles") + tag.command("get-api-cli-tools-codex-profiles") .description("Get Codex profiles") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/codex-profiles"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-codex-profiles") + tag.command("post-api-cli-tools-codex-profiles") .description("Create Codex profile") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -278,17 +193,11 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-cli-tools-codex-profiles") + tag.command("put-api-cli-tools-codex-profiles") .description("Update Codex profile") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -300,45 +209,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-codex-profiles") + tag.command("delete-api-cli-tools-codex-profiles") .description("Delete Codex profile") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/codex-profiles"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-codex-settings") + tag.command("get-api-cli-tools-codex-settings") .description("Get Codex CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/codex-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-codex-settings") + tag.command("post-api-cli-tools-codex-settings") .description("Apply Codex CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -350,45 +243,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-codex-settings") + tag.command("delete-api-cli-tools-codex-settings") .description("Reset Codex CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/codex-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-droid-settings") + tag.command("get-api-cli-tools-droid-settings") .description("Get Droid CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/droid-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-droid-settings") + tag.command("post-api-cli-tools-droid-settings") .description("Apply Droid CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -400,45 +277,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-droid-settings") + tag.command("delete-api-cli-tools-droid-settings") .description("Reset Droid CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/droid-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-kilo-settings") + tag.command("get-api-cli-tools-kilo-settings") .description("Get Kilo CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/kilo-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-kilo-settings") + tag.command("post-api-cli-tools-kilo-settings") .description("Apply Kilo CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -450,45 +311,29 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-kilo-settings") + tag.command("delete-api-cli-tools-kilo-settings") .description("Reset Kilo CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/kilo-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cli-tools-openclaw-settings") + tag.command("get-api-cli-tools-openclaw-settings") .description("Get OpenClaw CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/openclaw-settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cli-tools-openclaw-settings") + tag.command("post-api-cli-tools-openclaw-settings") .description("Apply OpenClaw CLI settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -500,26 +345,16 @@ export function register_cli_tools(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cli-tools-openclaw-settings") + tag.command("delete-api-cli-tools-openclaw-settings") .description("Reset OpenClaw CLI settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cli-tools/openclaw-settings"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/cloud.mjs b/bin/cli/api-commands/cloud.mjs index d03353a072..e942307196 100644 --- a/bin/cli/api-commands/cloud.mjs +++ b/bin/cli/api-commands/cloud.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_cloud(parent) { const tag = parent.command("cloud").description("Cloud endpoints"); - tag - .command("post-api-cloud-auth") + tag.command("post-api-cloud-auth") .description("Authenticate with cloud worker") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_cloud(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-cloud-credentials-update") + tag.command("put-api-cloud-credentials-update") .description("Update cloud worker credentials") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -40,17 +33,11 @@ export function register_cloud(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-cloud-model-resolve") + tag.command("post-api-cloud-model-resolve") .description("Resolve model via cloud") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -62,31 +49,20 @@ export function register_cloud(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cloud-models-alias") + tag.command("get-api-cloud-models-alias") .description("Get cloud model aliases") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cloud/models/alias"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-cloud-models-alias") + tag.command("put-api-cloud-models-alias") .description("Update cloud model alias") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -98,12 +74,7 @@ export function register_cloud(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/combos.mjs b/bin/cli/api-commands/combos.mjs index 57fe07c69e..2c3647f94b 100644 --- a/bin/cli/api-commands/combos.mjs +++ b/bin/cli/api-commands/combos.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_combos(parent) { const tag = parent.command("combos").description("Combos endpoints"); - tag - .command("get-api-combos") + tag.command("get-api-combos") .description("List routing combos") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-combos") + tag.command("post-api-combos") .description("Create routing combo") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,68 +26,43 @@ export function register_combos(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-combos-id-") + tag.command("patch-api-combos-id-") .description("Update combo") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/{id}"; - const res = await apiFetch(url, { - method: "PATCH", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-combos-id-") + tag.command("delete-api-combos-id-") .description("Delete combo") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/{id}"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-combos-metrics") + tag.command("get-api-combos-metrics") .description("Get combo metrics") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/metrics"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-combos-test") + tag.command("post-api-combos-test") .description("Test a combo configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/combos/test"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/compression.mjs b/bin/cli/api-commands/compression.mjs index d32ea40578..9f96e0f8b0 100644 --- a/bin/cli/api-commands/compression.mjs +++ b/bin/cli/api-commands/compression.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_compression(parent) { const tag = parent.command("compression").description("Compression endpoints"); - tag - .command("get-api-settings-compression") + tag.command("get-api-settings-compression") .description("Get global compression settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/compression"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-settings-compression") + tag.command("put-api-settings-compression") .description("Update global compression settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,17 +26,11 @@ export function register_compression(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-compression-preview") + tag.command("post-api-compression-preview") .description("Preview compression for a message payload") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -54,59 +42,38 @@ export function register_compression(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-compression-language-packs") + tag.command("get-api-compression-language-packs") .description("List Caveman compression language packs") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/compression/language-packs"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-compression-rules") + tag.command("get-api-compression-rules") .description("List Caveman compression rule metadata") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/compression/rules"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-context-rtk-config") + tag.command("get-api-context-rtk-config") .description("Get RTK compression settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/context/rtk/config"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-context-rtk-config") + tag.command("put-api-context-rtk-config") .description("Update RTK compression settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -118,31 +85,20 @@ export function register_compression(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-context-rtk-filters") + tag.command("get-api-context-rtk-filters") .description("List RTK filters and load diagnostics") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/context/rtk/filters"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-context-rtk-test") + tag.command("post-api-context-rtk-test") .description("Run RTK compression preview for text") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -154,28 +110,18 @@ export function register_compression(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-context-rtk-raw-output-id-") + tag.command("get-api-context-rtk-raw-output-id-") .description("Read retained redacted RTK raw output") .requiredOption("--id ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/context/rtk/raw-output/{id}"; url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/embedded-services.mjs b/bin/cli/api-commands/embedded-services.mjs new file mode 100644 index 0000000000..0baa58b8a7 --- /dev/null +++ b/bin/cli/api-commands/embedded-services.mjs @@ -0,0 +1,202 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_embedded_services(parent) { + const tag = parent.command("embedded-services").description("Embedded Services endpoints"); + tag.command("post-api-services-9router-install") + .description("Install 9Router from npm") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/install"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-start") + .description("Start 9Router") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/start"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-stop") + .description("Stop 9Router") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/stop"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-restart") + .description("Restart 9Router") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/restart"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-update") + .description("Update 9Router to a newer npm version") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/update"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-rotate-key") + .description("Rotate the 9Router API key") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/rotate-key"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-services-9router-status") + .description("Get 9Router status") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/status"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-9router-auto-start") + .description("Toggle 9Router auto-start") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/9router/auto-start"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-install") + .description("Install CLIProxyAPI from npm") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/install"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-start") + .description("Start CLIProxyAPI") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/start"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-stop") + .description("Stop CLIProxyAPI") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/stop"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-restart") + .description("Restart CLIProxyAPI") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/restart"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-update") + .description("Update CLIProxyAPI to a newer npm version") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/update"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-services-cliproxy-status") + .description("Get CLIProxyAPI status") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/status"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-services-cliproxy-auto-start") + .description("Toggle CLIProxyAPI auto-start") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/cliproxy/auto-start"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-services-name-logs") + .description("Stream service logs via SSE") + .requiredOption("--name ", "") + .option("--tail ", "Number of historical lines to include in the initial snapshot") + .option("--filter ", "Case-insensitive substring filter applied to log lines. No regex — ReDoS-safe by design.") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/services/{name}/logs"; + url = url.replace("{name}", encodeURIComponent(opts.name ?? "")); + const qs = new URLSearchParams(); + if (opts.tail != null) qs.set("tail", String(opts.tail)); + if (opts.filter != null) qs.set("filter", String(opts.filter)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/embeddings.mjs b/bin/cli/api-commands/embeddings.mjs index 77d75271d2..8ae1235a88 100644 --- a/bin/cli/api-commands/embeddings.mjs +++ b/bin/cli/api-commands/embeddings.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_embeddings(parent) { const tag = parent.command("embeddings").description("Embeddings endpoints"); - tag - .command("post-api-v1-embeddings") + tag.command("post-api-v1-embeddings") .description("Create embeddings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_embeddings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-providers-provider-embeddings") + tag.command("post-api-v1-providers-provider-embeddings") .description("Create embeddings (provider-specific)") .requiredOption("--provider ", "") .option("--body ", "JSON body or @path/to/file.json") @@ -42,12 +35,7 @@ export function register_embeddings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/fallback.mjs b/bin/cli/api-commands/fallback.mjs index 0e8824900c..b769ec8032 100644 --- a/bin/cli/api-commands/fallback.mjs +++ b/bin/cli/api-commands/fallback.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_fallback(parent) { const tag = parent.command("fallback").description("Fallback endpoints"); - tag - .command("get-api-fallback-chains") + tag.command("get-api-fallback-chains") .description("List fallback chains") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/fallback/chains"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-fallback-chains") + tag.command("post-api-fallback-chains") .description("Create fallback chain") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,17 +26,11 @@ export function register_fallback(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-fallback-chains") + tag.command("delete-api-fallback-chains") .description("Delete fallback chain") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -54,12 +42,7 @@ export function register_fallback(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "DELETE", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/images.mjs b/bin/cli/api-commands/images.mjs index af14946f57..14cc850844 100644 --- a/bin/cli/api-commands/images.mjs +++ b/bin/cli/api-commands/images.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_images(parent) { const tag = parent.command("images").description("Images endpoints"); - tag - .command("post-api-v1-images-generations") + tag.command("post-api-v1-images-generations") .description("Generate images") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_images(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-providers-provider-images-generations") + tag.command("post-api-v1-providers-provider-images-generations") .description("Generate images (provider-specific)") .requiredOption("--provider ", "") .option("--body ", "JSON body or @path/to/file.json") @@ -42,12 +35,7 @@ export function register_images(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/memory.mjs b/bin/cli/api-commands/memory.mjs new file mode 100644 index 0000000000..6cf8f68679 --- /dev/null +++ b/bin/cli/api-commands/memory.mjs @@ -0,0 +1,251 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_memory(parent) { + const tag = parent.command("memory").description("Memory endpoints"); + tag.command("get-api-memory") + .description("List memory entries") + .option("--api-key-id ", "") + .option("--type ", "") + .option("--session-id ", "") + .option("--q ", "") + .option("--limit ", "") + .option("--page ", "") + .option("--offset ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory"; + const qs = new URLSearchParams(); + if (opts.apiKeyId != null) qs.set("apiKeyId", String(opts.apiKeyId)); + if (opts.type != null) qs.set("type", String(opts.type)); + if (opts.sessionId != null) qs.set("sessionId", String(opts.sessionId)); + if (opts.q != null) qs.set("q", String(opts.q)); + if (opts.limit != null) qs.set("limit", String(opts.limit)); + if (opts.page != null) qs.set("page", String(opts.page)); + if (opts.offset != null) qs.set("offset", String(opts.offset)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-memory") + .description("Create a memory entry") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-memory-id-") + .description("Get a single memory entry") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/{id}"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-memory-id-") + .description("Update a memory entry") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/{id}"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-memory-id-") + .description("Delete a memory entry") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/{id}"; + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-memory-health") + .description("Memory store health check") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/health"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-memory-retrieve-preview") + .description("Dry-run memory retrieval (Playground)") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/retrieve-preview"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-memory-embedding-providers") + .description("List embedding providers") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/embedding-providers"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-memory-engine-status") + .description("Memory engine status") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/engine-status"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-memory-summarize") + .description("Compact old memories") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/summarize"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-memory-reindex") + .description("Trigger vector reindex") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/memory/reindex"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-settings-memory") + .description("Get memory settings") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/memory"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-settings-memory") + .description("Update memory settings") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/memory"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-settings-qdrant") + .description("Get Qdrant settings") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-settings-qdrant") + .description("Update Qdrant settings") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-settings-qdrant-health") + .description("Qdrant health probe") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant/health"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-settings-qdrant-search") + .description("Qdrant semantic search test") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant/search"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-settings-qdrant-cleanup") + .description("Clean up expired Qdrant points") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant/cleanup"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-settings-qdrant-embedding-models") + .description("List Qdrant embedding models") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/qdrant/embedding-models"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/messages.mjs b/bin/cli/api-commands/messages.mjs index cee8c4e5de..6618f31a9e 100644 --- a/bin/cli/api-commands/messages.mjs +++ b/bin/cli/api-commands/messages.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_messages(parent) { const tag = parent.command("messages").description("Messages endpoints"); - tag - .command("post-api-v1-messages") + tag.command("post-api-v1-messages") .description("Create message (Anthropic-compatible)") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_messages(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1-messages-count-tokens") + tag.command("post-api-v1-messages-count-tokens") .description("Count tokens for a message") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -40,12 +33,7 @@ export function register_messages(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/models.mjs b/bin/cli/api-commands/models.mjs index 73946c0e4f..09d0fa0e8a 100644 --- a/bin/cli/api-commands/models.mjs +++ b/bin/cli/api-commands/models.mjs @@ -5,55 +5,36 @@ import { readFileSync } from "node:fs"; export function register_models(parent) { const tag = parent.command("models").description("Models endpoints"); - tag - .command("get-api-v1-models") + tag.command("get-api-v1-models") .description("List available models") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/v1/models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-v1-providers-provider-models") + tag.command("get-api-v1-providers-provider-models") .description("List models for a specific provider") - .requiredOption( - "--provider ", - "Provider id or alias (for example `openai`, `claude`, `cc`)." - ) + .requiredOption("--provider ", "Provider id or alias (for example `openai`, `claude`, `cc`).") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/v1/providers/{provider}/models"; url = url.replace("{provider}", encodeURIComponent(opts.provider ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-models") + tag.command("get-api-models") .description("List models (management)") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-models-alias") + tag.command("post-api-models-alias") .description("Create or update a model alias") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -65,45 +46,29 @@ export function register_models(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-models-catalog") + tag.command("get-api-models-catalog") .description("Get full model catalog") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/models/catalog"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-v1beta-models") + tag.command("get-api-v1beta-models") .description("List models (Gemini format)") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/v1beta/models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-v1beta-models-path-") + tag.command("post-api-v1beta-models-path-") .description("Gemini generateContent") .requiredOption("--path ", "") .option("--body ", "JSON body or @path/to/file.json") @@ -117,12 +82,7 @@ export function register_models(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/moderations.mjs b/bin/cli/api-commands/moderations.mjs index 8bac14117f..0c716202f6 100644 --- a/bin/cli/api-commands/moderations.mjs +++ b/bin/cli/api-commands/moderations.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_moderations(parent) { const tag = parent.command("moderations").description("Moderations endpoints"); - tag - .command("post-api-v1-moderations") + tag.command("post-api-v1-moderations") .description("Create moderation") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,12 +17,7 @@ export function register_moderations(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/oauth.mjs b/bin/cli/api-commands/oauth.mjs index 6aed0c8099..1645bc3220 100644 --- a/bin/cli/api-commands/oauth.mjs +++ b/bin/cli/api-commands/oauth.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_oauth(parent) { const tag = parent.command("oauth").description("OAuth endpoints"); - tag - .command("get-api-oauth-provider-action-") + tag.command("get-api-oauth-provider-action-") .description("OAuth flow handler") .requiredOption("--provider ", "") .requiredOption("--action ", "") @@ -15,44 +14,29 @@ export function register_oauth(parent) { let url = "/api/oauth/{provider}/{action}"; url = url.replace("{provider}", encodeURIComponent(opts.provider ?? "")); url = url.replace("{action}", encodeURIComponent(opts.action ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-oauth-cursor-auto-import") + tag.command("get-api-oauth-cursor-auto-import") .description("Auto-import Cursor OAuth credentials") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/oauth/cursor/auto-import"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-oauth-cursor-import") + tag.command("get-api-oauth-cursor-import") .description("Get Cursor import status") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/oauth/cursor/import"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-oauth-cursor-import") + tag.command("post-api-oauth-cursor-import") .description("Import Cursor OAuth credentials") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -64,45 +48,29 @@ export function register_oauth(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-oauth-kiro-auto-import") + tag.command("get-api-oauth-kiro-auto-import") .description("Auto-import Kiro OAuth credentials") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/oauth/kiro/auto-import"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-oauth-kiro-import") + tag.command("get-api-oauth-kiro-import") .description("Get Kiro import status") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/oauth/kiro/import"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-oauth-kiro-import") + tag.command("post-api-oauth-kiro-import") .description("Import Kiro OAuth credentials") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -114,31 +82,20 @@ export function register_oauth(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-oauth-kiro-social-authorize") + tag.command("get-api-oauth-kiro-social-authorize") .description("Initiate Kiro social OAuth authorization") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/oauth/kiro/social-authorize"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-oauth-kiro-social-exchange") + tag.command("post-api-oauth-kiro-social-exchange") .description("Exchange Kiro social OAuth token") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -150,12 +107,7 @@ export function register_oauth(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/playground.mjs b/bin/cli/api-commands/playground.mjs new file mode 100644 index 0000000000..3866409833 --- /dev/null +++ b/bin/cli/api-commands/playground.mjs @@ -0,0 +1,83 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_playground(parent) { + const tag = parent.command("playground").description("Playground endpoints"); + tag.command("post-api-playground-improve-prompt") + .description("Improve prompt via LLM") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/improve-prompt"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-playground-presets") + .description("List playground presets") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/presets"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-playground-presets") + .description("Create playground preset") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/presets"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-playground-presets-id-") + .description("Get playground preset") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/presets/{id}"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-playground-presets-id-") + .description("Update playground preset") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/presets/{id}"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-playground-presets-id-") + .description("Delete playground preset") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/playground/presets/{id}"; + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/pricing.mjs b/bin/cli/api-commands/pricing.mjs index 76d59680d0..b790e24665 100644 --- a/bin/cli/api-commands/pricing.mjs +++ b/bin/cli/api-commands/pricing.mjs @@ -5,59 +5,39 @@ import { readFileSync } from "node:fs"; export function register_pricing(parent) { const tag = parent.command("pricing").description("Pricing endpoints"); - tag - .command("get-api-pricing") + tag.command("get-api-pricing") .description("Get model pricing") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/pricing"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-pricing") + tag.command("post-api-pricing") .description("Set model pricing") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/pricing"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-pricing-defaults") + tag.command("get-api-pricing-defaults") .description("Get default pricing") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/pricing/defaults"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-pricing-models") + tag.command("get-api-pricing-models") .description("Get pricing per model") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/pricing/models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/provider-nodes.mjs b/bin/cli/api-commands/provider-nodes.mjs index d8a96b51f6..1444f8d267 100644 --- a/bin/cli/api-commands/provider-nodes.mjs +++ b/bin/cli/api-commands/provider-nodes.mjs @@ -5,87 +5,57 @@ import { readFileSync } from "node:fs"; export function register_provider_nodes(parent) { const tag = parent.command("provider-nodes").description("Provider Nodes endpoints"); - tag - .command("get-api-provider-nodes") + tag.command("get-api-provider-nodes") .description("List provider nodes") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-nodes"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-provider-nodes") + tag.command("post-api-provider-nodes") .description("Create provider node") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-nodes"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-provider-nodes-id-") + tag.command("patch-api-provider-nodes-id-") .description("Update provider node") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-nodes/{id}"; - const res = await apiFetch(url, { - method: "PATCH", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-provider-nodes-id-") + tag.command("delete-api-provider-nodes-id-") .description("Delete provider node") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-nodes/{id}"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-provider-nodes-validate") + tag.command("post-api-provider-nodes-validate") .description("Validate a provider node") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-nodes/validate"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-provider-models") + tag.command("get-api-provider-models") .description("List provider models") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/provider-models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/providers.mjs b/bin/cli/api-commands/providers.mjs index 79a76e77bd..d0e56e2f84 100644 --- a/bin/cli/api-commands/providers.mjs +++ b/bin/cli/api-commands/providers.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_providers(parent) { const tag = parent.command("providers").description("Providers endpoints"); - tag - .command("get-api-providers") + tag.command("get-api-providers") .description("List provider connections") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-providers") + tag.command("post-api-providers") .description("Create provider connection") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,31 +26,20 @@ export function register_providers(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-providers-id-") + tag.command("get-api-providers-id-") .description("Get provider connection") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/{id}"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-providers-id-") + tag.command("patch-api-providers-id-") .description("Update provider connection") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -68,96 +51,97 @@ export function register_providers(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PATCH", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-providers-id-") + tag.command("delete-api-providers-id-") .description("Delete provider connection") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/{id}"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-providers-id-test") + tag.command("post-api-providers-id-test") .description("Test provider connection") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/{id}/test"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-providers-id-models") + tag.command("get-api-providers-id-models") .description("List models for a provider") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/{id}/models"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-providers-test-batch") + tag.command("post-api-providers-test-batch") .description("Test multiple providers at once") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/test-batch"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-providers-validate") + tag.command("post-api-providers-validate") .description("Validate provider credentials") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/validate"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-providers-client") + tag.command("get-api-providers-client") .description("Get client-side provider info") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/providers/client"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-providers-agy-auth-import") + .description("Import an Antigravity CLI (agy) token file as an `agy` connection") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/providers/agy-auth/import"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-providers-agy-auth-import-bulk") + .description("Bulk-import multiple Antigravity CLI (agy) token files (up to 50)") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/providers/agy-auth/import-bulk"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-providers-agy-auth-zip-extract") + .description("Extract `.json` token files from an uploaded ZIP for agy bulk import") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/providers/agy-auth/zip-extract"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-providers-agy-auth-apply-local") + .description("Auto-detect and import the local Antigravity CLI (agy) login from disk") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/providers/agy-auth/apply-local"; + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/quota.mjs b/bin/cli/api-commands/quota.mjs new file mode 100644 index 0000000000..5f72dad905 --- /dev/null +++ b/bin/cli/api-commands/quota.mjs @@ -0,0 +1,154 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_quota(parent) { + const tag = parent.command("quota").description("Quota endpoints"); + tag.command("get-api-quota-pools") + .description("List quota pools") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-quota-pools") + .description("Create quota pool") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-quota-pools-id-") + .description("Get quota pool by ID") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("patch-api-quota-pools-id-") + .description("Update quota pool (name or allocations)") + .requiredOption("--id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-quota-pools-id-") + .description("Delete quota pool") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-quota-pools-id-usage") + .description("Get pool usage snapshot (per-key consumption + burn rate)") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/pools/{id}/usage"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-quota-plans") + .description("List resolved provider plans (catalog + manual overrides)") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/plans"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-quota-plans-connection-id-") + .description("Get resolved plan for a connection") + .requiredOption("--connection-id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/plans/{connectionId}"; + url = url.replace("{connectionId}", encodeURIComponent(opts.connectionId ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-quota-plans-connection-id-") + .description("Upsert manual plan override for a connection") + .requiredOption("--connection-id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/plans/{connectionId}"; + url = url.replace("{connectionId}", encodeURIComponent(opts.connectionId ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-quota-plans-connection-id-") + .description("Delete manual plan override (reverts to catalog/auto)") + .requiredOption("--connection-id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/plans/{connectionId}"; + url = url.replace("{connectionId}", encodeURIComponent(opts.connectionId ?? "")); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-quota-preview") + .description("Dry-run quota enforcement check (preview only, no consumption recorded)") + .requiredOption("--api-key-id ", "") + .requiredOption("--pool-id ", "") + .option("--estimated-tokens ", "") + .option("--estimated-usd ", "") + .option("--estimated-requests ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/quota/preview"; + const qs = new URLSearchParams(); + if (opts.apiKeyId != null) qs.set("apiKeyId", String(opts.apiKeyId)); + if (opts.poolId != null) qs.set("poolId", String(opts.poolId)); + if (opts.estimatedTokens != null) qs.set("estimatedTokens", String(opts.estimatedTokens)); + if (opts.estimatedUsd != null) qs.set("estimatedUsd", String(opts.estimatedUsd)); + if (opts.estimatedRequests != null) qs.set("estimatedRequests", String(opts.estimatedRequests)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/registry.mjs b/bin/cli/api-commands/registry.mjs index e214f6cd63..95a5ec8172 100644 --- a/bin/cli/api-commands/registry.mjs +++ b/bin/cli/api-commands/registry.mjs @@ -1,4 +1,6 @@ // AUTO-GENERATED. Do not edit. +import { register_playground } from "./playground.mjs"; +import { register_memory } from "./memory.mjs"; import { register_chat } from "./chat.mjs"; import { register_messages } from "./messages.mjs"; import { register_responses } from "./responses.mjs"; @@ -19,37 +21,17 @@ import { register_usage } from "./usage.mjs"; import { register_pricing } from "./pricing.mjs"; import { register_translator } from "./translator.mjs"; import { register_cli_tools } from "./cli-tools.mjs"; +import { register_embedded_services } from "./embedded-services.mjs"; import { register_oauth } from "./oauth.mjs"; import { register_cloud } from "./cloud.mjs"; import { register_fallback } from "./fallback.mjs"; import { register_telemetry } from "./telemetry.mjs"; +import { register_quota } from "./quota.mjs"; +import { register_agentbridge } from "./agentbridge.mjs"; +import { register_traffic_inspector } from "./traffic-inspector.mjs"; +import { register_agent_skills } from "./agent-skills.mjs"; -export const API_TAGS = [ - "chat", - "messages", - "responses", - "embeddings", - "images", - "audio", - "moderations", - "rerank", - "system", - "models", - "providers", - "provider-nodes", - "api-keys", - "combos", - "settings", - "compression", - "usage", - "pricing", - "translator", - "cli-tools", - "oauth", - "cloud", - "fallback", - "telemetry", -]; +export const API_TAGS = ["playground","memory","chat","messages","responses","embeddings","images","audio","moderations","rerank","system","models","providers","provider-nodes","api-keys","combos","settings","compression","usage","pricing","translator","cli-tools","embedded-services","oauth","cloud","fallback","telemetry","quota","agentbridge","traffic-inspector","agent-skills"]; export function registerApiCommands(program) { const api = program @@ -58,9 +40,9 @@ export function registerApiCommands(program) { api .command("tags") .description("List available API tag groups") - .action(() => { - API_TAGS.forEach((t) => console.log(t)); - }); + .action(() => { API_TAGS.forEach((t) => console.log(t)); }); + register_playground(api); + register_memory(api); register_chat(api); register_messages(api); register_responses(api); @@ -81,8 +63,13 @@ export function registerApiCommands(program) { register_pricing(api); register_translator(api); register_cli_tools(api); + register_embedded_services(api); register_oauth(api); register_cloud(api); register_fallback(api); register_telemetry(api); + register_quota(api); + register_agentbridge(api); + register_traffic_inspector(api); + register_agent_skills(api); } diff --git a/bin/cli/api-commands/rerank.mjs b/bin/cli/api-commands/rerank.mjs index 56c3d69fb3..522999ede6 100644 --- a/bin/cli/api-commands/rerank.mjs +++ b/bin/cli/api-commands/rerank.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_rerank(parent) { const tag = parent.command("rerank").description("Rerank endpoints"); - tag - .command("post-api-v1-rerank") + tag.command("post-api-v1-rerank") .description("Rerank documents") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,12 +17,7 @@ export function register_rerank(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/responses.mjs b/bin/cli/api-commands/responses.mjs index 549bbed641..45ee00080e 100644 --- a/bin/cli/api-commands/responses.mjs +++ b/bin/cli/api-commands/responses.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_responses(parent) { const tag = parent.command("responses").description("Responses endpoints"); - tag - .command("post-api-v1-responses") + tag.command("post-api-v1-responses") .description("Create response (OpenAI Responses API)") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,12 +17,7 @@ export function register_responses(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/settings.mjs b/bin/cli/api-commands/settings.mjs index 31a5437df3..2a2d5ee7b5 100644 --- a/bin/cli/api-commands/settings.mjs +++ b/bin/cli/api-commands/settings.mjs @@ -5,22 +5,16 @@ import { readFileSync } from "node:fs"; export function register_settings(parent) { const tag = parent.command("settings").description("Settings endpoints"); - tag - .command("get-api-settings") + tag.command("get-api-settings") .description("Get application settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-settings") + tag.command("patch-api-settings") .description("Update settings") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -32,31 +26,20 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PATCH", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-payload-rules") + tag.command("get-api-settings-payload-rules") .description("Get payload rules configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/payload-rules"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-settings-payload-rules") + tag.command("put-api-settings-payload-rules") .description("Update payload rules configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -68,101 +51,65 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-combo-defaults") + tag.command("get-api-settings-combo-defaults") .description("Get combo default settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/combo-defaults"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-proxy") + tag.command("get-api-settings-proxy") .description("Get proxy settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/proxy"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-settings-proxy") + tag.command("patch-api-settings-proxy") .description("Update proxy settings") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/proxy"; - const res = await apiFetch(url, { - method: "PATCH", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-settings-proxy-test") + tag.command("post-api-settings-proxy-test") .description("Test proxy connection") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/proxy/test"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-settings-require-login") + tag.command("post-api-settings-require-login") .description("Toggle login requirement") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/require-login"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-ip-filter") + tag.command("get-api-settings-ip-filter") .description("Get IP filter configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/ip-filter"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-settings-ip-filter") + tag.command("put-api-settings-ip-filter") .description("Update IP filter configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -174,31 +121,20 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-system-prompt") + tag.command("get-api-settings-system-prompt") .description("Get system prompt configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/system-prompt"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-settings-system-prompt") + tag.command("put-api-settings-system-prompt") .description("Update system prompt configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -210,31 +146,20 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-settings-thinking-budget") + tag.command("get-api-settings-thinking-budget") .description("Get thinking budget configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/settings/thinking-budget"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("put-api-settings-thinking-budget") + tag.command("put-api-settings-thinking-budget") .description("Update thinking budget configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -246,31 +171,20 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PUT", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-rate-limit") + tag.command("get-api-rate-limit") .description("Get rate limit configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/rate-limit"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-rate-limit") + tag.command("post-api-rate-limit") .description("Update rate limit configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -282,12 +196,32 @@ export function register_settings(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-settings-quota-store") + .description("Get current quota store driver settings") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/quota-store"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-settings-quota-store") + .description("Update quota store driver settings") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/settings/quota-store"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/system.mjs b/bin/cli/api-commands/system.mjs index eb15c55f1c..c0def12050 100644 --- a/bin/cli/api-commands/system.mjs +++ b/bin/cli/api-commands/system.mjs @@ -5,36 +5,25 @@ import { readFileSync } from "node:fs"; export function register_system(parent) { const tag = parent.command("system").description("System endpoints"); - tag - .command("get-api-v1") + tag.command("get-api-v1") .description("API v1 root endpoint") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/v1"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-tags") + tag.command("get-api-tags") .description("List Ollama-compatible model tags") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/tags"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-auth-login") + tag.command("post-api-auth-login") .description("Authenticate user") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -46,157 +35,101 @@ export function register_system(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-auth-logout") + tag.command("post-api-auth-logout") .description("Log out") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/auth/logout"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-init") + tag.command("get-api-init") .description("Initialize application") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/init"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-restart") + tag.command("post-api-restart") .description("Restart the application") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/restart"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-shutdown") + tag.command("post-api-shutdown") .description("Shutdown the application") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/shutdown"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-db-backups") + tag.command("get-api-db-backups") .description("List database backups") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/db-backups"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-db-backups") + tag.command("post-api-db-backups") .description("Create database backup") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/db-backups"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-storage-health") + tag.command("get-api-storage-health") .description("Check storage health") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/storage/health"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-sync-cloud") + tag.command("post-api-sync-cloud") .description("Sync with cloud") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/sync/cloud"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-sync-initialize") + tag.command("post-api-sync-initialize") .description("Initialize cloud sync") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/sync/initialize"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-resilience") + tag.command("get-api-resilience") .description("Get resilience configuration") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/resilience"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("patch-api-resilience") + tag.command("patch-api-resilience") .description("Update resilience configuration") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -208,143 +141,92 @@ export function register_system(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "PATCH", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-resilience-reset") + tag.command("post-api-resilience-reset") .description("Reset circuit breakers") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/resilience/reset"; - const res = await apiFetch(url, { - method: "POST", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-monitoring-health") + tag.command("get-api-monitoring-health") .description("System health check") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/monitoring/health"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-rate-limits") + tag.command("get-api-rate-limits") .description("Get per-account rate limit status") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/rate-limits"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-sessions") + tag.command("get-api-sessions") .description("Get active sessions") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/sessions"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cache") + tag.command("get-api-cache") .description("Get cache statistics") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cache"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cache") + tag.command("delete-api-cache") .description("Clear all caches") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cache"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-cache-stats") + tag.command("get-api-cache-stats") .description("Get detailed cache statistics") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cache/stats"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-cache-stats") + tag.command("delete-api-cache-stats") .description("Clear cache statistics") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/cache/stats"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-evals") + tag.command("get-api-evals") .description("List eval suites") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/evals"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-evals") + tag.command("post-api-evals") .description("Run evaluation") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -356,47 +238,31 @@ export function register_system(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-evals-suite-id-") + tag.command("get-api-evals-suite-id-") .description("Get eval suite details") .requiredOption("--suite-id ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/evals/{suiteId}"; url = url.replace("{suiteId}", encodeURIComponent(opts.suiteId ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-policies") + tag.command("get-api-policies") .description("List routing policies") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/policies"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-policies") + tag.command("post-api-policies") .description("Create routing policy") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -408,58 +274,46 @@ export function register_system(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("delete-api-policies") + tag.command("delete-api-policies") .description("Delete routing policy") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/policies"; - const res = await apiFetch(url, { - method: "DELETE", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-compliance-audit-log") + tag.command("get-api-compliance-audit-log") .description("Get compliance audit log") + .option("--level ", "high = Activity feed events only; all = all audit events") + .option("--action ", "Filter by exact action string (e.g. \"provider.added\")") + .option("--actor ", "Filter by actor identifier") .option("--limit ", "") + .option("--offset ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/compliance/audit-log"; const qs = new URLSearchParams(); + if (opts.level != null) qs.set("level", String(opts.level)); + if (opts.action != null) qs.set("action", String(opts.action)); + if (opts.actor != null) qs.set("actor", String(opts.actor)); if (opts.limit != null) qs.set("limit", String(opts.limit)); + if (opts.offset != null) qs.set("offset", String(opts.offset)); if (qs.toString()) url += "?" + qs.toString(); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-openapi-spec") + tag.command("get-api-openapi-spec") .description("Get OpenAPI specification catalog") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/openapi/spec"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/telemetry.mjs b/bin/cli/api-commands/telemetry.mjs index c8dfa34b24..68a22de0c1 100644 --- a/bin/cli/api-commands/telemetry.mjs +++ b/bin/cli/api-commands/telemetry.mjs @@ -5,31 +5,21 @@ import { readFileSync } from "node:fs"; export function register_telemetry(parent) { const tag = parent.command("telemetry").description("Telemetry endpoints"); - tag - .command("get-api-telemetry-summary") + tag.command("get-api-telemetry-summary") .description("Get telemetry summary") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/telemetry/summary"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-token-health") + tag.command("get-api-token-health") .description("Get token health status") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/token-health"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/traffic-inspector.mjs b/bin/cli/api-commands/traffic-inspector.mjs new file mode 100644 index 0000000000..1572090b54 --- /dev/null +++ b/bin/cli/api-commands/traffic-inspector.mjs @@ -0,0 +1,307 @@ +// AUTO-GENERATED from docs/reference/openapi.yaml. Do not edit. +import { apiFetch } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { readFileSync } from "node:fs"; + +export function register_traffic_inspector(parent) { + const tag = parent.command("traffic-inspector").description("Traffic Inspector endpoints"); + tag.command("get-api-tools-traffic-inspector-requests") + .description("List intercepted requests (filterable)") + .option("--profile ", "") + .option("--host ", "") + .option("--agent ", "") + .option("--status ", "") + .option("--source ", "") + .option("--session-id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/requests"; + const qs = new URLSearchParams(); + if (opts.profile != null) qs.set("profile", String(opts.profile)); + if (opts.host != null) qs.set("host", String(opts.host)); + if (opts.agent != null) qs.set("agent", String(opts.agent)); + if (opts.status != null) qs.set("status", String(opts.status)); + if (opts.source != null) qs.set("source", String(opts.source)); + if (opts.sessionId != null) qs.set("sessionId", String(opts.sessionId)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-tools-traffic-inspector-requests") + .description("Clear the in-memory traffic buffer") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/requests"; + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-requests-id-") + .description("Get a single intercepted request by ID") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/requests/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-requests-id-replay") + .description("Replay a captured request through OmniRoute router") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/requests/{id}/replay"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "POST", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("put-api-tools-traffic-inspector-requests-id-annotation") + .description("Save or update annotation on a request") + .requiredOption("--id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/requests/{id}/annotation"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PUT", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-ws") + .description("Live WebSocket stream of intercepted requests") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/ws"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-export-har") + .description("Export current filtered request list as HAR 1.2") + .option("--profile ", "") + .option("--session-id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/export.har"; + const qs = new URLSearchParams(); + if (opts.profile != null) qs.set("profile", String(opts.profile)); + if (opts.sessionId != null) qs.set("sessionId", String(opts.sessionId)); + if (qs.toString()) url += "?" + qs.toString(); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-hosts") + .description("List custom capture hosts") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/hosts"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-hosts") + .description("Add a custom capture host (edits /etc/hosts)") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/hosts"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-tools-traffic-inspector-hosts-host-") + .description("Remove a custom capture host") + .requiredOption("--host ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/hosts/{host}"; + url = url.replace("{host}", encodeURIComponent(opts.host ?? "")); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("patch-api-tools-traffic-inspector-hosts-host-") + .description("Toggle enabled state of a custom host") + .requiredOption("--host ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/hosts/{host}"; + url = url.replace("{host}", encodeURIComponent(opts.host ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-capture-modes") + .description("Get state of all 4 capture modes") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/capture-modes"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-capture-modes-http-proxy") + .description("Start or stop the HTTP_PROXY listener (port 8080)") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/capture-modes/http-proxy"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-capture-modes-system-proxy") + .description("Apply or revert system-wide proxy settings") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/capture-modes/system-proxy"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-capture-modes-tls-intercept") + .description("Toggle TLS body decryption in HTTP_PROXY mode") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/capture-modes/tls-intercept"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-sessions") + .description("List all saved recording sessions") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions"; + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-sessions") + .description("Start a new recording session") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-sessions-id-") + .description("Get session snapshot (all captured requests)") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("patch-api-tools-traffic-inspector-sessions-id-") + .description("Stop or rename a recording session") + .requiredOption("--id ", "") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "PATCH", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("delete-api-tools-traffic-inspector-sessions-id-") + .description("Delete a recording session") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions/{id}"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "DELETE", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("get-api-tools-traffic-inspector-sessions-id-export-har") + .description("Export a recorded session as HAR 1.2") + .requiredOption("--id ", "") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/sessions/{id}/export.har"; + url = url.replace("{id}", encodeURIComponent(opts.id ?? "")); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); + tag.command("post-api-tools-traffic-inspector-internal-ingest") + .description("Internal ingest endpoint for server.cjs passthrough path") + .option("--body ", "JSON body or @path/to/file.json") + .action(async (opts, cmd) => { + const gOpts = cmd.optsWithGlobals(); + let url = "/api/tools/traffic-inspector/internal/ingest"; + let body; + if (opts.body) { + body = opts.body.startsWith("@") + ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) + : JSON.parse(opts.body); + } + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); + const data = res.ok ? await res.json() : await res.text(); + emit(data, gOpts); + }); +} diff --git a/bin/cli/api-commands/translator.mjs b/bin/cli/api-commands/translator.mjs index dae8df965c..10c1a03e22 100644 --- a/bin/cli/api-commands/translator.mjs +++ b/bin/cli/api-commands/translator.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_translator(parent) { const tag = parent.command("translator").description("Translator endpoints"); - tag - .command("post-api-translator-detect") + tag.command("post-api-translator-detect") .description("Detect request format") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -18,17 +17,11 @@ export function register_translator(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-translator-translate") + tag.command("post-api-translator-translate") .description("Translate between formats") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -40,17 +33,11 @@ export function register_translator(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-translator-send") + tag.command("post-api-translator-send") .description("Send translated request to provider") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -62,26 +49,16 @@ export function register_translator(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-translator-history") + tag.command("get-api-translator-history") .description("Get translation history") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/translator/history"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/api-commands/usage.mjs b/bin/cli/api-commands/usage.mjs index a8d3b7ce4c..40a8b1afb1 100644 --- a/bin/cli/api-commands/usage.mjs +++ b/bin/cli/api-commands/usage.mjs @@ -5,8 +5,7 @@ import { readFileSync } from "node:fs"; export function register_usage(parent) { const tag = parent.command("usage").description("Usage endpoints"); - tag - .command("get-api-usage-analytics") + tag.command("get-api-usage-analytics") .description("Get usage analytics") .option("--period ", "") .action(async (opts, cmd) => { @@ -15,16 +14,11 @@ export function register_usage(parent) { const qs = new URLSearchParams(); if (opts.period != null) qs.set("period", String(opts.period)); if (qs.toString()) url += "?" + qs.toString(); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-call-logs") + tag.command("get-api-usage-call-logs") .description("Get call logs") .option("--limit ", "") .option("--offset ", "") @@ -35,116 +29,76 @@ export function register_usage(parent) { if (opts.limit != null) qs.set("limit", String(opts.limit)); if (opts.offset != null) qs.set("offset", String(opts.offset)); if (qs.toString()) url += "?" + qs.toString(); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-call-logs-id-") + tag.command("get-api-usage-call-logs-id-") .description("Get a specific call log") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/call-logs/{id}"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-connection-id-") + tag.command("get-api-usage-connection-id-") .description("Get usage for a specific connection") .requiredOption("--connection-id ", "") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/{connectionId}"; url = url.replace("{connectionId}", encodeURIComponent(opts.connectionId ?? "")); - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-history") + tag.command("get-api-usage-history") .description("Get usage history") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/history"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-logs") + tag.command("get-api-usage-logs") .description("Get usage logs") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/logs"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-proxy-logs") + tag.command("get-api-usage-proxy-logs") .description("Get proxy logs") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/proxy-logs"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-request-logs") + tag.command("get-api-usage-request-logs") .description("Get request logs") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/request-logs"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("get-api-usage-budget") + tag.command("get-api-usage-budget") .description("Get usage budget status") .action(async (opts, cmd) => { const gOpts = cmd.optsWithGlobals(); let url = "/api/usage/budget"; - const res = await apiFetch(url, { - method: "GET", - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "GET", baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); - tag - .command("post-api-usage-budget") + tag.command("post-api-usage-budget") .description("Configure usage budget") .option("--body ", "JSON body or @path/to/file.json") .action(async (opts, cmd) => { @@ -156,12 +110,7 @@ export function register_usage(parent) { ? JSON.parse(readFileSync(opts.body.slice(1), "utf8")) : JSON.parse(opts.body); } - const res = await apiFetch(url, { - method: "POST", - body, - baseUrl: gOpts.baseUrl, - apiKey: gOpts.apiKey, - }); + const res = await apiFetch(url, { method: "POST", body, baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey }); const data = res.ok ? await res.json() : await res.text(); emit(data, gOpts); }); diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs new file mode 100644 index 0000000000..b23103f51e --- /dev/null +++ b/bin/cli/commands/setup-open-code.mjs @@ -0,0 +1,384 @@ +/** + * omniroute setup opencode — Wire the bundled @omniroute/opencode-plugin + * into a local OpenCode install. + * + * Closes the gap where `npm install -g omniroute` ships the plugin + * inside the omniroute package (`@omniroute/opencode-plugin/dist/`) but + * OpenCode discovers plugins via `~/.config/opencode/plugins/` or + * via entries in `opencode.json`. Without this command, the user has + * to extract the tarball and wire it up by hand (see the plugin README, + * "Install" section). + * + * What it does, in order: + * 1. Resolves the bundled plugin path (source + built dist). + * 2. Resolves the OpenCode config directory (XDG-aware). + * 3. Copies the built plugin into `/plugins/omniroute/`. + * 4. Creates or updates `opencode.json` with a single `plugin` entry + * pointing at the local copy (so OC ≥1.15 picks it up). + * 5. Optionally runs `opencode auth login --provider omniroute` + * so the next `opencode` invocation already has the API key. + * + * Idempotent: re-running with the same `--provider-id` updates the + * entry in place (path + baseURL) without duplicating it. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import os from "node:os"; + +import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { t } from "../i18n.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// We walk up from this file to find the omniroute package root. The script +// lives at `/bin/cli/commands/setup-open-code.mjs`, so the +// package root is three levels up. Using import.meta.url (not process.cwd()) +// means the command works the same way whether you run it from the source +// repo, a global install, or a symlinked location. +const PACKAGE_ROOT = resolve(__dirname, "..", "..", ".."); + +// The bundled plugin ships at PACKAGE_ROOT/@omniroute/opencode-plugin/ +// (see root package.json `files`: ["@omniroute/", ...]). The env override +// exists so tests can point at a fixture without building the real plugin. +const BUNDLED_PLUGIN_DIR = + process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR || + join(PACKAGE_ROOT, "@omniroute", "opencode-plugin"); + +/** + * Resolve the OpenCode config directory. Honours XDG_CONFIG_HOME and the + * platform-specific defaults documented at https://opencode.ai/. + * + * @returns {{ configDir: string, dataDir: string }} + */ +function resolveOpenCodeDirs() { + const home = os.homedir(); + const xdgConfig = process.env.XDG_CONFIG_HOME; + const xdgData = process.env.XDG_DATA_HOME; + const platform = process.platform; + + let configDir; + let dataDir; + if (platform === "darwin") { + // macOS: ~/Library/Application Support/opencode + configDir = join(home, "Library", "Application Support", "opencode"); + dataDir = configDir; // OC uses the same root for config + data on macOS + } else if (platform === "win32") { + const appdata = process.env.APPDATA || join(home, "AppData", "Roaming"); + const localAppdata = process.env.LOCALAPPDATA || join(home, "AppData", "Local"); + configDir = join(appdata, "opencode"); + dataDir = join(localAppdata, "opencode"); + } else { + // Linux + everything else: XDG-style + configDir = xdgConfig ? join(xdgConfig, "opencode") : join(home, ".config", "opencode"); + dataDir = xdgData ? join(xdgData, "opencode") : join(home, ".local", "share", "opencode"); + } + return { configDir, dataDir }; +} + +/** + * Locate the bundled @omniroute/opencode-plugin dist. The plugin may be + * present in two states: + * + * - Built (`dist/index.cjs` + `dist/index.js` exist) — preferred, + * ships from a published omniroute tarball after Step 8.8 of + * `scripts/build/prepublish.ts` runs. + * - Unbuilt (only `src/index.ts`) — local dev / fresh clone. We surface + * a clear error instead of running tsup here, because the CLI runtime + * may not have tsup available (it's a devDependency). + * + * @returns {{ distEntry: string, cjsEntry: string, packageDir: string }} + */ +function resolveBundledPlugin() { + if (!existsSync(BUNDLED_PLUGIN_DIR)) { + throw new Error( + `Bundled @omniroute/opencode-plugin not found at ${BUNDLED_PLUGIN_DIR}.\n` + + `This usually means omniroute was installed from a source tree that does not ` + + `include the workspace package. Try reinstalling omniroute (npm install -g omniroute) ` + + `or run \`cd @omniroute/opencode-plugin && npm install && npm run build\` from the source repo.` + ); + } + + const esmEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.js"); + const cjsEntry = join(BUNDLED_PLUGIN_DIR, "dist", "index.cjs"); + + if (!existsSync(esmEntry) || !existsSync(cjsEntry)) { + throw new Error( + `@omniroute/opencode-plugin dist/ not built (looked for ${esmEntry}).\n` + + `Run \`cd ${BUNDLED_PLUGIN_DIR} && npm install && npm run build\` and re-run this command.` + ); + } + + // Prefer ESM. OpenCode (≥1.15) loads ESM modules natively. + return { distEntry: esmEntry, cjsEntry, packageDir: BUNDLED_PLUGIN_DIR }; +} + +/** + * Copy the plugin package into `/plugins/omniroute/`. We + * copy the entire package (dist/ + package.json) so the dist file's + * require/import of `zod` and `@opencode-ai/plugin` resolves against the + * copy's own node_modules. Without the copy, OpenCode would need to + * resolve the peer deps from the omniroute package's tree, which is + * unreliable. + */ +function installPluginToOpenCode(pluginInfo, opencodeConfigDir) { + const targetDir = join(opencodeConfigDir, "plugins", "omniroute"); + mkdirSync(dirname(targetDir), { recursive: true }); + mkdirSync(targetDir, { recursive: true }); + + // Copy package.json + dist/. We intentionally do NOT recursively copy + // node_modules from the source — `peerDependenciesMeta` declares zod + + // @opencode-ai/plugin as peers, and the user's OpenCode install already + // provides them. Copying our own node_modules would risk duplicate zod + // instances (the @opencode-ai/plugin contract uses a singleton). + const packageJsonSrc = join(pluginInfo.packageDir, "package.json"); + const distSrc = join(pluginInfo.packageDir, "dist"); + cpSync(packageJsonSrc, join(targetDir, "package.json")); + cpSync(distSrc, join(targetDir, "dist"), { recursive: true }); + + return targetDir; +} + +/** + * Update `opencode.json` to register the plugin. Idempotent: if an entry + * for the same `providerId` already exists, replace it in place. If the + * user has any other plugin entries, preserve them. + * + * @returns {{ configPath: string, changed: boolean }} + */ +function registerPluginInOpenCodeConfig({ + opencodeConfigDir, + pluginTargetDir, + providerId, + baseURL, + displayName, +}) { + const configPath = join(opencodeConfigDir, "opencode.json"); + let cfg = {}; + if (existsSync(configPath)) { + try { + cfg = JSON.parse(readFileSync(configPath, "utf8")); + } catch (err) { + throw new Error( + `Failed to parse existing ${configPath}: ${err.message}\n` + + `Fix or remove the file manually, then re-run \`omniroute setup opencode\`.` + ); + } + } + + const plugins = Array.isArray(cfg.plugin) ? cfg.plugin : []; + + // Plugin entries can be either a string ("@some/pkg") or a tuple + // ("@some/pkg", { options }). The README documents the tuple form, so + // we use that. The "module path" is a file:// URL relative to the + // opencode config dir — that is what opencode ≥1.15 resolves. + const entry = [ + `./plugins/omniroute/dist/index.js`, + { + providerId, + baseURL, + ...(displayName ? { displayName } : {}), + }, + ]; + + // Idempotency: drop any prior entry for the same providerId. We also + // drop a legacy `opencode-omniroute-auth` entry if present — that + // package is the obsolete predecessor of @omniroute/opencode-plugin + // and was the root cause of issue #3711. + const filtered = plugins.filter((p) => { + if (typeof p === "string") { + return !p.includes("opencode-omniroute-auth"); + } + if (Array.isArray(p) && p[1] && typeof p[1] === "object") { + const pid = p[1].providerId; + if (pid === providerId) return false; + // Also drop the legacy auth plugin if it's there. + if (typeof p[0] === "string" && p[0].includes("opencode-omniroute-auth")) { + return false; + } + } + return true; + }); + filtered.push(entry); + cfg.plugin = filtered; + + // Make sure the config dir exists, then write the updated config. + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n", "utf8"); + + return { configPath, changed: true }; +} + +/** + * Optionally invoke `opencode auth login --provider `. We + * shell out (instead of importing) so this command works even if + * OpenCode's CLI surface shifts between minor versions — the user gets + * a clear "could not run opencode" message instead of a hard import + * failure. + */ +function runOpenCodeAuth(providerId) { + const isWin = process.platform === "win32"; + const opencodeBin = isWin ? "opencode.cmd" : "opencode"; + const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], { + stdio: "inherit", + shell: false, + }); + if (res.error) { + // ENOENT = opencode is not on PATH + if (res.error.code === "ENOENT") { + printInfo( + `opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.` + ); + return 1; + } + printError(`opencode auth login failed: ${res.error.message}`); + return 1; + } + return typeof res.status === "number" ? res.status : 1; +} + +/** + * Top-level action handler. Kept exported so the integration test can + * drive it without spawning a subprocess. + * + * @param {object} opts + * @param {string} [opts.providerId="omniroute"] + * @param {string} [opts.baseURL="http://localhost:20128"] (Commander camelCases + * `--base-url` into `baseUrl`, so both spellings are accepted.) + * @param {string} [opts.configDir] Override the OpenCode config dir (tests / non-standard installs). + * @param {string} [opts.displayName] + * @param {boolean} [opts.auth=false] Run `opencode auth login` after wiring. + * @param {boolean} [opts.nonInteractive=false] Skip prompts. + * @returns {Promise<{ exitCode: number, configPath?: string, pluginTargetDir?: string }>} + */ +export async function runSetupOpenCodeCommand(opts = {}) { + const providerId = opts.providerId || "omniroute"; + const baseURL = opts.baseURL || opts.baseUrl || "http://localhost:20128"; + const displayName = opts.displayName || null; + const wantsAuth = Boolean(opts.auth); + const nonInteractive = Boolean(opts.nonInteractive); + + printHeading("OmniRoute → OpenCode Plugin Setup"); + + const resolvedDirs = resolveOpenCodeDirs(); + const opencodeConfigDir = opts.configDir || resolvedDirs.configDir; + const opencodeDataDir = resolvedDirs.dataDir; + printInfo(`OpenCode config dir: ${opencodeConfigDir}`); + printInfo(`OpenCode data dir: ${opencodeDataDir}`); + + // 1. Resolve bundled plugin + let pluginInfo; + try { + pluginInfo = resolveBundledPlugin(); + } catch (err) { + printError(err.message); + return { exitCode: 1 }; + } + printInfo(`Bundled plugin: ${pluginInfo.distEntry}`); + + // 2. Ensure OpenCode config dir exists (opencode will create it on + // first run, but creating it now means we can write opencode.json + // even if OC has never been launched). + if (!existsSync(opencodeConfigDir)) { + mkdirSync(opencodeConfigDir, { recursive: true }); + printInfo(`Created OpenCode config dir (didn't exist yet).`); + } + + // 3. Copy plugin into OpenCode's plugin dir + let pluginTargetDir; + try { + pluginTargetDir = installPluginToOpenCode(pluginInfo, opencodeConfigDir); + printSuccess(`Plugin installed at ${pluginTargetDir}`); + } catch (err) { + printError(`Failed to install plugin: ${err.message}`); + return { exitCode: 1 }; + } + + // 4. Register in opencode.json + let configPath; + try { + const reg = registerPluginInOpenCodeConfig({ + opencodeConfigDir, + pluginTargetDir, + providerId, + baseURL, + displayName, + }); + configPath = reg.configPath; + printSuccess(`opencode.json updated at ${configPath}`); + } catch (err) { + printError(`Failed to update opencode.json: ${err.message}`); + return { exitCode: 1, pluginTargetDir }; + } + + // 5. Optionally run auth login + if (wantsAuth) { + if (nonInteractive) { + printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`); + printInfo(`Run manually: opencode auth login --provider ${providerId}`); + } else { + printHeading("Authenticating with OpenCode"); + const authExit = runOpenCodeAuth(providerId); + if (authExit !== 0) { + return { exitCode: authExit, configPath, pluginTargetDir }; + } + } + } else { + printInfo( + `Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)` + ); + } + + printSuccess("OpenCode plugin setup complete"); + printInfo(`Restart OpenCode to pick up the new plugin entry.`); + return { exitCode: 0, configPath, pluginTargetDir }; +} + +/** + * Register the `omniroute setup opencode` subcommand on the parent + * `setup` command. Commander builds the doc/help from the chain, so + * `omniroute setup --help` automatically shows the new subcommand. + * + * @param {import("commander").Command} setupCommand the registered `setup` command + */ +export function registerSetupOpenCode(setupCommand) { + setupCommand + .command("opencode") + .description( + t("setup.opencode") || + "Install and register the bundled @omniroute/opencode-plugin with a local OpenCode install" + ) + .option( + "--provider-id ", + "OpenCode provider id to register (default: omniroute)", + "omniroute" + ) + .option( + "--base-url ", + "OmniRoute base URL the plugin should talk to (default: http://localhost:20128)", + "http://localhost:20128" + ) + .option("--display-name ", "Display name in the OpenCode UI (optional)") + .option( + "--auth", + "Run `opencode auth login --provider ` after wiring (interactive)", + false + ) + .option("--non-interactive", "Do not prompt; skip the auth login step", false) + .action(async (opts, cmd) => { + // The parent `setup` command uses cmd.optsWithGlobals(); we mirror + // that here so global flags (--json, --base-url, --api-key) still + // flow through to the runner. + const globalOpts = cmd.parent?.parent?.optsWithGlobals?.() ?? {}; + const merged = { + ...opts, + output: globalOpts.output, + apiKey: opts.apiKey ?? globalOpts.apiKey, + baseUrl: opts.baseUrl ?? globalOpts.baseUrl, + }; + const { exitCode } = await runSetupOpenCodeCommand(merged); + if (exitCode !== 0) process.exit(exitCode); + }); +} diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index 690c4d9e08..cda32573b2 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -10,6 +10,7 @@ import { getProviderDisplayName, resolveProviderChoice, } from "../provider-catalog.mjs"; +import { registerSetupOpenCode } from "./setup-open-code.mjs"; import { t } from "../i18n.mjs"; const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -150,6 +151,11 @@ export function registerSetup(program) { const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + // Wire up `omniroute setup opencode` subcommand. Kept inside registerSetup + // so it always travels with the parent command (avoids a separate register + // call in the registry that would silently break if the parent renames). + registerSetupOpenCode(program.commands.find((c) => c.name() === "setup")); } export async function runSetupCommand(opts = {}) { diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 857e18b65a..bcbc016701 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -26,7 +26,8 @@ "testFailed": "Provider test failed: {error}", "loginEnabled": "Login: enabled (password updated)", "loginDisabled": "Login: disabled", - "providerInfo": "Provider: {info}" + "providerInfo": "Provider: {info}", + "opencode": "Install and configure the bundled @omniroute/opencode-plugin for OpenCode" }, "doctor": { "title": "OmniRoute Doctor", diff --git a/bin/cli/locales/zh-CN.json b/bin/cli/locales/zh-CN.json index c27f430239..557ca1f5ee 100644 --- a/bin/cli/locales/zh-CN.json +++ b/bin/cli/locales/zh-CN.json @@ -25,5 +25,1235 @@ "base_url": "OmniRoute 服务器的基础 URL", "context": "此命令使用的服务器上下文/配置文件", "lang": "设置 CLI 显示语言(覆盖 OMNIROUTE_LANG)" + }, + "setup": { + "title": "OmniRoute 设置", + "passwordPrompt": "管理员密码", + "providerPrompt": "默认提供商(留空跳过)", + "done": "设置完成", + "passwordSet": "管理员密码已配置", + "providerSet": "提供商已配置:{name}", + "testingProvider": "正在测试提供商连接:{name}", + "testPassed": "提供商测试通过", + "testFailed": "提供商测试失败:{error}", + "loginEnabled": "登录:已启用(密码已更新)", + "loginDisabled": "登录:已禁用", + "providerInfo": "提供商:{info}" + }, + "doctor": { + "title": "OmniRoute 诊断", + "dbOk": "数据库:正常({path})", + "dbMissing": "数据库:未初始化 — 运行 omniroute setup", + "portOk": "端口 {port}:可用", + "portConflict": "端口 {port}:已被其他进程占用", + "encryptionOk": "加密密钥:已配置", + "encryptionMissing": "加密密钥缺失 — 运行 omniroute setup", + "allGood": "所有检查通过。", + "warnings": "{count} 个警告 — 见上文。" + }, + "providers": { + "title": "提供商", + "noProviders": "未配置提供商。运行:omniroute setup", + "testing": "正在测试 {name}...", + "available": "{count} 个提供商可用", + "connected": "已连接", + "disconnected": "未连接", + "validationFailed": "验证失败:{error}", + "metrics": { + "description": "显示提供商性能指标(延迟、成功率、成本)", + "provider": "按提供商 ID 筛选", + "connection_id": "按连接 ID 筛选", + "period": "时间范围:1h|6h|24h|7d|30d(默认:24h)", + "metric": "关注特定指标字段", + "sort": "按字段排序(降序)", + "limit": "最大行数(默认:50)", + "watch": "每 5 秒刷新(实时模式)", + "compare": "逗号分隔的提供商 ID,并排比较" + }, + "metric_single": { + "description": "获取特定连接的单个指标值" + }, + "rotate": { + "description": "轮换提供商连接的上游 API 密钥", + "newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env)", + "fromEnvOpt": "从环境变量 VAR 读取新密钥", + "oauthOpt": "改为触发 OAuth 重新认证流程", + "skipTestOpt": "跳过轮换后的连通性测试", + "dryRunOpt": "预览将要更改的内容而不写入", + "confirmPrompt": "替换连接 \"{name}\" ({id}) 的 API 密钥?[y/N] ", + "dryRunResult": "【模拟】将轮换 \"{name}\" ({id}) 的密钥。未做任何更改。", + "oauthHint": "OAuth 连接 — 运行:omniroute oauth {provider}", + "envVarEmpty": "环境变量 {var} 未设置或为空。", + "success": "已为 \"{name}\" 轮换密钥。运行 providers test {id} 验证。", + "testPassed": "轮换后测试通过。", + "testFailed": "轮换后测试失败:{error}" + }, + "status": { + "description": "显示所有提供商连接的密钥健康状态(期限、过期、冷却)", + "providerOpt": "按提供商名称筛选", + "header": "ID 提供商 名称 过期状态 测试状态 冷却至", + "noData": "没有可用的提供商连接数据。", + "requiresServer": "providers status 需要 OmniRoute 服务器正在运行。" + } + }, + "keys": { + "title": "API 密钥", + "addDescription": "为提供商添加或更新 API 密钥", + "listDescription": "列出所有已配置的 API 密钥", + "removeDescription": "移除提供商的 API 密钥", + "regenerateDescription": "重新生成 OmniRoute API 密钥", + "revokeDescription": "撤销 OmniRoute API 密钥", + "revealDescription": "显示未掩码的 API 密钥值", + "usageDescription": "显示 API 密钥的最近使用情况", + "usageLimitOpt": "最近请求数", + "rotateDescription": "生成新密钥并使旧密钥失效", + "graceOpt": "旧密钥失效前的宽限期(毫秒)", + "stdinOpt": "从标准输入读取 API 密钥而不是参数", + "added": "已为 {provider} 添加密钥。", + "removed": "密钥已移除。", + "listed": "{count} 个密钥。", + "noKeys": "未配置密钥。", + "noUsage": "未找到使用数据。", + "confirmRemove": "移除密钥 {id}?", + "confirmRegenerate": "重新生成密钥 {id}?", + "confirmRevoke": "撤销密钥 {id}?", + "confirmRotate": "轮换密钥 {id}?(旧密钥将在宽限期后失效)", + "regenerated": "已重新生成。新密钥:{key}", + "revoked": "密钥 {id} 已撤销。", + "rotated": "密钥 {id} 已轮换。新密钥 ID:{newId}", + "revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。", + "providerRequired": "需要提供商。", + "keyRequired": "需要 API 密钥。", + "stdinEmpty": "未通过标准输入提供 API 密钥。", + "unknownProvider": "未知提供商:{provider}", + "policy": { + "title": "密钥策略", + "showDescription": "显示密钥的速率/成本策略", + "setDescription": "设置密钥的速率/成本策略", + "rateLimitOpt": "每分钟最大请求数", + "maxCostOpt": "每日最大成本(美元)", + "allowedModelsOpt": "逗号分隔的允许模型列表", + "nothingToSet": "未提供策略字段。使用 --rate-limit、--max-cost 或 --allowed-models。", + "updated": "策略已更新。" + }, + "expiration": { + "title": "密钥过期", + "listDescription": "列出即将过期的密钥", + "daysOpt": "显示 N 天内过期的密钥", + "none": "{days} 天内没有密钥过期。", + "listTitle": "{days} 天内过期的密钥:" + } + }, + "stream": { + "description": "使用 SSE 检查模式流式传输聊天响应", + "file": "从文件读取提示", + "stdin": "从标准输入读取提示", + "model": "模型 ID(默认:auto)", + "system": "系统提示", + "combo": "强制指定组合名称", + "max_tokens": "响应最大令牌数", + "responses_api": "使用 /v1/responses 而不是 /v1/chat/completions", + "raw": "打印接收到的原始 SSE 行", + "debug": "在 stderr 中打印每块的时间信息", + "save": "将所有 SSE 事件保存到 .jsonl 文件", + "error": { + "empty_prompt": "错误:需要提供提示(位置参数、--file 或 --stdin)" + } + }, + "usage": { + "description": "使用分析、预算、配额和日志", + "analytics": { + "description": "显示汇总使用分析", + "period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)", + "provider": "按提供商 ID 筛选" + }, + "budget": { + "description": "管理成本预算", + "set": { + "scope": "预算范围(默认:全局)", + "period": "预算周期:daily|weekly|monthly(默认:monthly)" + } + }, + "quota": { + "description": "显示提供商配额使用情况", + "provider": "按提供商 ID 筛选", + "check": "显示新请求是否有可用配额" + }, + "logs": { + "description": "显示请求调用日志", + "limit": "返回的日志条目数(默认:100)", + "search": "筛选日志的搜索查询", + "since": "返回自此时间戳以来的日志", + "follow": "持续跟踪新的日志条目", + "api_key": "按 API 密钥筛选日志" + }, + "utilization": { + "description": "显示 API 密钥利用率指标", + "api_key": "按 API 密钥筛选" + }, + "history": { + "description": "显示请求历史", + "limit": "历史条目数(默认:100)" + }, + "proxy_logs": { + "description": "显示代理级请求日志", + "limit": "代理日志条目数(默认:100)" + } + }, + "cost": { + "description": "按提供商、模型、组合或 API 密钥显示成本报告", + "period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)", + "since": "开始日期(ISO 格式,例如 2026-01-01)— 覆盖 --period", + "until": "结束日期(ISO 格式)", + "group_by": "按以下方式分组:provider|model|api-key|combo|day(默认:provider)", + "api_key_filter": "按特定 API 密钥筛选", + "limit": "显示的最大行数(默认:100)" + }, + "simulate": { + "description": "模拟路由(空运行)— 显示将选择哪些提供商而不调用上游", + "file": "从 JSON 文件加载完整请求体", + "model": "模型 ID(默认:auto)", + "combo": "强制指定组合名称", + "reasoning": "推理努力级别(low|medium|high)", + "thinking": "扩展思维令牌预算", + "explain": "在 stderr 中打印回退树和成本范围", + "noCombo": "未找到匹配的组合。使用以下命令配置:omniroute combo create" + }, + "chat": { + "description": "向 OmniRoute 发送一次性聊天提示", + "file": "从文件读取提示", + "stdin": "从标准输入读取提示", + "system": "系统提示", + "model": "模型 ID(默认:auto)", + "max_tokens": "响应最大令牌数", + "temperature": "采样温度(0–2)", + "top_p": "Top-p 核采样", + "reasoning_effort": "推理努力级别(low|medium|high)", + "thinking_budget": "扩展思维令牌预算", + "combo": "强制指定组合名称", + "responses_api": "使用 /v1/responses 而不是 /v1/chat/completions", + "stream": "增量流式传输响应", + "no_history": "不保存到 ~/.omniroute/cli-history.jsonl", + "error": { + "empty_prompt": "错误:需要提供提示(位置参数、--file 或 --stdin)" + } + }, + "serve": { + "description": "启动 OmniRoute 服务器(默认操作)", + "starting": "正在启动 OmniRoute 服务器(端口 {port})...", + "ready": "就绪地址:http://localhost:{port}", + "stopping": "正在停止服务器(PID {pid})...", + "stopped": "服务器已停止。", + "notRunning": "服务器未在运行。", + "port": "监听端口(默认:20128)", + "no_open": "不自动打开浏览器", + "daemon": "以后台守护进程方式运行服务器", + "log": "内联显示服务器日志", + "no_recovery": "禁用崩溃自动重启(调试模式)", + "max_restarts": "30 秒内的最大崩溃重启次数(默认:2)", + "tray": "显示系统托盘图标(仅桌面,选择加入)", + "no_tray": "禁用系统托盘图标" + }, + "backup": { + "title": "备份", + "description": "创建 OmniRoute 数据备份", + "creating": "正在创建备份...", + "done": "备份已保存至 {path}", + "noFiles": "没有可备份的文件(数据库未初始化)", + "failed": "备份失败:{error}", + "restoring": "正在从 {path} 恢复...", + "restored": "恢复完成。", + "restoreDescription": "从备份恢复", + "listTitle": "可用备份", + "noBackups": "未找到备份。", + "notFound": "未找到备份:{name}", + "confirmRestore": "用 {ts} 的备份覆盖当前数据?", + "createDescription": "创建 OmniRoute 数据备份", + "nameOpt": "自定义备份名称", + "cloudOpt": "将备份上传到云存储", + "encryptOpt": "使用 AES-256-GCM 加密备份", + "keyFileOpt": "包含加密密码的文件路径", + "excludeOpt": "排除匹配模式的文件(可重复)", + "retentionOpt": "仅保留最近 N 个备份", + "passphrasePrompt": "加密密码:", + "noPassphrase": "加密备份需要密码。", + "cloudFailed": "警告:云上传失败。本地备份已保存。", + "cloudUploaded": "云备份已上传:{url}", + "auto": { + "title": "备份计划", + "enableDescription": "启用定时自动备份", + "disableDescription": "禁用定时自动备份", + "statusDescription": "显示当前备份计划状态", + "cronOpt": "计划 cron 表达式(默认:每天凌晨 3 点)", + "enabled": "已启用自动备份(cron:{cron})。", + "hint": " 计划由 omniroute serve 启动时读取。", + "disabled": "已禁用自动备份。", + "notConfigured": "未配置备份计划。" + } + }, + "health": { + "description": "检查服务器健康状态和组件状态", + "noServer": "服务器未运行。启动:omniroute serve", + "title": "健康状态", + "status": "状态:{status}", + "uptime": "运行时间:{uptime}", + "requests": "请求数(24h):{count}", + "cost": "成本(24h):" + }, + "quota": { + "description": "显示提供商配额使用情况", + "noServer": "服务器未运行。启动:omniroute serve", + "noData": "没有可用的配额信息。" + }, + "cache": { + "description": "管理响应缓存", + "noServer": "服务器未运行。启动:omniroute serve", + "cleared": "缓存已清除。", + "clearFailed": "清除缓存失败。" + }, + "test": { + "description": "测试提供商连接", + "noServer": "服务器未运行。启动:omniroute serve", + "testing": "正在测试 {provider} / {model}...", + "passed": "连接成功!", + "failed": "连接失败:{error}", + "allProvidersOpt": "测试所有已配置的提供商", + "latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)", + "repeatOpt": "重复测试 N 次并汇总结果", + "compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet)", + "saveOpt": "将结果保存到 JSON 文件", + "saved": "结果已保存至 {path}", + "compareTitle": "模型比较", + "compareMinTwo": "--compare 需要至少两个模型(逗号分隔)", + "noProviders": "未配置提供商。添加:omniroute keys add" + }, + "update": { + "checking": "正在检查更新...", + "upToDate": "已是最新版本({version})。", + "available": "有可用更新:{current} → {latest}", + "installing": "正在安装 {latest}...", + "done": "已更新至 {latest}。重启服务器以生效。" + }, + "mcp": { + "title": "MCP 服务器", + "running": "MCP 服务器正在运行({transport})", + "stopped": "MCP 服务器已停止。", + "restarted": "MCP 服务器已重启。", + "call": { + "description": "直接调用 MCP 工具", + "args": "JSON 参数对象(内联)", + "args_file": "JSON 参数文件路径", + "stream": "使用流式端点(/api/mcp/stream)", + "scope": "所需作用域(可重复,例如 read:health)" + }, + "scopes": { + "description": "列出可用的 MCP 作用域", + "tool": "显示特定工具所需的作用域" + }, + "tools": { + "description": "检查 MCP 工具", + "list": { + "description": "列出所有 MCP 工具", + "scope": "按作用域筛选" + }, + "info": { + "description": "显示 MCP 工具的元数据" + }, + "schema": { + "description": "显示 MCP 工具的输入/输出 JSON 模式", + "io": "模式类型:input|output(默认:input)" + } + }, + "audit": { + "description": "MCP 审计日志(audit --source mcp 的别名)" + } + }, + "a2a": { + "skills": { + "description": "从代理卡片列出可用的 A2A 技能" + }, + "invoke": { + "description": "调用 A2A 技能并返回任务 ID", + "input": "JSON 输入对象", + "input_file": "JSON 输入文件路径", + "wait": "等待任务完成后返回", + "timeout": "等待完成的超时时间(毫秒,默认:60000)" + }, + "tasks": { + "description": "管理 A2A 任务", + "list": { + "status": "按状态筛选", + "skill": "按技能 ID 筛选" + }, + "watch": { + "description": "轮询任务状态直至完成" + }, + "stream": { + "description": "通过 SSE 流式传输任务执行事件" + }, + "logs": { + "description": "显示任务消息和产物" + } + } + }, + "policy": { + "description": "管理 OmniRoute 授权策略", + "list": { + "description": "列出策略", + "kind": "按类型筛选(allow|deny|rate-limit|cost-cap)", + "scope": "按作用域筛选(global|api-key|provider)" + }, + "get": { + "description": "按 ID 获取策略详情" + }, + "create": { + "description": "从 JSON 文件创建策略", + "file": "策略 JSON 文件路径" + }, + "update": { + "description": "从 JSON 文件更新策略", + "file": "策略 JSON 文件路径" + }, + "delete": { + "description": "按 ID 删除策略", + "yes": "跳过确认提示" + }, + "evaluate": { + "description": "空运行策略评估(退出码 0=允许,4=拒绝)", + "api_key": "要评估的 API 密钥", + "action": "要检查的操作(例如 chat, embed, admin)", + "resource": "资源路径或标识符", + "context": "作为 JSON 对象的额外上下文" + }, + "export": { + "description": "将所有策略导出到 JSON 文件" + }, + "import": { + "description": "从 JSON 文件导入策略", + "overwrite": "覆盖具有相同 ID 的现有策略" + } + }, + "compression": { + "description": "配置和检查 OmniRoute 压缩管道", + "status": { + "description": "显示当前压缩状态和设置" + }, + "configure": { + "description": "配置压缩设置", + "engine": "压缩引擎(caveman|rtk|hybrid|none)", + "caveman_agg": "Caveman 激进程度 0.0–1.0", + "rtk_budget": "RTK 令牌预算", + "language_pack": "要激活的语言包" + }, + "engine": { + "description": "获取或设置活动压缩引擎" + }, + "combos": { + "description": "管理压缩组合统计" + }, + "rules": { + "description": "管理压缩规则", + "add": { + "pattern": "要匹配的模式(正则或 field:pattern)", + "action": "操作:drop|shrink|replace" + } + }, + "language_packs": { + "description": "列出可用的压缩语言包" + }, + "preview": { + "description": "预览压缩对请求的效果", + "file": "请求 JSON 文件路径" + } + }, + "tunnel": { + "title": "隧道", + "listDescription": "列出活动隧道", + "createDescription": "创建隧道", + "created": "隧道已创建:{url}", + "stopped": "隧道已停止。", + "confirmStop": "停止隧道 {id}?", + "stopDescription": "停止隧道", + "statusDescription": "显示隧道的详细状态", + "logsDescription": "显示隧道日志", + "infoDescription": "显示隧道的配置详情", + "rotateDescription": "生成新的隧道 URL", + "tailOpt": "显示的日志行数", + "typeRequired": "需要隧道类型。", + "noLogs": "没有可用日志。", + "notAvailable": "隧道信息不可用。", + "noTunnels": "没有活动隧道。", + "infoTitle": "隧道信息:{type}", + "rotated": "隧道 URL 已轮换:{url}", + "confirmRotate": "轮换隧道 {type}?(将生成新 URL)" + }, + "stop": { + "description": "停止 OmniRoute 服务器", + "stopping": "正在停止服务器(PID {pid})...", + "stopped": "服务器已停止。", + "notRunning": "没有服务器在运行。", + "portFallback": "未找到 PID 文件,尝试通过端口停止..." + }, + "restart": { + "description": "重启 OmniRoute 服务器", + "restarting": "正在重启 OmniRoute 服务器..." + }, + "dashboard": { + "description": "在浏览器中打开 OmniRoute 仪表盘", + "opening": "正在打开仪表盘:{url}", + "urlOnly": "仅打印仪表盘 URL,不打开浏览器", + "tui": "打开交互式 TUI 仪表盘(终端 UI,7 个标签页)" + }, + "models": { + "description": "列出可用模型(需要服务器)", + "search": "按 ID、名称、提供商或描述筛选模型", + "noServer": "服务器未运行。启动:omniroute serve", + "noModels": "未找到模型。" + }, + "audit": { + "description": "访问合规和 MCP 审计日志", + "source": "日志来源:all|compliance|mcp(默认:all)", + "since": "返回自此时间戳以来的条目(ISO 8601)", + "until": "返回直至此时间戳的条目(ISO 8601)", + "tail": { + "description": "显示最近的审计日志条目", + "follow": "持续跟踪新条目(2 秒轮询)", + "limit": "显示的最大条目数(默认:100)" + }, + "search": { + "description": "按关键词搜索审计日志条目", + "limit": "最大结果数(默认:200)", + "actor": "按参与者 ID 筛选", + "action": "按操作名称筛选" + }, + "export": { + "description": "将审计日志导出到文件", + "format": "输出格式:jsonl|csv(默认:jsonl)" + }, + "stats": { + "description": "显示审计日志统计", + "period": "时间范围:1d|7d|30d(默认:7d)" + }, + "get": { + "description": "按 ID 获取单个审计日志条目" + } + }, + "skills": { + "description": "管理 OmniRoute 技能(沙箱、内置、自定义、混合、skillssh)", + "list": { + "description": "列出已安装的技能", + "type": "按类型筛选(sandbox|custom|builtin|hybrid|skillssh)", + "enabled": "仅显示已启用的技能", + "disabled": "仅显示已禁用的技能", + "api_key": "按 API 密钥筛选" + }, + "get": { + "description": "按 ID 获取技能详情" + }, + "install": { + "description": "从文件或 URL 安装技能", + "from_file": "技能 JSON 定义文件路径", + "from_url": "远程技能定义的 URL", + "type": "技能类型(sandbox|custom|hybrid)", + "enable": "安装后启用技能" + }, + "enable": { + "description": "按 ID 启用技能" + }, + "disable": { + "description": "按 ID 禁用技能", + "yes": "跳过确认提示" + }, + "delete": { + "description": "按 ID 删除技能", + "yes": "跳过确认提示" + }, + "execute": { + "description": "按 ID 执行技能", + "input": "JSON 输入对象", + "input_file": "JSON 输入文件路径", + "timeout": "执行超时时间(毫秒,默认:30000)" + }, + "executions": { + "description": "列出技能执行历史", + "skill": "按技能 ID 筛选", + "limit": "最大结果数(默认:50)", + "status": "按状态筛选(running|completed|failed)" + }, + "skillssh": { + "description": "管理通过 SSH 安装的技能" + }, + "marketplace": { + "description": "浏览和安装市场中的技能" + }, + "mp": { + "search": { + "description": "搜索市场包", + "category": "按类别筛选", + "tag": "按标签筛选", + "limit": "最大结果数(默认:30)", + "sort": "排序方式:downloads|rating|recent" + }, + "info": { + "description": "显示包详情和说明文档" + }, + "install": { + "description": "安装市场包", + "version": "包版本(默认:latest)", + "enable": "安装后启用", + "yes": "跳过确认提示" + }, + "categories": { + "description": "列出市场类别" + }, + "featured": { + "description": "显示推荐市场包" + } + } + }, + "memory": { + "description": "管理 OmniRoute 对话记忆(FTS5 + 向量)", + "search": { + "description": "按语义查询搜索记忆条目", + "type": "按记忆类型筛选(user|feedback|project|reference)", + "limit": "最大结果数(默认:20)", + "api_key": "按 API 密钥筛选", + "token_budget": "限制结果中的总令牌数" + }, + "add": { + "description": "添加新的记忆条目", + "content": "记忆文本内容", + "file": "从文件读取内容", + "type": "记忆类型(默认:user)", + "metadata": "JSON 元数据对象", + "api_key": "关联到 API 密钥" + }, + "clear": { + "description": "删除匹配筛选条件的记忆条目", + "type": "按类型筛选", + "older": "删除早于指定时长的条目(30d, 6m, 1y)", + "api_key": "按 API 密钥筛选", + "yes": "跳过确认提示" + }, + "list": { + "description": "列出记忆条目(不按搜索排名)", + "type": "按类型筛选", + "limit": "最大结果数(默认:100)", + "api_key": "按 API 密钥筛选" + }, + "get": { + "description": "按 ID 获取单个记忆条目" + }, + "delete": { + "description": "按 ID 删除记忆条目", + "yes": "跳过确认提示" + }, + "health": { + "description": "显示记忆子系统健康状态(FTS5 + Qdrant)" + } + }, + "oauth": { + "description": "管理 OAuth 提供商连接", + "providers": { + "description": "列出支持 OAuth 的提供商及其流程类型" + }, + "start": { + "description": "为提供商启动 OAuth 授权流程", + "provider": "提供商 ID(gemini, copilot, cursor, ...)", + "no_browser": "仅打印 URL — 不打开浏览器", + "import_system": "从本地系统配置自动导入凭据", + "social": "社交登录提供商(google|github)— kiro 需要", + "timeout": "等待授权超时时间(毫秒,默认:300000)" + }, + "status": { + "description": "列出活动的 OAuth 连接", + "provider": "按提供商 ID 筛选" + }, + "revoke": { + "description": "撤销 OAuth 连接", + "provider": "要撤销的提供商 ID", + "connection_id": "按 ID 撤销特定连接", + "yes": "跳过确认提示" + } + }, + "cloud": { + "description": "管理云 AI 代理任务(codex, devin, jules)", + "agents": { + "description": "列出可用的云代理" + }, + "agent": { + "description": "管理 {agent} 云代理任务", + "auth": { + "description": "通过 OAuth 授权 {agent}" + } + }, + "task": { + "description": "管理云代理任务", + "create": { + "description": "创建新的代理任务", + "title": "任务标题(默认为提示的前 80 个字符)", + "prompt": "任务提示文本", + "prompt_file": "从文件读取提示", + "repo": "要克隆的任务仓库 URL", + "branch": "要使用的分支名称", + "metadata": "JSON 元数据对象" + }, + "list": { + "description": "列出代理任务", + "status": "按状态筛选(running|completed|failed|cancelled)", + "limit": "最大结果数(默认:50)" + }, + "get": { + "description": "按 ID 获取任务详情" + }, + "status": { + "description": "打印任务状态(running|completed|failed|cancelled)" + }, + "cancel": { + "description": "取消运行中的任务", + "yes": "跳过确认提示" + }, + "approve": { + "description": "批准代理计划开始执行" + }, + "message": { + "description": "向运行中的任务发送消息" + } + }, + "sources": { + "description": "列出任务产生的源文件" + } + }, + "eval": { + "description": "管理评估套件和运行", + "suites": { + "description": "管理评估套件", + "list": { + "description": "列出评估套件" + }, + "get": { + "description": "按 ID 获取评估套件详情" + }, + "create": { + "description": "从 JSON 文件创建评估套件", + "file": "套件定义 JSON 文件路径" + } + }, + "run": { + "description": "为套件启动评估运行", + "model": "要评估的模型 ID(默认:auto)", + "combo": "强制指定组合名称", + "concurrency": "并发样本数(默认:4)", + "tag": "标记此次运行以便后续筛选", + "watch": "观察运行进度直至完成" + }, + "list": { + "description": "列出评估运行", + "suite": "按套件 ID 筛选", + "status": "按状态筛选", + "since": "返回自此时间戳以来的运行", + "limit": "最大结果数(默认:50)" + }, + "get": { + "description": "按 ID 获取评估运行详情" + }, + "results": { + "description": "显示评估运行的样本结果", + "failed": "仅显示失败的样本" + }, + "cancel": { + "description": "取消正在运行的评估", + "yes": "跳过确认提示" + }, + "scorecard": { + "description": "显示已完成评估运行的记分卡" + } + }, + "webhooks": { + "description": "管理 OmniRoute Webhook", + "events": { + "description": "列出所有可用的 Webhook 事件类型" + }, + "list": { + "description": "列出已配置的 Webhook" + }, + "get": { + "description": "按 ID 获取 Webhook 详情" + }, + "add": { + "description": "注册新的 Webhook", + "url": "目标 URL", + "events": "逗号分隔的事件类型列表", + "secret": "HMAC 签名密钥", + "header": "额外的标头(key=value 格式,可重复)", + "no_enabled": "以禁用状态创建 Webhook" + }, + "update": { + "description": "更新现有 Webhook", + "enabled": "设置启用状态(true|false)" + }, + "remove": { + "description": "删除 Webhook", + "yes": "跳过确认提示" + }, + "test": { + "description": "向 Webhook 发送测试事件", + "event": "要模拟的事件类型(默认:request.completed)" + } + }, + "files": { + "description": "管理文件(上传、列出、获取、下载、删除)", + "list": { + "purpose": "按用途筛选", + "limit": "最大结果数" + }, + "get": { + "description": "获取文件元数据" + }, + "upload": { + "description": "上传文件", + "purpose": "文件用途(batch, assistants, fine-tune)" + }, + "content": { + "description": "下载文件内容", + "out": "保存到路径(默认:stdout)" + }, + "delete": { + "yes": "跳过确认" + } + }, + "batches": { + "description": "管理兼容 OpenAI 的批量作业", + "list": { + "status": "按状态筛选", + "limit": "最大结果数" + }, + "create": { + "description": "创建批量作业", + "inputFile": "输入文件 ID", + "endpoint": "目标端点", + "window": "完成窗口", + "metadata": "添加元数据 key=value" + }, + "submit": { + "description": "上传 JSONL 文件并创建批量作业", + "jsonl": "JSONL 文件路径", + "endpoint": "目标端点", + "wait": "等待完成" + }, + "cancel": { + "yes": "跳过确认" + }, + "wait": { + "timeout": "超时时间(毫秒)" + }, + "output": { + "out": "将输出保存到路径" + }, + "errors": { + "out": "将错误保存到路径" + } + }, + "translator": { + "description": "在 LLM 格式之间转换请求体", + "detect": { + "description": "检测请求体格式" + }, + "translate": { + "description": "将请求体从一种格式转换为另一种格式" + }, + "send": { + "description": "转换并发送到上游" + }, + "stream": { + "description": "流式转换请求体" + }, + "from": "源格式(openai|anthropic|gemini|cohere)", + "to": "目标格式(openai|anthropic|gemini|cohere)", + "file": "请求体 JSON 文件路径", + "out": "将输出保存到文件", + "model": "覆盖模型", + "history": { + "limit": "最大结果数" + } + }, + "pricing": { + "description": "管理模型定价数据", + "sync": { + "description": "从上游同步价格", + "provider": "按提供商筛选", + "force": "强制重新同步" + }, + "list": { + "provider": "按提供商筛选", + "model": "按模型筛选", + "limit": "最大结果数" + }, + "defaults": { + "description": "管理默认定价", + "input": "每 1M 令牌的输入成本(美元)", + "output": "每 1M 令牌的输出成本(美元)", + "cacheRead": "每 1M 令牌的缓存读取成本(美元)", + "cacheWrite": "每 1M 令牌的缓存写入成本(美元)" + }, + "diff": { + "description": "显示与上游价格的差异", + "model": "按模型筛选" + } + }, + "resilience": { + "description": "检查和管理弹性机制", + "status": { + "provider": "按提供商筛选" + }, + "breakers": { + "provider": "按提供商筛选" + }, + "cooldowns": { + "provider": "按提供商筛选", + "connectionId": "按连接 ID 筛选" + }, + "lockouts": { + "provider": "按提供商筛选", + "model": "按模型筛选" + }, + "reset": { + "description": "重置断路器/冷却状态", + "provider": "要重置的提供商", + "connectionId": "要重置的连接 ID", + "model": "要重置锁定状态的模型", + "allCooldowns": "重置提供商的所有冷却", + "yes": "跳过确认" + }, + "profile": { + "description": "管理弹性配置文件", + "name": "配置文件名称" + }, + "config": { + "description": "管理弹性配置", + "threshold": "失败阈值", + "resetTimeout": "重置超时(毫秒)", + "baseCooldown": "基础冷却时间(毫秒)" + } + }, + "nodes": { + "description": "管理提供商节点(端点)", + "list": { + "provider": "按提供商筛选", + "enabled": "仅显示已启用的节点" + }, + "add": { + "provider": "提供商名称", + "baseUrl": "节点的基础 URL", + "name": "节点名称", + "weight": "负载均衡权重", + "region": "区域标签", + "authHeader": "自定义认证标头(key=value)" + }, + "update": { + "baseUrl": "新的基础 URL", + "name": "新名称", + "weight": "新权重", + "region": "新区域", + "enabled": "启用或禁用(true|false)" + }, + "remove": { + "yes": "跳过确认" + }, + "validate": { + "baseUrl": "要验证的 URL", + "provider": "要验证的提供商" + }, + "test": { + "description": "向节点发送测试请求" + }, + "metrics": { + "description": "显示节点指标", + "period": "时间范围(例如 24h, 7d)" + } + }, + "context": { + "description": "配置上下文工程管道(Caveman, RTK)", + "analytics": { + "period": "时间范围(例如 7d, 30d)" + }, + "caveman": { + "description": "管理 Caveman 上下文压缩器", + "config": { + "description": "显示或更新 Caveman 配置", + "aggressiveness": "激进程度 0.0–1.0", + "maxShrinkPct": "最大压缩百分比", + "preserveTags": "要保留的逗号分隔标签" + } + }, + "rtk": { + "description": "管理 RTK 上下文优化器", + "config": { + "description": "显示或更新 RTK 配置", + "tokenBudget": "RTK 令牌预算", + "reservePct": "保留百分比" + }, + "filters": { + "description": "管理 RTK 过滤器", + "pattern": "过滤器模式(正则)", + "priority": "过滤器优先级(默认:100)", + "action": "过滤器操作:drop|shrink|replace", + "yes": "跳过确认" + }, + "test": { + "file": "请求 JSON 文件路径" + } + }, + "combos": { + "description": "上下文感知的组合管理" + } + }, + "sessions": { + "description": "检查和管理活动会话", + "list": { + "user": "按用户筛选", + "kind": "按类型筛选(dashboard|api-key|mcp|a2a)", + "active": "仅显示活动会话", + "limit": "最大结果数(默认:100)" + }, + "expire": { + "yes": "跳过确认" + }, + "expireAll": { + "user": "要过期其会话的用户", + "yes": "跳过确认" + } + }, + "tags": { + "description": "管理资源标签", + "add": { + "color": "标签颜色(十六进制或名称)", + "description": "标签描述" + }, + "remove": { + "yes": "跳过确认" + }, + "assign": { + "tag": "标签名称", + "to": "目标资源,格式为 type:id(例如 provider:openai)" + }, + "unassign": { + "tag": "标签名称", + "from": "来源资源,格式为 type:id" + } + }, + "openapi": { + "description": "访问和测试 OmniRoute OpenAPI 规范", + "dump": { + "description": "将 OpenAPI 规范输出到 stdout 或文件", + "format": "输出格式:yaml|json(默认:yaml)", + "out": "保存到文件路径" + }, + "validate": { + "description": "验证 OpenAPI 规范" + }, + "try": { + "description": "通过规范测试 API 端点", + "method": "HTTP 方法(默认:GET)", + "body": "请求体 JSON 文件路径", + "query": "查询参数 key=value(可重复)", + "header": "标头 key=value(可重复)" + }, + "endpoints": { + "description": "列出所有 API 端点", + "search": "按路径或摘要筛选" + }, + "paths": { + "description": "列出所有 API 路径" + } + }, + "combo": { + "title": "组合", + "switched": "当前组合:{name}", + "created": "组合已创建:{name}", + "deleted": "组合已删除:{name}", + "noCombos": "未配置组合。", + "confirmDelete": "删除组合 {name}?", + "suggest": { + "description": "使用 AI 评分为任务推荐最佳组合", + "task": "任务描述", + "maxCost": "每次请求的最大成本(美元)", + "maxLatencyMs": "最大延迟(毫秒)", + "weights": "JSON 评分权重,例如 {\"latency\":0.7,\"cost\":0.3}", + "top": "要显示的候选数(默认:5)", + "explain": "将理由打印到 stderr", + "switch": "激活排名最高的组合" + } + }, + "oneproxy": { + "description": "管理 OneProxy 上游代理池", + "stats": { + "provider": "按提供商筛选", + "period": "时间范围(默认:24h)" + }, + "fetch": { + "description": "从代理池获取代理", + "count": "要获取的代理数(默认:1)", + "type": "代理类型:http|socks5(默认:http)" + }, + "rotate": { + "description": "强制轮换代理", + "provider": "要轮换的提供商", + "connectionId": "要轮换的特定连接 ID" + }, + "config": { + "description": "显示或更新 OneProxy 配置", + "enabled": "启用代理池(true|false)", + "poolSize": "池大小", + "providerSource": "代理提供商的 URL", + "rotationPolicy": "轮换策略:sticky|per-request|periodic" + }, + "pool": { + "description": "列出当前代理池及指标" + } + }, + "open": { + "description": "在浏览器中打开特定的 OmniRoute 仪表盘页面", + "url": "仅打印 URL,不打开浏览器" + }, + "telemetry": { + "description": "访问聚合遥测数据", + "summary": { + "description": "显示聚合遥测摘要", + "period": "时间范围:24h|7d|30d(默认:24h)", + "compareTo": "与上一时期比较" + }, + "export": { + "description": "将遥测事件导出到 JSONL", + "out": "输出文件路径(默认:telemetry.jsonl)", + "period": "导出时间范围(默认:7d)" + } + }, + "sync": { + "description": "在 OmniRoute 实例之间同步配置", + "push": { + "description": "将配置推送到云或远程实例", + "target": "目标(cloud 或 context:name)", + "bundle": "要同步的包部分", + "dryRun": "预览但不应用" + }, + "pull": { + "description": "从云或远程拉取配置", + "source": "来源(cloud 或 context:name)", + "merge": "与现有配置合并", + "replace": "替换现有配置", + "dryRun": "预览但不应用" + }, + "diff": { + "source": "源上下文", + "target": "目标上下文" + }, + "bundle": { + "description": "将配置导出为包文件", + "include": "要包含的部分(逗号分隔)" + }, + "import": { + "description": "导入包文件", + "dryRun": "验证但不应用" + }, + "initialize": { + "fromCloud": "从云备份初始化" + }, + "tokens": { + "description": "管理同步令牌", + "create": { + "name": "令牌名称", + "scope": "令牌作用域", + "ttl": "令牌有效期(例如 30d)" + }, + "revoke": { + "yes": "跳过确认" + } + }, + "resolve": { + "description": "交互式解决同步冲突" + } + }, + "config": { + "contexts": { + "description": "管理服务器上下文/配置文件(添加、使用、列出、显示、移除、重命名、导出、导入)" + }, + "lang": { + "description": "管理 CLI 显示语言", + "getDescription": "显示当前活动的语言代码", + "setDescription": "设置显示语言并保存到配置", + "listDescription": "列出所有可用语言", + "listTitle": "可用语言", + "current": "语言:{code}({name})", + "saved": "语言已设置为 {code}({name})。", + "noCode": "需要语言代码。运行 omniroute config lang list 查看可用代码。", + "unknown": "未知语言代码:{code}。运行 omniroute config lang list 查看可用代码。", + "alreadySet": "语言已设置为 {code}。", + "envHint": "提示:您也可以在环境中设置 OMNIROUTE_LANG={code}。" + } + }, + "completion": { + "description": "生成或安装 Shell 补全脚本", + "zsh": "打印 zsh 补全脚本", + "bash": "打印 bash 补全脚本", + "fish": "打印 fish 补全脚本", + "install": "为检测到的 Shell 全局安装补全脚本", + "refresh": "刷新组合/提供商/模型缓存" + }, + "logs": { + "description": "流式传输或导出请求日志", + "follow": "实时流式传输日志", + "filter": "按级别筛选(error,warn,info)— 逗号分隔", + "lines": "要获取的行数", + "timeout": "连接超时(毫秒)", + "baseUrl": "OmniRoute API 基础 URL", + "requestId": "按请求 ID 筛选", + "apiKey": "按 API 密钥筛选", + "combo": "按组合名称筛选", + "status": "按 HTTP 状态码筛选", + "durationMin": "最小请求持续时间(毫秒)", + "durationMax": "最大请求持续时间(毫秒)", + "export": "将日志保存到文件(json/jsonl/csv)", + "exported": "日志已保存至 {path}", + "stopped": "日志流已停止。", + "streamError": "日志流错误:{message}" + }, + "tray": { + "description": "控制系统托盘图标", + "show": "显示托盘图标(如果服务器使用 --tray 运行)", + "hide": "隐藏托盘图标", + "quit": "通过托盘退出 OmniRoute" + }, + "autostart": { + "description": "管理 OmniRoute 开机自启(Linux:systemd 用户服务)", + "enable": "启用开机自启", + "disable": "禁用开机自启", + "status": "显示自启状态", + "toggle": "切换开机自启" + }, + "runtime": { + "description": "管理本地运行时依赖", + "check": "检查运行时目录中本地依赖的状态", + "repair": "重新安装运行时目录中的本地依赖", + "repair_force": "即使有效也强制重新安装", + "clean": "删除运行时目录(释放磁盘空间)", + "clean_yes": "跳过确认" + }, + "repl": { + "description": "与 LLM 交互式多轮 REPL", + "model": "要使用的模型(默认:auto)", + "combo": "要使用的组合名称", + "system": "系统提示", + "resume": "按名称恢复保存的会话" + }, + "plugin": { + "description": "管理 CLI 插件(omniroute-cmd-*)", + "list": "列出已安装的插件", + "install": "从 npm 或本地路径安装插件", + "remove": "删除已安装的插件", + "info": "显示已安装插件的详情", + "search": "搜索 npm 注册表中的可用插件", + "update": "更新已安装的插件", + "scaffold": "搭建新的插件模板" } } diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index e416f57f38..3792dec1ec 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -17,7 +17,7 @@ It provides a single OpenAI-compatible endpoint (`/v1/*`) and routes traffic acr Core capabilities: -- OpenAI-compatible API surface for CLI/tools (177 providers, 55 executors) +- OpenAI-compatible API surface for CLI/tools (177 providers, 60 executors) - Request/response translation across provider formats - Model combo fallback (multi-model sequence) - Structured combo steps (`provider + model + connection`) with runtime ordering by `compositeTiers` diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 6256824de5..dcc10ce3f9 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -989,6 +989,12 @@ history, or compressing fallback requests; enabling it allows configured hedging skips, and proactive fallback compression to trade routing/request fidelity for lower tail latency. +Disable **Reasoning token buffer** when upstream providers require strict +`max_tokens` / `maxOutputTokens` limits. When enabled, combo routing only adds reasoning-model +headroom for models with a known output cap and leaves the client token limit unchanged when the +safe buffered value would exceed that cap. If the client limit is already above a known cap, +OmniRoute clamps it down to that cap before sending the upstream request. + --- ### Health Dashboard diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 087aa8fe97..a69849f9a1 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 6c3e4d337c..53b25f55cb 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 6c3e4d337c..53b25f55cb 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index d1e38220e5..eac10e6cd8 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 856d776d61..e8ca032a15 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 420b57b239..17cf7768ea 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index dd29ccc899..c19a046563 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index cbc8be128d..4e22735726 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 7b8246e1e4..36e621d9a0 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 9ae8fc57e6..6baf5e6475 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 12446f97d8..3a99dde766 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index f7115d78d2..8a2ec07435 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index ce4f834626..f542d29ede 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 67952135d7..5854502c47 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 4153d89e7a..cac7dfef75 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 1a4946a8a8..efbd66686b 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 287f5e74ad..f4b5f6aedd 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index ea20c5e0a9..4293c8264f 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index bee3830813..84e747513d 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 2d4a4bc119..7e05fe336c 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 2d6d31b79b..e6ea8faf0e 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 941dbf55ec..12b6491553 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index c957a171fd..87ee99cd32 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 2620b21135..48336b783e 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index d149e3c9e0..b0e72d3e66 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 0688bb4b92..985f01dfc0 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index afeeb61e5b..fc2a5cadbe 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 46cd4ffbf2..c1e556f9c7 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index ee4c92d430..7515cb8c77 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 31a5da7e2d..8602274b67 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 63117457b1..e03ef3ee6d 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index f071c2e757..4467eb15ed 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 6307211fb3..c85af1c131 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index bb5ca0b214..30f309ffd8 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 926726b61f..09ea46c7b1 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index ef1d0a62ce..9d785a7add 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 1eb1306787..f59f8e1a7e 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index e606dd07dd..1583ff85f7 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 21e84b8e72..31a19aecf5 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index ba1ca85bcc..739c7af600 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 630537e06f..6cc1a5fbe3 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -171,7 +171,7 @@ _Development cycle in progress._ `[403] `. The quota path now skips proactive refresh for rotating providers (`rotationGroupFor`) and reuses the current access_token, deferring genuine expiry to the reactive, serialized 401 path. Defense in - depth: `serializeRefresh` now leaves a settle gap between two *queued* sibling + depth: `serializeRefresh` now leaves a settle gap between two _queued_ sibling refreshes (default 2000 ms, tunable via `CODEX_REFRESH_SPACING_MS`, `"0"` to opt out) while releasing a lone refresh immediately, so the reactive path adds no latency. @@ -234,7 +234,7 @@ _Development cycle in progress._ via a new `RegistryModel.interleavedField` field, so follow-up/tool-use turns replay reasoning_content. Previously `big-pickle` matched no replay pattern and failed with `[400] The reasoning_content in the thinking mode must be passed - back to the API` (its DeepSeek-thinking upstream is not detectable from the +back to the API` (its DeepSeek-thinking upstream is not detectable from the model id, and `requiresReasoningReplay` does not consume `supportsReasoning`). `getResolvedModelCapabilities` now surfaces the registry `interleavedField`. (#2900) - **providers/github-copilot:** built-in GitHub Copilot Claude Opus and Gemini @@ -267,7 +267,7 @@ _Development cycle in progress._ instead of failing with "No credentials for provider: opencode-zen". A configured, active key is still used when present. (#2962) - **translator/responses:** fixed an upstream `[400] Messages with role 'tool' - must be a response to a preceding message with 'tool_calls'` when a Codex +must be a response to a preceding message with 'tool_calls'` when a Codex client sent a `function_call` with an empty/missing `call_id`. The orphaned `function_call_output` previously slipped past the orphan filter. Now empty-`call_id` function calls are skipped (no dangling assistant tool_call) @@ -386,6 +386,12 @@ _Development cycle in progress._ ## [Unreleased] +--- + +## [3.8.23] — TBD + +--- + ### ✨ New Features ### 🔧 Bug Fixes diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 333bc4b697..b51e645e6a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -685,6 +685,15 @@ Automatic model pricing data synchronization from external sources. --- +## Arena ELO Sync + +| Variable | Default | Source File | Description | +| --------------------------- | ------------- | -------------------------- | ------------------------------------------------------------- | +| `ARENA_ELO_SYNC_ENABLED` | `false` | `src/lib/arenaEloSync.ts` | Opt-in periodic Arena AI leaderboard ELO sync. | +| `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | `src/lib/arenaEloSync.ts` | Sync interval in seconds. | + +--- + ## 19. Model Sync (Dev) | Variable | Default | Source File | Description | @@ -865,6 +874,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `ALIBABA_CODING_PLAN_QUOTA_URL` | derived from host | `open-sse/services/bailianQuotaFetcher.ts` | Full quota URL override for Alibaba Bailian. | | `CONTEXT_RESERVE_TOKENS` | `1024` | `open-sse/services/contextManager.ts` | Tokens reserved for completion output when computing prompt budgets. | | `MODEL_ALIAS_COMPAT_ENABLED` | enabled | `open-sse/services/model.ts` | Toggle the legacy model-alias compatibility layer used by older clients. | +| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. | | `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. | | `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. | | `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 3b8cdb8ff0..3fce18a61d 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.22 + version: 3.8.23 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, @@ -138,7 +138,7 @@ paths: - detailed default: concise responses: - '200': + "200": description: Improved prompt(s) content: application/json: @@ -153,10 +153,10 @@ paths: type: integer tokensOut: type: integer - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" /api/playground/presets: get: tags: @@ -166,7 +166,7 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: Preset list content: application/json: @@ -176,9 +176,9 @@ paths: presets: type: array items: - $ref: '#/components/schemas/PlaygroundPreset' - '401': - $ref: '#/components/responses/Unauthorized' + $ref: "#/components/schemas/PlaygroundPreset" + "401": + $ref: "#/components/responses/Unauthorized" post: tags: - Playground @@ -191,18 +191,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaygroundPresetCreate' + $ref: "#/components/schemas/PlaygroundPresetCreate" responses: - '201': + "201": description: Created preset content: application/json: schema: - $ref: '#/components/schemas/PlaygroundPreset' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' + $ref: "#/components/schemas/PlaygroundPreset" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" /api/playground/presets/{id}: parameters: - name: id @@ -218,15 +218,15 @@ paths: security: - BearerAuth: [] responses: - '200': + "200": description: Preset found content: application/json: schema: - $ref: '#/components/schemas/PlaygroundPreset' - '401': - $ref: '#/components/responses/Unauthorized' - '404': + $ref: "#/components/schemas/PlaygroundPreset" + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Preset not found put: tags: @@ -239,19 +239,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PlaygroundPresetCreate' + $ref: "#/components/schemas/PlaygroundPresetCreate" responses: - '200': + "200": description: Updated preset content: application/json: schema: - $ref: '#/components/schemas/PlaygroundPreset' - '400': - $ref: '#/components/responses/BadRequest' - '401': - $ref: '#/components/responses/Unauthorized' - '404': + $ref: "#/components/schemas/PlaygroundPreset" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Preset not found delete: tags: @@ -260,11 +260,11 @@ paths: security: - BearerAuth: [] responses: - '204': + "204": description: Deleted - '401': - $ref: '#/components/responses/Unauthorized' - '404': + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Preset not found # --- Memory Engine (plan 21) --- /api/memory: @@ -315,7 +315,7 @@ paths: type: integer minimum: 0 responses: - '200': + "200": description: Paginated list of memories with stats content: application/json: @@ -325,7 +325,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/MemoryEntry' + $ref: "#/components/schemas/MemoryEntry" total: type: integer totalPages: @@ -346,8 +346,8 @@ paths: type: integer misses: type: integer - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" post: tags: - Memory @@ -391,16 +391,16 @@ paths: format: date-time nullable: true responses: - '201': + "201": description: Created memory entry content: application/json: schema: - $ref: '#/components/schemas/MemoryEntry' - '400': + $ref: "#/components/schemas/MemoryEntry" + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/{id}: parameters: - name: id @@ -416,15 +416,15 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Memory entry content: application/json: schema: - $ref: '#/components/schemas/MemoryEntry' - '401': - $ref: '#/components/responses/Unauthorized' - '404': + $ref: "#/components/schemas/MemoryEntry" + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Memory not found put: tags: @@ -458,17 +458,17 @@ paths: additionalProperties: true additionalProperties: false responses: - '200': + "200": description: Updated memory entry content: application/json: schema: - $ref: '#/components/schemas/MemoryEntry' - '400': + $ref: "#/components/schemas/MemoryEntry" + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' - '404': + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Memory not found delete: tags: @@ -478,7 +478,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Deleted content: application/json: @@ -487,9 +487,9 @@ paths: properties: success: type: boolean - '401': - $ref: '#/components/responses/Unauthorized' - '404': + "401": + $ref: "#/components/responses/Unauthorized" + "404": description: Memory not found /api/memory/health: get: @@ -500,7 +500,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Health result content: application/json: @@ -514,8 +514,8 @@ paths: error: type: string nullable: true - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/retrieve-preview: post: tags: @@ -558,7 +558,7 @@ paths: default: 20 additionalProperties: false responses: - '200': + "200": description: Preview results content: application/json: @@ -634,10 +634,10 @@ paths: type: integer budgetMaxTokens: type: integer - '400': + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/embedding-providers: get: tags: @@ -647,7 +647,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Provider list content: application/json: @@ -670,14 +670,14 @@ paths: properties: id: type: string - description: 'Format: provider/model' + description: "Format: provider/model" name: type: string dimensions: type: integer nullable: true - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/engine-status: get: tags: @@ -687,7 +687,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Engine status content: application/json: @@ -778,14 +778,14 @@ paths: type: boolean reason: type: string - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/summarize: post: tags: - Memory summary: Compact old memories - description: 'Manually triggers memory compaction for memories older than `olderThanDays`. Use `dryRun: true` to preview candidates. Corresponds to `MemorySummarizeSchema`.' + description: "Manually triggers memory compaction for memories older than `olderThanDays`. Use `dryRun: true` to preview candidates. Corresponds to `MemorySummarizeSchema`." security: - ManagementSessionAuth: [] requestBody: @@ -808,7 +808,7 @@ paths: default: false additionalProperties: false responses: - '200': + "200": description: Summarization result content: application/json: @@ -821,16 +821,16 @@ paths: type: integer dryRun: type: boolean - '400': + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/memory/reindex: post: tags: - Memory summary: Trigger vector reindex - description: 'Starts background reindexing of memories with `needs_reindex = 1`. Use `force: true` to regenerate ALL vectors regardless of index status. Corresponds to `MemoryReindexSchema`.' + description: "Starts background reindexing of memories with `needs_reindex = 1`. Use `force: true` to regenerate ALL vectors regardless of index status. Corresponds to `MemoryReindexSchema`." security: - ManagementSessionAuth: [] requestBody: @@ -846,7 +846,7 @@ paths: description: When true, marks all memories needs_reindex=1 before running. additionalProperties: false responses: - '200': + "200": description: Reindex started content: application/json: @@ -858,10 +858,10 @@ paths: pending: type: integer description: Memories still pending after this batch - '400': + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/settings/memory: get: tags: @@ -872,20 +872,20 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Extended memory settings content: application/json: schema: - $ref: '#/components/schemas/MemorySettingsExtended' - '401': - $ref: '#/components/responses/Unauthorized' + $ref: "#/components/schemas/MemorySettingsExtended" + "401": + $ref: "#/components/responses/Unauthorized" put: tags: - Memory - Settings summary: Update memory settings - description: 'Update any subset of the extended memory settings. All fields are optional; only provided fields are updated. Schema: `MemorySettingsExtendedSchema`.' + description: "Update any subset of the extended memory settings. All fields are optional; only provided fields are updated. Schema: `MemorySettingsExtendedSchema`." security: - ManagementSessionAuth: [] requestBody: @@ -893,18 +893,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MemorySettingsExtended' + $ref: "#/components/schemas/MemorySettingsExtended" responses: - '200': + "200": description: Updated memory settings content: application/json: schema: - $ref: '#/components/schemas/MemorySettingsExtended' - '400': + $ref: "#/components/schemas/MemorySettingsExtended" + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/settings/qdrant: get: tags: @@ -915,14 +915,14 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Qdrant settings content: application/json: schema: - $ref: '#/components/schemas/QdrantSettings' - '401': - $ref: '#/components/responses/Unauthorized' + $ref: "#/components/schemas/QdrantSettings" + "401": + $ref: "#/components/responses/Unauthorized" put: tags: - Memory @@ -957,16 +957,16 @@ paths: description: Empty string removes the key additionalProperties: false responses: - '200': + "200": description: Updated Qdrant settings content: application/json: schema: - $ref: '#/components/schemas/QdrantSettings' - '400': + $ref: "#/components/schemas/QdrantSettings" + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" /api/settings/qdrant/health: get: tags: @@ -976,20 +976,20 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Health result content: application/json: schema: - $ref: '#/components/schemas/QdrantHealthResult' - '401': - $ref: '#/components/responses/Unauthorized' + $ref: "#/components/schemas/QdrantHealthResult" + "401": + $ref: "#/components/responses/Unauthorized" /api/settings/qdrant/search: post: tags: - Memory summary: Qdrant semantic search test - description: 'Performs a test semantic search against the Qdrant collection. Useful for validating that the integration works end-to-end. Schema: `QdrantSearchSchema`.' + description: "Performs a test semantic search against the Qdrant collection. Useful for validating that the integration works end-to-end. Schema: `QdrantSearchSchema`." security: - ManagementSessionAuth: [] requestBody: @@ -1011,7 +1011,7 @@ paths: default: 5 additionalProperties: false responses: - '200': + "200": description: Search results content: application/json: @@ -1022,11 +1022,11 @@ paths: type: array items: type: object - '400': + "400": description: Validation error - '401': - $ref: '#/components/responses/Unauthorized' - '503': + "401": + $ref: "#/components/responses/Unauthorized" + "503": description: Qdrant unavailable (structured error, no stack trace) /api/settings/qdrant/cleanup: post: @@ -1037,7 +1037,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Cleanup result content: application/json: @@ -1048,9 +1048,9 @@ paths: type: integer checked: type: integer - '401': - $ref: '#/components/responses/Unauthorized' - '503': + "401": + $ref: "#/components/responses/Unauthorized" + "503": description: Qdrant unavailable (structured error, no stack trace) /api/settings/qdrant/embedding-models: get: @@ -1061,7 +1061,7 @@ paths: security: - ManagementSessionAuth: [] responses: - '200': + "200": description: Embedding models list content: application/json: @@ -1072,8 +1072,8 @@ paths: type: array items: type: string - '401': - $ref: '#/components/responses/Unauthorized' + "401": + $ref: "#/components/responses/Unauthorized" # ─── Proxy Endpoints ────────────────────────────────────────── /api/v1/chat/completions: @@ -5422,7 +5422,20 @@ components: InterceptedRequest: type: object description: A single intercepted HTTP request captured by the Traffic Inspector - required: [id, source, timestamp, method, host, path, requestHeaders, requestSize, responseHeaders, responseSize, status] + required: + [ + id, + source, + timestamp, + method, + host, + path, + requestHeaders, + requestSize, + responseHeaders, + responseSize, + status, + ] properties: id: type: string diff --git a/electron/package-lock.json b/electron/package-lock.json index 8206cb09e3..fdb25d1b99 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute-desktop", - "version": "3.8.20", + "version": "3.8.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute-desktop", - "version": "3.8.20", + "version": "3.8.23", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9" diff --git a/electron/package.json b/electron/package.json index 888f249bd0..abcd8110b7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.8.22", + "version": "3.8.23", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/file-size-baseline.json b/file-size-baseline.json index fbd4cd6e9b..62e48dcfdb 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -2,107 +2,113 @@ "_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": 3394, + "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/api-manager/ApiManagerPageClient.tsx": 2701, "src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105, "src/app/(dashboard)/dashboard/cache/page.tsx": 841, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894, "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 913, "src/app/(dashboard)/dashboard/combos/page.tsx": 4350, - "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1481, + "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, "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": 784, "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": 939, "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": 943, + "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": 3146, "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": 1392, + "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.).", + "_rebaseline_2026_06_12_v3823_new_features": "Re-baseline v3.8.23 pós-merge de #3742 (cost drilldown: ApiManagerPageClient.tsx +21, CostOverviewTab.tsx +14, providerLimits.ts +2, usage.ts +53) + #3743 (provider display modes: ProviderDetailPageClient.tsx +2, providerPageHelpers.ts +42, providers.ts +2) + #3740 (semantic cache key isolation: chat.ts +3). Crescimento justificado por features novas mergeadas no ciclo." } diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 0376d7396d..0878f5ab7a 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -65,9 +65,10 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, - // Gemini 3.1 Pro budget tiers — agy already ships these; #3184 confirmed they work via - // the antigravity OAuth provider. The -high/-low suffix is aliased to the plain - // gemini-3.1-pro upstream id (see ANTIGRAVITY_MODEL_ALIASES / #3229). + // Gemini 3.1 Pro budget tiers — agy ships these and they route directly via the + // antigravity OAuth provider. The upstream ACCEPTS the suffixed ids verbatim (wire- + // confirmed via `agy --model gemini-3.1-pro-high`: 200 OK on /v1internal:streamGenerateContent). + // No alias needed; see #3696 (supersedes the #3229 premise). { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)", @@ -158,10 +159,10 @@ export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ // (upstream `gemini-3-flash-agent`). It is NOT re-added to the public catalog. "gemini-3.5-flash-preview": "gemini-3-flash-agent", "gemini-3-pro-preview": "gemini-3.1-pro", - // agy catalog exposes -high/-low budget tiers, but the upstream rejects the suffix - // for gemini-3.x (#3229) — map them to the plain proven id. - "gemini-3.1-pro-high": "gemini-3.1-pro", - "gemini-3.1-pro-low": "gemini-3.1-pro", + // gemini-3.1-pro-high and gemini-3.1-pro-low are NOT aliased here: wire capture + // (#3696) confirmed the upstream accepts the suffixed ids verbatim → pass through. + // (The earlier #3229 assumption — "upstream rejects -high/-low for gemini-3.x" — + // was refuted by the agy --log-file 200 OK evidence.) "gemini-3-pro-image-preview": "gemini-3-pro-image", "gemini-2.5-computer-use-preview-10-2025": "rev19-uic3-1p", // Legacy Claude display ids → current upstream ids. NOTE: an earlier comment here diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 5b29278ece..d7d5160915 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -418,14 +418,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "friendliai", modelId: "meta-llama-3.1-8b-instruct", displayName: "meta-llama-3.1-8b-instruct", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "friendliai", tos: "avoid" }, { provider: "ai21", modelId: "jamba-large-1.7", displayName: "jamba-large-1.7", monthlyTokens: 0, creditTokens: 10000000, freeType: "one-time-initial", poolKey: "ai21", tos: "avoid" }, { provider: "ai21", modelId: "jamba-mini-2", displayName: "jamba-mini-2", monthlyTokens: 0, creditTokens: 10000000, freeType: "one-time-initial", poolKey: "ai21", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen-plus", displayName: "Qwen Plus", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen-max", displayName: "Qwen Max", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen-turbo", displayName: "Qwen Turbo", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen3-plus", displayName: "Qwen3 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen3-max", displayName: "Qwen3 Max", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen3-flash", displayName: "Qwen3 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen3-coder-plus", displayName: "Qwen3 Coder Plus", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, - { provider: "qwen-web", modelId: "qwen3-coder-flash", displayName: "Qwen3 Coder Flash", monthlyTokens: 0, creditTokens: 0, freeType: "discontinued", poolKey: "qwen-web", tos: "avoid" }, + { provider: "qwen-web", modelId: "qwen3.7-max", displayName: "Qwen3.7 Max", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, + { provider: "qwen-web", modelId: "qwen3.7-plus", displayName: "Qwen3.7 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, + { provider: "qwen-web", modelId: "qwen3.6-plus", displayName: "Qwen3.6 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, { provider: "gitlawb", modelId: "mimo-v2.5-pro", displayName: "MiMo-V2.5-Pro", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "gitlawb", tos: "unknown" }, { provider: "gitlawb", modelId: "mimo-v2.5", displayName: "MiMo-V2.5", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "gitlawb", tos: "unknown" }, { provider: "gitlawb", modelId: "mimo-v2-pro", displayName: "MiMo-V2-Pro", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "gitlawb", tos: "unknown" }, diff --git a/open-sse/config/geminiRateLimits.json b/open-sse/config/geminiRateLimits.json new file mode 100644 index 0000000000..a1ae15dc7f --- /dev/null +++ b/open-sse/config/geminiRateLimits.json @@ -0,0 +1,34 @@ +{ + "gemini-2.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 }, + "gemini-2.5-pro": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "gemini-2-flash": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "gemini-2-flash-lite": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "gemini-2.5-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 }, + "gemini-2.5-pro-tts": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "imagen-4-generate": { "rpm": -1, "rpd": 25, "tpm": -1 }, + "imagen-4-ultra-generate": { "rpm": -1, "rpd": 25, "tpm": -1 }, + "imagen-4-fast-generate": { "rpm": -1, "rpd": 25, "tpm": -1 }, + "gemma-4-26b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 }, + "gemma-4-31b-it": { "rpm": 15, "rpd": 1500, "tpm": -1 }, + "gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 }, + "gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 }, + "gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 }, + "gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 }, + "nano-banana-gemini-2.5-flash-preview-image": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "nano-banana-pro-gemini-3-pro-image": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "nano-banana-2-gemini-3.1-flash-image": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "lyria-3-clip": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "lyria-3-pro": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "veo-3-generate": { "rpm": 0, "rpd": 0, "tpm": -1 }, + "veo-3-fast-generate": { "rpm": 0, "rpd": 0, "tpm": -1 }, + "veo-3-lite-generate": { "rpm": 0, "rpd": 0, "tpm": -1 }, + "gemini-3.1-flash-tts": { "rpm": 3, "rpd": 10, "tpm": 10000 }, + "gemini-robotics-er-1.5-preview": { "rpm": 10, "rpd": 20, "tpm": 250000 }, + "gemini-robotics-er-1.6-preview": { "rpm": 5, "rpd": 20, "tpm": 250000 }, + "computer-use-preview": { "rpm": 0, "rpd": 0, "tpm": 0 }, + "gemini-embedding-exp-04-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 }, + "gemini-3.5-live-translate": { "rpm": -1, "rpd": -1, "tpm": 20000 }, + "gemini-2.5-flash-native-audio-dialog": { "rpm": -1, "rpd": -1, "tpm": 1000000 }, + "gemini-3-flash-live": { "rpm": -1, "rpd": -1, "tpm": 65000 } +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index d017b25a18..27bc46650f 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -4011,18 +4011,17 @@ const _REGISTRY_EAGER: Record = { alias: "qwen-web", format: "openai", executor: "qwen-web", - baseUrl: "https://chat.qwen.ai/api/chat/completions", + // v2 API (the legacy /api/chat/completions endpoint was retired upstream). + baseUrl: "https://chat.qwen.ai/api/v2/chat/completions", authType: "apikey", authHeader: "bearer", + // Current upstream catalog (GET https://chat.qwen.ai/api/models). Legacy + // ids (qwen-plus, qwen3-max, ...) still resolve via the executor's + // MODEL_ALIASES map for backward compatibility. models: [ - { id: "qwen-plus", name: "Qwen Plus" }, - { id: "qwen-max", name: "Qwen Max" }, - { id: "qwen-turbo", name: "Qwen Turbo" }, - { id: "qwen3-plus", name: "Qwen3 Plus" }, - { id: "qwen3-max", name: "Qwen3 Max" }, - { id: "qwen3-flash", name: "Qwen3 Flash" }, - { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, - { id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max" }, + { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, + { id: "qwen3.6-plus", name: "Qwen3.6 Plus" }, ], }, diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index d75d24174f..00bc5061b6 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -28,6 +28,42 @@ const COOLDOWN_MAX_MS = 60_000; const MIMO_SOURCE = "mimocode-cli-free"; +/** + * Anti-abuse gate marker required by the Xiaomi free endpoint. + * + * `/api/free-ai/openai/chat` returns `403 "Illegal access"` unless the request body + * contains a recognized MiMoCode prompt signature as a substring inside a `system`-role + * message (verified empirically — headers, fingerprint, and JWT are not what is checked). + * This is the canonical MiMoCode agent opener the official CLI sends, and it is on the + * upstream allowlist. We inject it as a leading system message so user requests pass the + * gate. The string MUST stay byte-for-byte identical — the check is case-sensitive and + * truncations are rejected. + */ +export const MIMO_SYSTEM_MARKER = + "You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks."; + +/** + * Ensure the outgoing body carries the MiMoCode anti-abuse marker in a system message. + * Idempotent: if any system message already contains the marker, the body is returned + * unchanged. Bodies without a `messages` array are left untouched. + */ +function injectSystemMarker(body: Record): Record { + const messages = body.messages; + if (!Array.isArray(messages)) return body; + + const hasMarker = messages.some( + (m) => + m != null && + typeof m === "object" && + (m as { role?: unknown }).role === "system" && + typeof (m as { content?: unknown }).content === "string" && + (m as { content: string }).content.includes(MIMO_SYSTEM_MARKER) + ); + if (hasMarker) return body; + + return { ...body, messages: [{ role: "system", content: MIMO_SYSTEM_MARKER }, ...messages] }; +} + const USER_AGENTS = [ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", @@ -67,7 +103,9 @@ function getCpuModel(): string { try { const cpus = os.cpus(); if (cpus.length > 0 && cpus[0].model) return cpus[0].model.trim(); - } catch { /* ignore */ } + } catch { + /* ignore */ + } return "unknown-cpu"; } @@ -80,8 +118,13 @@ export function generateFingerprint(seed?: string): string { let username = "unknown-user"; try { username = os.userInfo().username; - } catch { /* ignore */ } - return crypto.createHash("sha256").update(`${hostname}|${platform}|${arch}|${cpu}|${username}`).digest("hex"); + } catch { + /* ignore */ + } + return crypto + .createHash("sha256") + .update(`${hostname}|${platform}|${arch}|${cpu}|${username}`) + .digest("hex"); } // ── Bootstrap ────────────────────────────────────────────────────────────── @@ -91,7 +134,7 @@ const bootstrapInflight = new Map { const existing = bootstrapInflight.get(fingerprint); if (existing) return existing; @@ -146,7 +189,13 @@ export class MimocodeExecutor extends BaseExecutor { constructor() { super("mimocode", { format: "openai" }); this.baseUrl = this.getBaseUrls()[0] || "https://api.xiaomimimo.com"; - this.accounts.push({ fingerprint: generateFingerprint(), jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 }); + this.accounts.push({ + fingerprint: generateFingerprint(), + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + }); } private syncAccountsFromCredentials(credentials: ProviderCredentials): void { @@ -155,13 +204,22 @@ export class MimocodeExecutor extends BaseExecutor { const existing = new Set(this.accounts.map((a) => a.fingerprint)); for (const fp of fingerprints) { if (typeof fp === "string" && !existing.has(fp)) { - this.accounts.push({ fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 }); + this.accounts.push({ + fingerprint: fp, + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + }); existing.add(fp); } } } - private async getJwtForAccount(account: AccountState, signal?: AbortSignal | null): Promise { + private async getJwtForAccount( + account: AccountState, + signal?: AbortSignal | null + ): Promise { if (isAccountReady(account)) return account.jwt; const result = await bootstrapJwt(this.baseUrl, account.fingerprint, signal); account.jwt = result.jwt; @@ -185,7 +243,10 @@ export class MimocodeExecutor extends BaseExecutor { private markCooldown(account: AccountState): void { account.consecutiveFails++; - const backoff = Math.min(COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), COOLDOWN_MAX_MS); + const backoff = Math.min( + COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), + COOLDOWN_MAX_MS + ); account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; } @@ -193,7 +254,12 @@ export class MimocodeExecutor extends BaseExecutor { account.consecutiveFails = 0; } - buildUrl(_model: string, _stream: boolean, _urlIndex = 0, _credentials?: ProviderCredentials | null): string { + buildUrl( + _model: string, + _stream: boolean, + _urlIndex = 0, + _credentials?: ProviderCredentials | null + ): string { return `${this.baseUrl.replace(/\/$/, "")}${CHAT_PATH}`; } @@ -201,7 +267,7 @@ export class MimocodeExecutor extends BaseExecutor { _credentials: ProviderCredentials, stream = true, _clientHeaders?: Record | null, - _model?: string, + _model?: string ): Record { const headers: Record = { "Content-Type": "application/json", @@ -212,9 +278,15 @@ export class MimocodeExecutor extends BaseExecutor { return headers; } - transformRequest(model: string, body: unknown, _stream: boolean, _credentials?: ProviderCredentials | null): unknown { + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials?: ProviderCredentials | null + ): unknown { if (typeof body === "object" && body !== null) { - return { ...(body as Record), model: rewriteModelName(model) }; + const withModel = { ...(body as Record), model: rewriteModelName(model) }; + return injectSystemMarker(withModel); } return body; } @@ -222,15 +294,25 @@ export class MimocodeExecutor extends BaseExecutor { async testConnection( _credentials: ProviderCredentials, _signal?: AbortSignal | null, - log?: ExecuteInput["log"], + log?: ExecuteInput["log"] ): Promise { try { const account = this.accounts[0]; const jwt = await this.getJwtForAccount(account, _signal); const resp = await fetch(this.buildUrl("mimo-auto", false), { method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}`, "X-Mimo-Source": MIMO_SOURCE }, - body: JSON.stringify({ model: "mimo-auto", messages: [{ role: "user", content: "ping" }], stream: false }), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${jwt}`, + "X-Mimo-Source": MIMO_SOURCE, + }, + body: JSON.stringify( + injectSystemMarker({ + model: "mimo-auto", + messages: [{ role: "user", content: "ping" }], + stream: false, + }) + ), signal: _signal ?? undefined, }); return resp.status === 200; @@ -251,9 +333,14 @@ export class MimocodeExecutor extends BaseExecutor { if (signal?.aborted) { return { - response: new Response(encoder.encode(JSON.stringify({ - error: { message: "Request aborted", type: "abort", code: "ABORTED" }, - })), { status: 499, headers: { "Content-Type": "application/json" } }), + response: new Response( + encoder.encode( + JSON.stringify({ + error: { message: "Request aborted", type: "abort", code: "ABORTED" }, + }) + ), + { status: 499, headers: { "Content-Type": "application/json" } } + ), url: this.buildUrl(model, stream), headers: this.buildHeaders(input.credentials, stream), transformedBody: body, @@ -282,45 +369,81 @@ export class MimocodeExecutor extends BaseExecutor { // On auth failure, re-bootstrap this account and retry once if (resp.status === 401 || resp.status === 403) { - log?.warn?.("MIMOCODE", `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…`); + log?.warn?.( + "MIMOCODE", + `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…` + ); account.jwt = ""; account.expiresAt = 0; account.consecutiveFails = 0; const freshJwt = await this.getJwtForAccount(account, signal); headers["Authorization"] = `Bearer ${freshJwt}`; - resp = await fetch(url, { method: "POST", headers, body: JSON.stringify(reqBody), signal: signal ?? undefined }); + resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(reqBody), + signal: signal ?? undefined, + }); } if (resp.status === 429) { this.markCooldown(account); - log?.warn?.("MIMOCODE", `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…`); + log?.warn?.( + "MIMOCODE", + `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…` + ); continue; } this.markSuccess(account); const respHeaders: Record = {}; - resp.headers.forEach((v, k) => { respHeaders[k] = v; }); - return { response: resp as unknown as Response, url, headers: respHeaders, transformedBody: reqBody }; + resp.headers.forEach((v, k) => { + respHeaders[k] = v; + }); + return { + response: resp as unknown as Response, + url, + headers: respHeaders, + transformedBody: reqBody, + }; } catch (err) { this.markCooldown(account); if (attempt === this.accounts.length - 1) { const msg = err instanceof Error ? err.message : String(err); log?.error?.("MIMOCODE", `Executor error: ${msg}`); return { - response: new Response(encoder.encode(JSON.stringify({ - error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, - })), { status: 502, headers: { "Content-Type": "application/json" } }), - url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body, + response: new Response( + encoder.encode( + JSON.stringify({ + error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, + }) + ), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + url, + headers: this.buildHeaders(input.credentials, stream), + transformedBody: body, }; } } } return { - response: new Response(encoder.encode(JSON.stringify({ - error: { message: "All accounts exhausted", type: "upstream_error", code: "NO_ACCOUNTS" }, - })), { status: 502, headers: { "Content-Type": "application/json" } }), - url, headers: this.buildHeaders(input.credentials, stream), transformedBody: body, + response: new Response( + encoder.encode( + JSON.stringify({ + error: { + message: "All accounts exhausted", + type: "upstream_error", + code: "NO_ACCOUNTS", + }, + }) + ), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + url, + headers: this.buildHeaders(input.credentials, stream), + transformedBody: body, }; } } diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 67c7f50c1e..82db1baebb 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -1,173 +1,367 @@ /** - * QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai + * QwenWebExecutor — Alibaba Tongyi Qwen Chat via chat.qwen.ai (v2 API) * - * Routes requests through Qwen's consumer chat API. - * Chinese market provider with strong vision, coding, and reasoning models. + * Routes requests through Qwen's consumer chat API. The legacy v1 endpoint + * (`/api/chat/completions`) was retired upstream in 2026 and now answers 504 + * HTML from Alibaba's gateway for every request, regardless of credentials + * (#3288 / discussion #2768). The current contract is a two-step v2 flow: * - * Auth: Token from chat.qwen.ai Local Storage or tongyi_sso_ticket cookie - * Endpoint: POST https://chat.qwen.ai/api/chat/completions - * Format: OpenAI-compatible + * 1. POST /api/v2/chats/new → create a chat, returns chat_id + * 2. POST /api/v2/chat/completions?chat_id= → phase-based SSE stream + * + * The v2 endpoints sit behind Alibaba's "baxia" WAF, which requires the full + * browser cookie jar from a real logged-in session (cna, ssxmod_itna, + * ssxmod_itna2, token, ...). We therefore replay the captured/pasted Cookie + * header verbatim plus the bearer token, mirroring how grok-web replays its + * anti-bot cookies. + * + * SSE chunks carry `choices[0].delta` with a `phase` field: `think` / + * `thinking_summary` map to reasoning, `answer` (or a null phase) carries the + * assistant content. + * + * Reference implementations: gpt4free `g4f/Provider/Qwen.py`, + * Chat2API `proxy/adapters/qwen-ai.ts`. + * + * Auth: full Cookie header from chat.qwen.ai + bearer token (localStorage + * `token`, also mirrored to a `token` cookie). + * Format: OpenAI-compatible (translated from Qwen's phase protocol). */ import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; import { prepareToolMessages, buildToolAwareResult } from "../translator/webTools.ts"; +import { buildQwenCookieHeader, extractQwenToken } from "@/lib/providers/webCookieAuth"; const BASE_URL = "https://chat.qwen.ai"; -const CHAT_URL = `${BASE_URL}/api/chat/completions`; +const CHATS_NEW_URL = `${BASE_URL}/api/v2/chats/new`; +const CHAT_COMPLETIONS_URL = `${BASE_URL}/api/v2/chat/completions`; const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; +// Anti-bot headers the v2 endpoint expects. `bx-umidtoken` is normally minted +// per-session from sg-wum.alibaba.com; a captured value travels with the cookie +// jar, but we also send a static fallback so the header is always present. +const BX_VERSION = "2.5.36"; +const BX_UMIDTOKEN_FALLBACK = "T2gA0000000000000000000000000000000000000000"; + +const MODEL_ALIASES: Record = { + // Legacy OmniRoute ids → current upstream catalog (GET /api/models). + "qwen-plus": "qwen3.7-plus", + "qwen-max": "qwen3.7-max", + "qwen-turbo": "qwen3.6-plus", + "qwen3-plus": "qwen3.7-plus", + "qwen3-max": "qwen3.7-max", + "qwen3-flash": "qwen3.6-plus", + "qwen3-coder-plus": "qwen3.7-max", + "qwen3-coder-flash": "qwen3.6-plus", + qwen: "qwen3.7-max", + qwen3: "qwen3.7-max", +}; + +const DEFAULT_MODEL = "qwen3.7-max"; + +function mapModel(modelId: string): string { + return MODEL_ALIASES[modelId] || modelId; +} + +function uuid(): string { + return crypto.randomUUID(); +} + +/** Detect Alibaba's WAF / retired-v1 gateway page so we never surface raw HTML. */ +function isWafResponse(status: number, contentType: string, bodyText: string): boolean { + if (contentType.includes("text/html")) return true; + if (status === 504) return true; + return /aliyun_waf|baxia| { + const headers: Record = { + "Content-Type": "application/json", + Accept: "*/*", + "User-Agent": USER_AGENT, + Origin: BASE_URL, + Referer: chatId ? `${BASE_URL}/c/${chatId}` : `${BASE_URL}/`, + source: "web", + "x-request-id": uuid(), + "bx-v": BX_VERSION, + "bx-umidtoken": BX_UMIDTOKEN_FALLBACK, + }; + if (token) headers["Authorization"] = `Bearer ${token}`; + if (cookieHeader) headers["Cookie"] = cookieHeader; + return headers; + } + async execute(input: ExecuteInput) { const { body, credentials, signal, stream: wantStream } = input; const bodyObj = (body || {}) as Record; - const rawToken = String(credentials?.apiKey ?? credentials?.accessToken ?? "").trim(); + + const rawCred = String(credentials?.apiKey ?? "").trim(); + const cookieHeader = buildQwenCookieHeader(rawCred); + let token = extractQwenToken(rawCred); + if (!token && credentials?.accessToken) token = String(credentials.accessToken).trim(); const messages = (bodyObj.messages as Array<{ role: string; content: string }>) || []; - const modelId = (bodyObj.model as string) || "qwen-plus"; + const requestedModel = (bodyObj.model as string) || DEFAULT_MODEL; + const modelId = mapModel(requestedModel); const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(bodyObj, messages); - const reqBody = { - messages: effectiveMessages.map((m) => ({ role: m.role, content: String(m.content ?? "") })), - model: modelId, - stream: wantStream, - max_tokens: (bodyObj.max_tokens as number) || 4096, - }; + // Qwen Web is single-turn: fold the conversation into one user prompt. + const prompt = this.foldMessages(effectiveMessages); - const reqHeaders: Record = { - "Content-Type": "application/json", - "User-Agent": USER_AGENT, - Accept: wantStream ? "text/event-stream" : "application/json", - Referer: `${BASE_URL}/`, - Origin: BASE_URL, - }; - if (rawToken) { - reqHeaders["Authorization"] = `Bearer ${rawToken}`; + // ── Step 1: create a chat ──────────────────────────────────────────────── + let chatId: string; + try { + const newChatRes = await fetch(CHATS_NEW_URL, { + method: "POST", + headers: this.buildHeaders(token, cookieHeader), + body: JSON.stringify({ + title: "New Chat", + models: [modelId], + chat_mode: "normal", + chat_type: "t2t", + timestamp: Date.now(), + }), + signal, + }); + + const ct = newChatRes.headers.get("content-type") || ""; + if (!newChatRes.ok || ct.includes("text/html")) { + const text = await newChatRes.text().catch(() => ""); + if (isWafResponse(newChatRes.status, ct, text)) { + return makeErrorResult(401, WAF_ERROR_MESSAGE, body, CHATS_NEW_URL); + } + return makeErrorResult( + newChatRes.status || 502, + `Qwen create-chat failed: ${text.slice(0, 300)}`, + body, + CHATS_NEW_URL + ); + } + + const data = (await newChatRes.json()) as { data?: { id?: string } }; + chatId = data?.data?.id ?? ""; + if (!chatId) { + return makeErrorResult(502, "Qwen create-chat returned no chat id", body, CHATS_NEW_URL); + } + } catch (err) { + return makeErrorResult( + 502, + `Qwen create-chat error: ${err instanceof Error ? err.message : "unknown"}`, + body, + CHATS_NEW_URL + ); } + // ── Step 2: send the message ───────────────────────────────────────────── + const completionUrl = `${CHAT_COMPLETIONS_URL}?chat_id=${chatId}`; + const msgPayload = this.buildMessagePayload(chatId, modelId, prompt, requestedModel); + let upstream: Response; try { - upstream = await fetch(CHAT_URL, { + upstream = await fetch(completionUrl, { method: "POST", - headers: reqHeaders, - body: JSON.stringify(reqBody), + headers: this.buildHeaders(token, cookieHeader, chatId), + body: JSON.stringify(msgPayload), signal, }); } catch (err) { return makeErrorResult( 502, - `Qwen fetch failed: ${err instanceof Error ? err.message : "unknown"}`, + `Qwen completion fetch failed: ${err instanceof Error ? err.message : "unknown"}`, body, - CHAT_URL + completionUrl ); } - if (!upstream.ok) { + const ct = upstream.headers.get("content-type") || ""; + if (!upstream.ok || ct.includes("text/html")) { const errText = await upstream.text().catch(() => ""); - if (upstream.status === 401) { - return makeErrorResult( - 401, - "Qwen authentication failed. Your token may have expired. " + - "Get a fresh token from chat.qwen.ai (DevTools → Application → Local Storage → token)", - body, - CHAT_URL - ); + if (isWafResponse(upstream.status, ct, errText)) { + return makeErrorResult(401, WAF_ERROR_MESSAGE, body, completionUrl); } - return makeErrorResult(upstream.status, `Qwen error: ${errText}`, body, CHAT_URL); + return makeErrorResult( + upstream.status || 502, + `Qwen error: ${errText.slice(0, 300)}`, + body, + completionUrl + ); } if (!wantStream) { - const data = (await upstream.json()) as Record; - const rawContent = - (data?.choices as Array<{ message?: { content?: string } }>)?.[0]?.message?.content || - (data?.content as string) || - ""; + const { content } = await this.collectStream(upstream); + const finalText = content; if (hasTools) { - const { content, toolCalls, finishReason } = buildToolAwareResult(rawContent, requestedTools, "qwen"); - const message: Record = { role: "assistant", content }; - if (toolCalls) { message.tool_calls = toolCalls; message.content = null; } - return { - response: new Response( - JSON.stringify({ - id: `chatcmpl-qwen-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model: modelId, - choices: [{ index: 0, message, finish_reason: finishReason }], - }), - { headers: { "Content-Type": "application/json" } } - ), - url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, - }; + const { + content: toolContent, + toolCalls, + finishReason, + } = buildToolAwareResult(finalText, requestedTools, "qwen"); + const message: Record = { role: "assistant", content: toolContent }; + if (toolCalls) { + message.tool_calls = toolCalls; + message.content = null; + } + return this.jsonResponse(modelId, message, finishReason, completionUrl, msgPayload); } - return { - response: new Response( - JSON.stringify({ - id: `chatcmpl-qwen-${Date.now()}`, object: "chat.completion", - created: Math.floor(Date.now() / 1000), model: modelId, - choices: [{ index: 0, message: { role: "assistant", content: rawContent }, finish_reason: "stop" }], - }), - { headers: { "Content-Type": "application/json" } } - ), - url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, - }; + return this.jsonResponse( + modelId, + { role: "assistant", content: finalText }, + "stop", + completionUrl, + msgPayload + ); } - // Streaming + // Streaming: transform Qwen phase SSE → OpenAI chat.completion.chunk SSE. + const stream = this.buildClientStream(upstream, modelId, hasTools, requestedTools, signal); + return { + response: new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: completionUrl, + headers: this.buildHeaders(token, cookieHeader, chatId), + transformedBody: msgPayload, + }; + } + + private foldMessages(messages: Array<{ role: string; content: unknown }>): string { + let systemContent = ""; + let userContent = ""; + for (const m of messages) { + const text = String(m.content ?? ""); + if (m.role === "system") { + systemContent += (systemContent ? "\n\n" : "") + text; + } else if (m.role === "user") { + userContent = text; + } + } + return systemContent ? `${systemContent}\n\nUser: ${userContent}` : userContent; + } + + private buildMessagePayload( + chatId: string, + modelId: string, + prompt: string, + requestedModel: string + ): Record { + const fid = uuid(); + const enableThinking = /think|reason|r1/i.test(requestedModel); + const featureConfig: Record = { + thinking_enabled: enableThinking, + output_schema: "phase", + auto_thinking: enableThinking, + research_mode: "normal", + auto_search: false, + }; + return { + stream: true, + incremental_output: true, + chat_id: chatId, + chat_mode: "normal", + model: modelId, + parent_id: null, + messages: [ + { + fid, + parentId: null, + childrenIds: [], + role: "user", + content: prompt, + user_action: "chat", + files: [], + timestamp: Math.floor(Date.now() / 1000), + models: [modelId], + chat_type: "t2t", + feature_config: featureConfig, + sub_chat_type: "t2t", + parent_id: null, + }, + ], + }; + } + + /** Read the whole upstream SSE stream, returning the joined answer + reasoning. */ + private async collectStream(upstream: Response): Promise<{ content: string; reasoning: string }> { + const reader = upstream.body?.getReader(); + const decoder = new TextDecoder(); + let content = ""; + let reasoning = ""; + if (!reader) return { content, reasoning }; + + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const delta = parseSseDelta(line); + if (!delta) continue; + if (delta.kind === "answer") content += delta.text; + else if (delta.kind === "think") reasoning += delta.text; + } + } + } catch { + /* upstream closed mid-stream — return what we have */ + } + return { content, reasoning }; + } + + /** Transform the Qwen phase SSE into OpenAI chat.completion.chunk SSE. */ + private buildClientStream( + upstream: Response, + modelId: string, + hasTools: boolean, + requestedTools: unknown, + signal: AbortSignal | null | undefined + ): ReadableStream { const encoder = new TextEncoder(); const decoder = new TextDecoder(); + const id = `chatcmpl-qwen-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const emitChunk = (delta: Record, finishReason: string | null) => + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model: modelId, + choices: [{ index: 0, delta, finish_reason: finishReason }], + })}\n\n`; - if (hasTools) { - let fullContent = ""; - const reader = upstream.body?.getReader(); - if (reader) { - let buf = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - for (const line of buf.split("\n")) { - if (!line.startsWith("data:")) continue; - const d = line.slice(5).trim(); - if (d === "[DONE]") continue; - try { fullContent += JSON.parse(d).choices?.[0]?.delta?.content || ""; } catch {} - } - buf = buf.split("\n").pop() || ""; - } - } catch {} - } - - const { content, toolCalls, finishReason } = buildToolAwareResult(fullContent, requestedTools, "qwen"); - const stream = new ReadableStream({ - start(controller) { - const id = `chatcmpl-qwen-${Date.now()}`; - const created = Math.floor(Date.now() / 1000); - const delta = toolCalls - ? { role: "assistant", content: null, tool_calls: toolCalls } - : { role: "assistant", content }; - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: modelId, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`)); - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created, model: modelId, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] })}\n\n`)); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); - }, - }); - return { - response: new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" } }), - url: CHAT_URL, headers: reqHeaders, transformedBody: reqBody, - }; - } - - const stream = new ReadableStream({ + return new ReadableStream({ async start(controller) { const reader = upstream.body?.getReader(); - if (!reader) { controller.close(); return; } + if (!reader) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + return; + } let buffer = ""; + let fullContent = ""; + controller.enqueue(encoder.encode(emitChunk({ role: "assistant", content: "" }, null))); try { while (true) { const { done, value } = await reader.read(); @@ -176,43 +370,95 @@ export class QwenWebExecutor extends BaseExecutor { const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { - if (!line.startsWith("data:")) continue; - const data = line.slice(5).trim(); - if (data === "[DONE]") { controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } - try { - const parsed = JSON.parse(data); - const text = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.text || ""; - if (text) { - const chunk = { - id: `chatcmpl-qwen-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: modelId, - choices: [{ index: 0, delta: { content: text }, finish_reason: null }], - }; - controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + const delta = parseSseDelta(line); + if (!delta || !delta.text) continue; + if (delta.kind === "answer") { + fullContent += delta.text; + if (!hasTools) { + controller.enqueue(encoder.encode(emitChunk({ content: delta.text }, null))); } - } catch { - /* skip unparseable chunks */ + } else if (delta.kind === "think" && !hasTools) { + controller.enqueue( + encoder.encode(emitChunk({ reasoning_content: delta.text }, null)) + ); } } } } catch (err) { - if (!signal?.aborted) controller.error(err); - } finally { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - controller.close(); + if (!signal?.aborted) { + controller.error(err); + return; + } } + + if (hasTools) { + const { content, toolCalls, finishReason } = buildToolAwareResult( + fullContent, + requestedTools, + "qwen" + ); + const delta = toolCalls + ? { role: "assistant", content: null, tool_calls: toolCalls } + : { role: "assistant", content }; + controller.enqueue(encoder.encode(emitChunk(delta, null))); + controller.enqueue(encoder.encode(emitChunk({}, finishReason))); + } else { + controller.enqueue(encoder.encode(emitChunk({}, "stop"))); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); }, }); + } + private jsonResponse( + modelId: string, + message: Record, + finishReason: string, + url: string, + transformedBody: unknown + ) { return { - response: new Response(stream, { - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, - }), - url: CHAT_URL, - headers: reqHeaders, - transformedBody: reqBody, + response: new Response( + JSON.stringify({ + id: `chatcmpl-qwen-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [{ index: 0, message, finish_reason: finishReason }], + }), + { headers: { "Content-Type": "application/json" } } + ), + url, + headers: {} as Record, + transformedBody, }; } } + +/** Parse one SSE line into a typed delta, or null if it carries no content. */ +function parseSseDelta(line: string): { kind: "answer" | "think"; text: string } | null { + if (!line.startsWith("data:")) return null; + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") return null; + let parsed: { + choices?: Array<{ delta?: { phase?: string | null; content?: unknown } }>; + }; + try { + parsed = JSON.parse(payload); + } catch { + return null; + } + const delta = parsed?.choices?.[0]?.delta; + if (!delta) return null; + const phase = delta.phase; + const content = typeof delta.content === "string" ? delta.content : ""; + if (phase === "think" || phase === "thinking_summary") { + return { kind: "think", text: content }; + } + // `answer` phase or a null/absent phase both carry assistant content. + if (phase === "answer" || phase === null || phase === undefined) { + return { kind: "answer", text: content }; + } + return null; +} diff --git a/open-sse/executors/vertex.ts b/open-sse/executors/vertex.ts index 7412fa24b9..02994026d0 100644 --- a/open-sse/executors/vertex.ts +++ b/open-sse/executors/vertex.ts @@ -21,6 +21,28 @@ export function parseSAFromApiKey(apiKey: string): ServiceAccount { } } +/** + * A Service Account credential is a JSON object (type/client_email/private_key). A Vertex AI + * Express-mode API key is an opaque non-JSON string. Distinguishing them lets the executor + * support BOTH: Service Account JSON (JWT → OAuth → project-scoped endpoint + Bearer auth) and + * Express keys (project-less publisher endpoint + x-goog-api-key auth), instead of failing every + * Express key with "requires a valid Service Account JSON". + */ +export function looksLikeServiceAccountJson(apiKey: string): boolean { + if (!apiKey || typeof apiKey !== "string") return false; + try { + const parsed = JSON.parse(apiKey); + return !!parsed && typeof parsed === "object" && !Array.isArray(parsed); + } catch { + return false; + } +} + +/** True for a Vertex AI Express-mode API key (a non-empty, non-JSON, non-OAuth credential). */ +export function isExpressApiKey(apiKey?: string | null): boolean { + return typeof apiKey === "string" && apiKey.trim().length > 0 && !looksLikeServiceAccountJson(apiKey); +} + export async function getAccessToken(sa: ServiceAccount): Promise { if (!sa.client_email || !sa.private_key) { throw new Error( @@ -110,7 +132,13 @@ export class VertexExecutor extends BaseExecutor { async execute(input: ExecuteInput) { const { credentials, log } = input; - if (credentials.apiKey && !credentials.accessToken) { + // Defensive: trim stray surrounding whitespace from a pasted credential. + if (typeof credentials.apiKey === "string") { + credentials.apiKey = credentials.apiKey.trim(); + } + // Service Account JSON → mint a short-lived OAuth token (Bearer). An Express-mode API key is + // sent as-is via x-goog-api-key (see buildHeaders), so no token exchange is needed for it. + if (credentials.apiKey && !credentials.accessToken && looksLikeServiceAccountJson(credentials.apiKey)) { try { const sa = parseSAFromApiKey(credentials.apiKey); credentials.accessToken = await getAccessToken(sa); @@ -123,6 +151,19 @@ export class VertexExecutor extends BaseExecutor { } buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + // Vertex AI Express mode: project-less v1 publisher endpoint with the API key passed as a + // ?key= query parameter (verified working contract — same as the CaptionAI GeminiClient). The + // Express key is NOT accepted as a Bearer/OAuth credential or via x-goog-api-key on this API. + if (isExpressApiKey(credentials?.apiKey) && !credentials?.accessToken) { + const expressKey = encodeURIComponent(String(credentials.apiKey).trim()); + if (isPartnerModel(model)) { + // Partner (Anthropic/etc.) models are not available via Express keys; best-effort. + return `https://aiplatform.googleapis.com/v1/publishers/openapi/chat/completions?key=${expressKey}`; + } + const op = stream ? "streamGenerateContent?alt=sse&" : "generateContent?"; + return `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${op}key=${expressKey}`; + } + const region = credentials?.providerSpecificData?.region || "us-central1"; let project = "unknown-project"; @@ -146,6 +187,7 @@ export class VertexExecutor extends BaseExecutor { if (credentials.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } + // Express-mode keys are carried in the ?key= query parameter (see buildUrl), not a header. if (stream) { headers["Accept"] = "text/event-stream"; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5198a21a7d..c2bf1943f3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -203,11 +203,6 @@ import { getDegradedModel, getBackgroundDegradationConfig, } from "../services/backgroundTaskDetector.ts"; -import { - shouldUseFallback, - isFallbackDecision, - EMERGENCY_FALLBACK_CONFIG, -} from "../services/emergencyFallback.ts"; import type { CompressionConfig } from "../services/compression/types.ts"; import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts"; import { @@ -233,6 +228,7 @@ import { getModelScopeRetryDelayMs, isModelScopeProvider, } from "../services/modelscopePolicy.ts"; +import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts"; const MEMORY_EXTRACTION_TEXT_LIMIT = 64 * 1024; @@ -1551,7 +1547,6 @@ export async function handleChatCore({ isCombo = false, comboStepId = null, comboExecutionKey = null, - disableEmergencyFallback = false, cachedSettings = null, skipUpstreamRetry = false, createPiiTransform = null, @@ -1986,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(); @@ -2296,6 +2292,7 @@ export async function handleChatCore({ startTime, log, persistAttemptLogs, + apiKeyId: apiKeyInfo?.id ?? undefined, }); if (cacheHit) { return cacheHit; @@ -2769,7 +2766,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); } @@ -3104,6 +3104,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. @@ -3787,6 +3793,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", }); @@ -4756,85 +4768,20 @@ export async function handleChatCore({ }); persistFailureUsage(statusCode, `upstream_${statusCode}`); - const requestHasTools = - Array.isArray(translatedBody.tools) && translatedBody.tools.length > 0; - let emergencyFallbackServed = false; - - if (!disableEmergencyFallback && !stream) { - const fbDecision = shouldUseFallback( - statusCode, - message, - requestHasTools, - EMERGENCY_FALLBACK_CONFIG - ); - if (isFallbackDecision(fbDecision)) { - log?.info?.("EMERGENCY_FALLBACK", fbDecision.reason); - try { - const originalProvider = provider; - const fbExecutor = getExecutor(fbDecision.provider); - const fbResult = await fbExecutor.execute({ - model: fbDecision.model, - body: { - ...translatedBody, - model: fbDecision.model, - max_tokens: Math.min( - typeof translatedBody.max_tokens === "number" - ? translatedBody.max_tokens - : fbDecision.maxOutputTokens, - fbDecision.maxOutputTokens - ), - max_completion_tokens: Math.min( - typeof translatedBody.max_completion_tokens === "number" - ? translatedBody.max_completion_tokens - : typeof translatedBody.max_tokens === "number" - ? translatedBody.max_tokens - : fbDecision.maxOutputTokens, - fbDecision.maxOutputTokens - ), - }, - stream: false, - credentials: credentials, - signal: streamController.signal, - log, - extendedContext, - }); - if (fbResult.response.ok) { - provider = fbDecision.provider; - model = fbDecision.model; - translatedBody.model = fbDecision.model; - providerResponse = fbResult.response; - providerUrl = fbResult.url; - providerHeaders = new Headers(fbResult.headers || {}); - finalBody = fbResult.transformedBody; - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - log?.info?.( - "EMERGENCY_FALLBACK", - `Serving ${fbDecision.provider}/${fbDecision.model} as budget fallback for ${originalProvider}/${requestedModel}` - ); - emergencyFallbackServed = true; - } else { - log?.warn?.( - "EMERGENCY_FALLBACK", - `Emergency fallback also failed (${fbResult.response.status})` - ); - } - } catch (fbErr) { - const errMessage = fbErr instanceof Error ? fbErr.message : String(fbErr); - log?.warn?.("EMERGENCY_FALLBACK", `Emergency fallback error: ${errMessage}`); - } - } - } - - if (!emergencyFallbackServed) { - return createErrorResult( - statusCode, - errMsg, - retryAfterMs, - upstreamErrorCode, - upstreamErrorType, - upstreamErrorBody - ); - } + // Emergency budget fallback is orchestrated exclusively by the routing layer + // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency + // provider through account selection. The executor-level hop that used to + // live here re-sent the FAILING provider's credentials to the emergency + // provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com) + // — a cross-provider credential leak that also never succeeded upstream. + return createErrorResult( + statusCode, + errMsg, + retryAfterMs, + upstreamErrorCode, + upstreamErrorType, + upstreamErrorBody + ); } // ── End T5 ─────────────────────────────────────────────────────────────── } @@ -5240,7 +5187,8 @@ export async function handleChatCore({ model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyInfo?.id ?? undefined ); const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0; setCachedResponse(signature, model, translatedResponse, tokensSaved); @@ -5442,17 +5390,14 @@ export async function handleChatCore({ } const responseHeaders: Record = { - ...buildStreamingResponseHeaders( - providerResponse.headers, - { - provider, - model, - cacheHit: false, - latencyMs: 0, - usage: null, - costUsd: 0, - } - ), + ...buildStreamingResponseHeaders(providerResponse.headers, { + provider, + model, + cacheHit: false, + latencyMs: 0, + usage: null, + costUsd: 0, + }), "x-omniroute-request-id": pendingRequestId, }; @@ -5560,7 +5505,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) { @@ -5635,7 +5582,8 @@ export async function handleChatCore({ model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyInfo?.id ?? undefined ); const u = streamUsage as Record | null; const tokensSaved = diff --git a/open-sse/handlers/chatCore/semanticCache.ts b/open-sse/handlers/chatCore/semanticCache.ts index 9ce133c1a2..6e99989e02 100644 --- a/open-sse/handlers/chatCore/semanticCache.ts +++ b/open-sse/handlers/chatCore/semanticCache.ts @@ -23,6 +23,7 @@ export async function checkSemanticCache({ startTime, log, persistAttemptLogs, + apiKeyId, }: { semanticCacheEnabled: boolean; body: Record; @@ -36,13 +37,15 @@ export async function checkSemanticCache({ startTime: number; log: unknown; persistAttemptLogs: (args: unknown) => void; + apiKeyId?: string | null; }) { if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { const signature = generateSignature( model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyId ?? undefined ); const cached = getCachedResponse(signature); if (cached) { diff --git a/open-sse/package.json b/open-sse/package.json index 88578a28bd..b9b9f1fd17 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.8.22", + "version": "3.8.23", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index ad0b2a344e..729ee8b433 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -27,7 +27,9 @@ import { looksLikeQuotaExhausted, type FailureKind, } from "../../src/shared/utils/classify429"; +import { resolveProviderId } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; +import { isRpdExhausted, isRpmExhausted } from "./geminiRateLimitTracker.ts"; export type ProviderProfile = { baseCooldownMs: number; @@ -65,6 +67,9 @@ type ModelFailureState = { failureCount: number; lastFailureAt: number; resetAfterMs: number; + /** Cooldown applied on the last failure — extends the escalation window so a + * model that fails again right after its lockout expires keeps escalating. */ + lastCooldownMs?: number; }; type AccountState = JsonRecord & { id?: string | null; @@ -150,9 +155,6 @@ export const CREDITS_EXHAUSTED_SIGNALS = [ "credits exhausted", "out of credits", "payment required", - "resource has been exhausted", - "resource_exhausted", - "check quota", "free tier of the model has been exhausted", ]; @@ -350,8 +352,20 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi const modelLockouts = new Map(); const modelFailureState = new Map(); +// Aliases (e.g. "cx" → "codex") must share lockout state with their canonical +// provider, otherwise a model locked via one spelling stays routable via the other. +const canonicalProviderCache = new Map(); +function getCanonicalLockProvider(provider: string): string { + let canonical = canonicalProviderCache.get(provider); + if (!canonical) { + canonical = resolveProviderId(provider); + canonicalProviderCache.set(provider, canonical); + } + return canonical; +} + function getModelLockKey(provider: string, connectionId: string, model: string) { - return `${provider}:${connectionId}:${model}`; + return `${getCanonicalLockProvider(provider)}:${connectionId}:${model}`; } function getFailureWindowMs(profile: ProviderProfile | null = null, fallbackMs = 30 * 60 * 1000) { @@ -367,7 +381,9 @@ function cleanupModelLockKey(key: string, now = Date.now()) { const failure = modelFailureState.get(key); if (!failure) return; - if (now - failure.lastFailureAt <= failure.resetAfterMs) return; + // The escalation window extends past the applied cooldown: a model that fails + // again right after its lockout expires must keep escalating, not reset to 1. + if (now - failure.lastFailureAt <= failure.resetAfterMs + (failure.lastCooldownMs ?? 0)) return; if (modelLockouts.has(key)) return; modelFailureState.delete(key); } @@ -468,7 +484,7 @@ export function recordModelLockoutFailure( status: number, fallbackCooldownMs: number, profile: ProviderProfile | null = null, - options: { exactCooldownMs?: number | null } = {} + options: { exactCooldownMs?: number | null; maxCooldownMs?: number } = {} ) { ensureCleanupTimer(); const key = getModelLockKey(provider, connectionId, model); @@ -483,24 +499,39 @@ export function recordModelLockoutFailure( const resetAfterMs = getFailureWindowMs(profile); const previous = modelFailureState.get(key); - const withinWindow = previous && now - previous.lastFailureAt <= previous.resetAfterMs; + // Escalation window extends past the previously applied cooldown so a model + // that fails again right after its lockout expires keeps escalating. + const withinWindow = + previous && + now - previous.lastFailureAt <= previous.resetAfterMs + (previous.lastCooldownMs ?? 0); const failureCount = withinWindow ? previous.failureCount + 1 : 1; + + const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile); + // Cap exponential backoff so repeated failures cannot produce absurdly long + // lockouts; exact cooldowns (e.g. daily-quota until-midnight) are not capped. + const maxCooldownMs = + typeof options.maxCooldownMs === "number" && options.maxCooldownMs > 0 + ? options.maxCooldownMs + : BACKOFF_CONFIG.max; + const cooldownMs = + typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0 + ? options.exactCooldownMs + : Math.min( + getScaledCooldown( + baseCooldownMs, + failureCount, + profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel + ), + maxCooldownMs + ); + modelFailureState.set(key, { failureCount, lastFailureAt: now, resetAfterMs, + lastCooldownMs: cooldownMs, }); - const baseCooldownMs = getModelLockBaseCooldown(status, fallbackCooldownMs, profile); - const cooldownMs = - typeof options.exactCooldownMs === "number" && options.exactCooldownMs > 0 - ? options.exactCooldownMs - : getScaledCooldown( - baseCooldownMs, - failureCount, - profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel - ); - lockModel(provider, connectionId, model, reason, cooldownMs, { failureCount, lastFailureAt: now, @@ -590,6 +621,36 @@ export function shouldMarkAccountExhaustedFrom429( ); } +export function classifyLockoutReason(status: number): string { + if (status === 429) return "rate_limit"; + if (status === 403) return "quota_exhausted"; + return "unknown"; +} + +export type DecayResult = { cleared: boolean; newFailureCount: number }; + +export function decayModelFailureCount( + provider: string, + connectionId: string, + model: string +): DecayResult { + const key = getModelLockKey(provider, connectionId, model); + const failure = modelFailureState.get(key); + if (!failure) return { cleared: false, newFailureCount: 0 }; + + const newFailureCount = Math.floor(failure.failureCount / 2); + if (newFailureCount === 0) { + modelFailureState.delete(key); + return { cleared: true, newFailureCount: 0 }; + } else { + modelFailureState.set(key, { + ...failure, + failureCount: newFailureCount, + }); + return { cleared: false, newFailureCount }; + } +} + /** * Clear all in-memory model lockouts and failure state (for tests / full reset). */ @@ -933,8 +994,9 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { // 2026-05-17T10:00:00Z" or "Please wait until 2026-05-17T10:00:00.000Z"). // Convert to a future-duration in milliseconds if it parses. const isoMatch = - /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i - .exec(msg); + /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec( + msg + ); if (isoMatch) { const parsedTs = Date.parse(isoMatch[1]); if (Number.isFinite(parsedTs)) { @@ -1021,10 +1083,8 @@ export function classifyErrorText(errorText: unknown): RateLimitReasonValue { const configuredRule = matchErrorRuleByText(errorText); if (configuredRule?.reason) return configuredRule.reason; if (lower.includes("rate_limit")) return RateLimitReason.RATE_LIMIT_EXCEEDED; - if ( - lower.includes("resource exhausted") || - lower.includes("high demand") - ) return RateLimitReason.MODEL_CAPACITY; + if (lower.includes("resource exhausted") || lower.includes("high demand")) + return RateLimitReason.MODEL_CAPACITY; if ( lower.includes("unauthorized") || lower.includes("invalid api key") || @@ -1414,6 +1474,19 @@ export function checkFallbackError( } } + // Gemini-specific: use known published RPM/RPD limits to distinguish 429 types. + // Gemini returns the same error body for both, so we use per-model request + // counters to decide: if daily count >= RPD → quota_exhausted (midnight lockout); + // if minute count >= RPM → rate_limit_exceeded (exponential backoff). + if (provider === "gemini" && status === HTTP_STATUS.RATE_LIMITED && _model) { + if (isRpdExhausted(_model)) { + return buildRetryableFallback(RateLimitReason.QUOTA_EXHAUSTED); + } + if (isRpmExhausted(_model)) { + return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED); + } + } + const configuredRule = isRateLimitStatus && !preserveQuota429 ? matchErrorRuleByStatus(status) diff --git a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts index 7d1f9beec8..e2636f6e52 100644 --- a/open-sse/services/autoCombo/__tests__/autoCombo.test.ts +++ b/open-sse/services/autoCombo/__tests__/autoCombo.test.ts @@ -2,10 +2,10 @@ * Unit tests for Auto-Combo Engine (Phase 5) */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { calculateFactors, calculateScore, DEFAULT_WEIGHTS, validateWeights } from "../scoring"; import type { ProviderCandidate, ScoringWeights } from "../scoring"; -import { getTaskFitness, getTaskTypes } from "../taskFitness"; +import { getTaskFitness, getTaskFitnessWithSource, getTaskTypes, getModelsDevTierFitness, invalidateFitnessCache } from "../taskFitness"; import { SelfHealingManager } from "../selfHealing"; import { MODE_PACKS, getModePack, getModePackNames } from "../modePacks"; import { getStrategy } from "../routerStrategy"; @@ -410,3 +410,164 @@ describe("LKGP Strategy", () => { expect(result.provider).toBe("openai"); }); }); + +describe("Task Fitness Resolution Chain", () => { + it("getTaskFitness should return static table score for known models", () => { + const score = getTaskFitness("claude-sonnet", "coding"); + expect(score).toBe(0.95); + }); + + it("getTaskFitness should return 0.5 for unknown models with no wildcard match", () => { + const score = getTaskFitness("unknown-model-xyz", "coding"); + expect(score).toBe(0.5); + }); + + it("getTaskFitness should apply wildcard boosts for model name patterns", () => { + const score = getTaskFitness("deepseek-coder-v2", "coding"); + expect(score).toBeGreaterThan(0.5); + }); + + it("getTaskFitness should apply thinking wildcard for planning tasks", () => { + const score = getTaskFitness("some-thinking-model", "planning"); + expect(score).toBeGreaterThan(0.5); + }); + + it("getTaskFitnessWithSource should return source='fitness_table' for known static models", () => { + const result = getTaskFitnessWithSource("claude-sonnet", "coding"); + expect(result).toEqual({ score: 0.95, source: "fitness_table" }); + }); + + it("getTaskFitnessWithSource should return source='wildcard_boost' for wildcard-matched models", () => { + const result = getTaskFitnessWithSource("fast-model", "coding"); + expect(result).toEqual({ score: expect.any(Number), source: "wildcard_boost" }); + }); + + it("getTaskTypes should return task types without 'default'", () => { + const types = getTaskTypes(); + expect(types).toContain("coding"); + expect(types).toContain("review"); + expect(types).toContain("planning"); + expect(types).not.toContain("default"); + }); + + it("unknown models should return 0.5 (default) when no DB or static entry exists", () => { + const score = getTaskFitness("completely-unknown-model-xyz-999", "coding"); + expect(score).toBe(0.5); + }); + + it("wildcard boosts still work for models containing 'coder'", () => { + const score = getTaskFitness("my-coder-pro", "coding"); + // Base 0.5 + coder boost 0.15 + code boost 0.1 = 0.75 + // "coder" contains "code", so both wildcard patterns match + expect(score).toBe(0.75); + }); + + it("wildcard boosts still work for models containing 'thinking'", () => { + const score = getTaskFitness("my-thinking-model", "planning"); + // Base 0.5 + thinking boost 0.1 = 0.6 + expect(score).toBe(0.6); + }); + + it("wildcard boosts still work for models containing 'thinking' for analysis tasks", () => { + const score = getTaskFitness("my-thinking-model", "analysis"); + // Base 0.5 + thinking boost 0.1 = 0.6 + expect(score).toBe(0.6); + }); + + it("wildcard boosts for 'code' pattern apply to coding tasks", () => { + const score = getTaskFitness("my-code-generator", "coding"); + // Base 0.5 + code boost 0.1 = 0.6 + expect(score).toBe(0.6); + }); + + it("wildcard boosts for 'fast' pattern apply to coding tasks", () => { + const score = getTaskFitness("my-fast-model", "coding"); + // Base 0.5 + fast boost 0.05 = 0.55 + expect(score).toBe(0.55); + }); + + it("getTaskFitnessWithSource returns 'wildcard_boost' for pattern-matched unknown models", () => { + const result = getTaskFitnessWithSource("my-coder-pro", "coding"); + expect(result.source).toBe("wildcard_boost"); + expect(result.score).toBeGreaterThan(0.5); + }); + + it("getTaskFitnessWithSource returns 'fitness_table' for statically known models", () => { + const result = getTaskFitnessWithSource("claude-sonnet", "review"); + expect(result.source).toBe("fitness_table"); + expect(result.score).toBe(0.92); + }); + + it("getTaskFitnessWithSource returns 'wildcard_boost' with 0.5 for unknown models with no pattern", () => { + const result = getTaskFitnessWithSource("totally-random-xyz", "coding"); + expect(result.source).toBe("wildcard_boost"); + expect(result.score).toBe(0.5); + }); +}); + +describe("Task Fitness DB Resolution Chain", () => { + // These tests verify that when DB is available, the resolution chain + // (user_override → arena_elo → models_dev_tier → static → wildcard) + // works correctly. Since the DB module is loaded lazily via require(), + // these tests cover the cases where DB is NOT available (graceful fallback). + + it("falls back to static FITNESS_TABLE when DB is not initialized", () => { + // In the test environment, DB is typically not initialized, + // so getTaskFitness should fall through to the static table + const score = getTaskFitness("claude-sonnet", "coding"); + // Static table has claude-sonnet → 0.95 for coding + expect(score).toBe(0.95); + }); + + it("falls back to static FITNESS_TABLE for review task type", () => { + const score = getTaskFitness("claude-opus", "review"); + // Static table has claude-opus → 0.95 for review + expect(score).toBe(0.95); + }); + + it("falls back to wildcard boosts when no static entry exists and DB unavailable", () => { + // "coder-unknown" has no static entry but matches "coder" wildcard + const score = getTaskFitness("coder-unknown", "coding"); + expect(score).toBeGreaterThan(0.5); + expect(score).toBeLessThanOrEqual(1.0); + }); + + it("getModelsDevTierFitness returns null when DB is not initialized", () => { + // Without a running DB, this should return null gracefully + const score = getModelsDevTierFitness("claude-sonnet", "coding"); + // Either null (no capabilities data) or a number from DB if DB happens to be up + if (score !== null) { + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(1); + } + }); + + it("invalidateFitnessCache does not throw", () => { + expect(() => invalidateFitnessCache()).not.toThrow(); + }); + + it("resolution chain: static table takes priority over wildcard for known models", () => { + // "claude-sonnet" is in the static table with coding=0.95 + // It does NOT match "coder" wildcard because the static table is checked first + const score = getTaskFitness("claude-sonnet", "coding"); + expect(score).toBe(0.95); // From static table, NOT wildcard + }); + + it("getTaskFitnessWithSource identifies fitness_table as source for known models", () => { + const result = getTaskFitnessWithSource("gpt-4o", "coding"); + expect(result.source).toBe("fitness_table"); + expect(result.score).toBe(0.9); + }); + + it("case insensitivity: model names are lowercased before lookup", () => { + const upperScore = getTaskFitness("CLAUDE-SONNET", "coding"); + const lowerScore = getTaskFitness("claude-sonnet", "coding"); + expect(upperScore).toBe(lowerScore); + }); + + it("case insensitivity: task types are lowercased before lookup", () => { + const upperScore = getTaskFitness("claude-sonnet", "CODING"); + const lowerScore = getTaskFitness("claude-sonnet", "coding"); + expect(upperScore).toBe(lowerScore); + }); +}); diff --git a/open-sse/services/autoCombo/taskFitness.ts b/open-sse/services/autoCombo/taskFitness.ts index 123d479b1c..404a476089 100644 --- a/open-sse/services/autoCombo/taskFitness.ts +++ b/open-sse/services/autoCombo/taskFitness.ts @@ -3,8 +3,24 @@ * * Maps model patterns × task types → fitness score [0..1]. * Supports wildcards and prefix matching. + * + * Resolution chain (highest → lowest priority): + * 1. User override — DB `model_intelligence` where source='user_override' + * 2. Arena ELO — DB `model_intelligence` where source='arena_elo' + * 3. Models.dev tier — derived from `model_capabilities` table capability data + * 4. Static FITNESS_TABLE — existing hardcoded lookup (current behavior) + * 5. Wildcard boosts — existing pattern matching boosts (current behavior) */ +// ─── Static fitness table (unchanged, fallback layer 4) ───────────────── + +import { getDbInstance } from "../../../src/lib/db/core.ts"; +import { + getModelIntelligenceBySource, + setUserFitnessOverrideEntry, + deleteUserFitnessOverrideEntry, +} from "../../../src/lib/db/modelIntelligence.ts"; + const FITNESS_TABLE: Record> = { coding: { "claude-sonnet": 0.95, @@ -131,34 +147,274 @@ const WILDCARD_BOOSTS: Array<{ pattern: string; taskType: string; boost: number { pattern: "thinking", taskType: "analysis", boost: 0.1 }, ]; +// ─── Models.dev tier → task fitness mapping (resolution layer 3) ──────── + /** - * Get task fitness score for a model × taskType combination. - * Returns 0.5 (neutral) if no mapping found. + * Intelligence tier derived from models.dev capability data. + * Tier assignment rules: + * - `reasoning === true` → "premium" + * - `tool_call === true && context >= 128000` → "standard" + * - `tool_call === true` → "fast" + * - everything else → "budget" */ -export function getTaskFitness(model: string, taskType: string): number { +const TIER_TASK_FITNESS: Record> = { + premium: { + coding: 0.92, + review: 0.93, + planning: 0.94, + analysis: 0.95, + debugging: 0.9, + documentation: 0.88, + default: 0.85, + }, + standard: { + coding: 0.85, + review: 0.84, + planning: 0.85, + analysis: 0.85, + debugging: 0.82, + documentation: 0.85, + default: 0.78, + }, + fast: { + coding: 0.78, + review: 0.72, + planning: 0.7, + analysis: 0.72, + debugging: 0.75, + documentation: 0.8, + default: 0.72, + }, + budget: { + coding: 0.65, + review: 0.6, + planning: 0.55, + analysis: 0.58, + debugging: 0.6, + documentation: 0.7, + default: 0.55, + }, +}; +// ─── DB access helpers ────────────────────────────────────────────────── + +const _intelligenceCache = new Map(); + +function queryModelIntelligence( + model: string, + category: string, + source: string, +): number | null { + const cacheKey = `${model}:${category}:${source}`; + if (_intelligenceCache.has(cacheKey)) { + return _intelligenceCache.get(cacheKey)!; + } + + try { + const entry = getModelIntelligenceBySource(model, source, category); + if (entry) { + _intelligenceCache.set(cacheKey, entry.score); + return entry.score; + } + return null; + } catch { + return null; + } +} + +// ─── Models.dev capability → tier → fitness resolution ────────────────── + +let _capabilitiesCache: Record | null = null; + +interface ModelCapRow { + tool_call: boolean | null; + reasoning: boolean | null; + limit_context: number | null; +} + +function deriveTierFromCapabilities(cap: ModelCapRow): string { + if (cap.reasoning === true) return "premium"; + if (cap.tool_call === true && (cap.limit_context ?? 0) >= 128000) + return "standard"; + if (cap.tool_call === true) return "fast"; + return "budget"; +} + +function loadModelCapabilities(): Record | null { + if (_capabilitiesCache) return _capabilitiesCache; + + try { + const db = getDbInstance(); + const rows = db.prepare("SELECT * FROM model_capabilities").all() as Record< + string, + unknown + >[]; + const cache: Record = {}; + + for (const row of rows) { + const modelId = typeof row.model_id === "string" ? row.model_id : ""; + if (!modelId) continue; + + cache[modelId.toLowerCase()] = { + tool_call: + row.tool_call === true || row.tool_call === 1 + ? true + : row.tool_call === false || row.tool_call === 0 + ? false + : null, + reasoning: + row.reasoning === true || row.reasoning === 1 + ? true + : row.reasoning === false || row.reasoning === 0 + ? false + : null, + limit_context: + typeof row.limit_context === "number" ? row.limit_context : null, + }; + } + + _capabilitiesCache = cache; + return cache; + } catch { + return null; + } +} + +export function getModelsDevTierFitness( + model: string, + taskType: string, +): number | null { const normalizedModel = model.toLowerCase(); const normalizedTask = taskType.toLowerCase(); - const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default; - // Direct match + const dbScore = queryModelIntelligence( + normalizedModel, + normalizedTask, + "models_dev_tier", + ); + if (dbScore !== null) return dbScore; + + const caps = loadModelCapabilities(); + if (!caps) return null; + + const capRow = caps[normalizedModel]; + if (!capRow) return null; + + const tier = deriveTierFromCapabilities(capRow); + const tierScores = TIER_TASK_FITNESS[tier]; + if (!tierScores) return null; + + return tierScores[normalizedTask] ?? tierScores.default ?? null; +} + +// ─── Resolution chain ─────────────────────────────────────────────────── + +function lookupStaticFitnessTable( + normalizedModel: string, + normalizedTask: string, +): number | null { + const table = FITNESS_TABLE[normalizedTask] || FITNESS_TABLE.default; for (const [pattern, score] of Object.entries(table)) { if (normalizedModel.includes(pattern)) return score; } + return null; +} - // Wildcard boost +function lookupWildcardBoosts( + normalizedModel: string, + normalizedTask: string, +): number { let baseScore = 0.5; for (const wc of WILDCARD_BOOSTS) { if (normalizedModel.includes(wc.pattern) && normalizedTask === wc.taskType) { baseScore += wc.boost; } } - return Math.min(1.0, baseScore); } -/** - * Get all task types available. - */ +export function getTaskFitness(model: string, taskType: string): number { + return getTaskFitnessWithSource(model, taskType).score; +} + +export function getTaskFitnessWithSource( + model: string, + taskType: string, +): { score: number; source: string } { + const normalizedModel = model.toLowerCase(); + const normalizedTask = taskType.toLowerCase(); + + const userOverride = queryModelIntelligence( + normalizedModel, + normalizedTask, + "user_override", + ); + if (userOverride !== null) { + return { score: userOverride, source: "user_override" }; + } + + const arenaElo = queryModelIntelligence( + normalizedModel, + normalizedTask, + "arena_elo", + ); + if (arenaElo !== null) { + return { score: arenaElo, source: "arena_elo" }; + } + + const tierScore = getModelsDevTierFitness(normalizedModel, normalizedTask); + if (tierScore !== null) { + return { score: tierScore, source: "models_dev_tier" }; + } + + const staticScore = lookupStaticFitnessTable( + normalizedModel, + normalizedTask, + ); + if (staticScore !== null) { + return { score: staticScore, source: "fitness_table" }; + } + + return { score: lookupWildcardBoosts(normalizedModel, normalizedTask), source: "wildcard_boost" }; +} + +export function setUserFitnessOverride( + model: string, + category: string, + score: number, +): void { + try { + setUserFitnessOverrideEntry( + model.toLowerCase(), + category.toLowerCase(), + score, + ); + invalidateFitnessCache(); + } catch (err) { + throw new Error( + `Failed to set user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +export function clearUserFitnessOverride( + model: string, + category: string, +): void { + try { + deleteUserFitnessOverrideEntry(model.toLowerCase(), category.toLowerCase()); + invalidateFitnessCache(); + } catch (err) { + throw new Error( + `Failed to clear user fitness override for ${model}/${category}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + export function getTaskTypes(): string[] { return Object.keys(FITNESS_TABLE).filter((k) => k !== "default"); } + +export function invalidateFitnessCache(): void { + _capabilitiesCache = null; + _intelligenceCache.clear(); +} diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 5095207bcc..492abe2886 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -8,8 +8,12 @@ import { checkFallbackError, classifyErrorText, + classifyLockoutReason, + decayModelFailureCount, formatRetryAfter, getRuntimeProviderProfile, + isModelLocked, + recordModelLockoutFailure, recordProviderFailure, isProviderFailureCode, isProviderExhaustedReason, @@ -44,6 +48,7 @@ import { getLastSessionModel, getHandoff, } from "../../src/lib/db/contextHandoffs.ts"; +import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings"; import { fetchCodexQuota } from "./codexQuotaFetcher.ts"; import { getQuotaFetcher } from "./quotaPreflight.ts"; import * as semaphore from "./rateLimitSemaphore.ts"; @@ -71,11 +76,7 @@ import { type ProviderCandidate, type ScoringWeights, } from "./autoCombo/scoring.ts"; -import { - getResolvedModelCapabilities, - supportsReasoning, - supportsToolCalling, -} from "./modelCapabilities.ts"; +import { getResolvedModelCapabilities, supportsToolCalling } from "./modelCapabilities.ts"; import { estimateTokens } from "./contextManager.ts"; import { getReasoningTokens } from "../../src/lib/usage/tokenAccounting.ts"; import { getSessionConnection } from "./sessionManager.ts"; @@ -108,6 +109,7 @@ import { resolveResilienceSettings, type ResilienceSettings, } from "../../src/lib/resilience/settings"; +import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "./reasoningTokenBuffer.ts"; // Status codes that should mark round-robin target semaphores as cooling down. const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504]; @@ -446,7 +448,211 @@ export async function validateResponseQuality( isStreaming: boolean, log: { warn?: (...args: unknown[]) => void } ): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> { - if (isStreaming) return { valid: true }; + // Issue #3685: For Claude SSE streaming responses, use a BOUNDED PEEK to + // detect the empty-content-block pattern (content_filter stop_reason with + // no content_block_* events) WITHOUT de-streaming non-empty responses. + // + // Strategy: + // - Read chunks from response.body one at a time, accumulating raw bytes. + // - Parse SSE events incrementally. + // - If a content_block_* event appears → stream HAS content. Stop buffering. + // Return a clonedResponse whose body replays buffered bytes then pipes the + // remainder of the original reader. Only the chunks up to the first content + // block were held in memory — the rest stream normally. + // - If the stream ends with a complete Claude lifecycle but NO content_block + // → return invalid (combo failover). The empty lifecycle is tiny so fully + // reading it is acceptable. + // - If the stream ends without a recognisable complete Claude lifecycle → + // return valid with a clonedResponse replaying all buffered bytes (don't + // misclassify non-Claude or partial streams as empty). + // + // Non-text/event-stream streaming responses are not buffered at all. + if (isStreaming) { + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("text/event-stream")) { + return { valid: true }; + } + + if (!response.body) { + return { valid: true }; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8"); + + // Raw Uint8Array chunks accumulated so far — used to replay the prefix + // in the returned clonedResponse. + const bufferedChunks: Uint8Array[] = []; + // Decoded text accumulated across chunks for incremental SSE parsing. + // Only the tail of the most-recently-processed line window remains here + // between iterations (incomplete lines are deferred to the next chunk). + let decodedSoFar = ""; + + // SSE lifecycle state. + let hasMessageStart = false; + let hasContentBlock = false; + let hasLifecycleEnd = false; + // `event:` type line seen before the next `data:` line in the same event. + let pendingEventType = ""; + + /** + * Parse any complete SSE lines from `decodedSoFar`, updating lifecycle + * flags in the closure. The last (potentially incomplete) line is kept in + * `decodedSoFar` for the next iteration. + * + * Returns true when a content_block_* event is detected — the caller + * should stop peeking and treat the stream as non-empty. + */ + function parseAccumulatedSse(): boolean { + const lines = decodedSoFar.split(/\r?\n/); + // Retain the potentially-incomplete trailing fragment. + decodedSoFar = lines[lines.length - 1]; + + for (let i = 0; i < lines.length - 1; i++) { + const trimmed = lines[i].trim(); + + if (trimmed.startsWith("event:")) { + pendingEventType = trimmed.slice(6).trim(); + continue; + } + + if (!trimmed.startsWith("data:")) { + if (!trimmed) pendingEventType = ""; + continue; + } + + const data = trimmed.slice(5).trim(); + if (!data || data === "[DONE]") continue; + + let parsed: Record; + try { + parsed = JSON.parse(data); + } catch { + continue; + } + + const eventType = + (typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || ""; + pendingEventType = ""; + + switch (eventType) { + case "message_start": + hasMessageStart = true; + break; + case "content_block_start": + case "content_block_delta": + case "content_block_stop": + hasContentBlock = true; + // Signal caller to stop buffering immediately. + return true; + case "message_stop": + hasLifecycleEnd = true; + break; + case "message_delta": { + const delta = parsed.delta; + if ( + delta && + typeof delta === "object" && + (delta as Record).stop_reason != null + ) { + hasLifecycleEnd = true; + } + break; + } + default: + break; + } + } + return false; + } + + /** + * Build a Response whose body first replays all bytes in `bufferedChunks`, + * then forwards the remainder of `readerToForward` chunk-by-chunk. + * Preserves the original response's status, statusText, and headers. + */ + function buildReplayResponse( + readerToForward: ReadableStreamDefaultReader + ): Response { + // Snapshot the prefix so mutations after this point don't affect it. + const prefix = bufferedChunks.slice(); + let prefixIdx = 0; + const stream = new ReadableStream({ + async pull(controller) { + // 1. Drain the buffered prefix one chunk at a time. + if (prefixIdx < prefix.length) { + controller.enqueue(prefix[prefixIdx++]); + return; + } + // 2. Forward the remainder from the original reader. + try { + const { done, value } = await readerToForward.read(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + } catch { + controller.close(); + } + }, + }); + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + } + + // Main bounded-peek loop. + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + // Stream finished — flush the TextDecoder and parse any remaining text. + const tail = decoder.decode(undefined, { stream: false }); + if (tail) decodedSoFar += tail; + parseAccumulatedSse(); + + if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) { + // Complete Claude lifecycle with zero content blocks → failover. + log.warn?.( + "COMBO", + "Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover" + ); + return { valid: false, reason: "streaming empty content block" }; + } + + // Incomplete lifecycle or non-Claude stream — replay all buffered + // bytes. The reader is exhausted so the forwarding reader will + // immediately signal done. + const clonedResponse = buildReplayResponse(reader); + return { valid: true, clonedResponse }; + } + + // Accumulate raw bytes for potential replay. + bufferedChunks.push(value); + + // Decode incrementally (stream:true keeps multi-byte char state). + decodedSoFar += decoder.decode(value, { stream: true }); + const foundContent = parseAccumulatedSse(); + + if (foundContent) { + // A content_block_* event was found — stop peeking. Return a + // clonedResponse that replays all buffered bytes (the current chunk + // is already in bufferedChunks) and then forwards the remainder of + // the original reader unchanged. + const clonedResponse = buildReplayResponse(reader); + return { valid: true, clonedResponse }; + } + } + } catch { + // If reading the stream fails, pass through — other mechanisms + // (stream readiness timeout) will catch truly broken streams. + return { valid: true }; + } + } const contentType = response.headers.get("content-type") || ""; if (!contentType.includes("application/json") && !contentType.includes("text/")) { @@ -2815,6 +3021,7 @@ export async function handleComboChat({ ? resolveComboConfig(combo, settings) : { ...getDefaultComboConfig(), ...(combo.config || {}) }; const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS); + const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false; // ── Per-model timeout wrapper ──────────────────────────────────────────── // Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can @@ -3391,6 +3598,7 @@ export async function handleComboChat({ ): Promise<{ ok: boolean; response?: Response } | null> => { const target = orderedTargets[i]; const modelStr = target.modelStr; + const rawModel = parseModel(modelStr).model || modelStr; const provider = target.provider; const cb = getCircuitBreaker(provider); @@ -3447,6 +3655,13 @@ export async function handleComboChat({ return null; } + // Pre-check: skip models locked by the resilience system (model-level lockout) + if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) { + log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`); + if (i > 0) fallbackCount++; + return null; + } + // Pre-screen may have already determined this target unavailable (e.g. // circuit-breaker OPEN at resolve time). Skip immediately in that case. // For targets pre-screened as "available" we still call isModelAvailable @@ -3603,23 +3818,25 @@ export async function handleComboChat({ } } - // Issue #3587: Reasoning models (deepseek-v4-flash, nemotron, etc.) consume - // ALL max_tokens for reasoning_tokens, leaving content empty. Add a buffer - // to max_tokens so the model has enough tokens for both reasoning and content. - if (supportsReasoning(modelStr)) { + // Issue #3587: Reasoning models can spend the whole output budget on + // reasoning. Only add headroom when the complete buffer fits inside the + // model's known output cap; otherwise preserve the client's explicit limit. + { const bodyRecord = attemptBody as Record; - const currentMaxTokens = Number(bodyRecord.max_tokens) || 0; - if (currentMaxTokens > 0) { - // Add 50% buffer + 1000 floor to ensure reasoning + content both fit - const bufferedMaxTokens = Math.max( - currentMaxTokens + 1000, - Math.ceil(currentMaxTokens * 1.5) - ); + const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens); + const bufferedMaxTokens = resolveReasoningBufferedMaxTokens( + modelStr, + bodyRecord.max_tokens, + { enabled: reasoningTokenBufferEnabled } + ); + if (currentMaxTokens !== null && bufferedMaxTokens !== null) { bodyRecord.max_tokens = bufferedMaxTokens; - log.info( - "COMBO", - `Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}` - ); + if (bufferedMaxTokens !== currentMaxTokens) { + log.info( + "COMBO", + `Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}` + ); + } } } const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { @@ -3648,6 +3865,25 @@ export async function handleComboChat({ lastError = `Upstream response failed quality validation: ${quality.reason}`; if (!lastStatus) lastStatus = 502; if (i > 0) fallbackCount++; + if (provider && rawModel) { + const mlSettings = resolveModelLockoutSettings(settings); + if (mlSettings.enabled && mlSettings.errorCodes.includes(502)) { + recordModelLockoutFailure( + provider, + target.connectionId || "", + rawModel, + "quality_failure", + 502, + mlSettings.baseCooldownMs, + profile, + { + exactCooldownMs: mlSettings.useExponentialBackoff + ? 0 + : mlSettings.baseCooldownMs, + } + ); + } + } emit("combo.target.failed", { comboName: combo.name, targetIndex: i, @@ -3658,6 +3894,25 @@ export async function handleComboChat({ }); return null; } + + // Success decay: a healthy response walks the model's lockout failure + // count back down (and eventually clears an expired lockout entirely). + if (provider && rawModel) { + const dcResult = decayModelFailureCount( + provider, + target.connectionId || "", + rawModel + ); + if (dcResult.cleared) { + log.info("COMBO", `Model ${modelStr} fully recovered — lockout cleared`); + } else if (dcResult.newFailureCount > 0) { + log.debug( + "COMBO", + `Model ${modelStr} decayed to failureCount=${dcResult.newFailureCount}` + ); + } + } + const latencyMs = Date.now() - startTime; emit("combo.target.succeeded", { comboName: combo.name, @@ -4031,7 +4286,36 @@ export async function handleComboChat({ !isTokenLimitBreach && [408, 429, 500, 502, 503, 504].includes(result.status); if (retry < maxRetries && isTransient && !providerExhausted) { - continue; // Retry same model + // Record model lockout immediately on the first transient failure — + // once the model is cooling down, retrying it would waste an upstream + // call and extend the cooldown via exponential backoff. + let lockoutRecorded = false; + if (provider && rawModel && retry === 0) { + const mlSettings = resolveModelLockoutSettings(settings); + if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) { + recordModelLockoutFailure( + provider, + target.connectionId || "", + rawModel, + classifyLockoutReason(result.status), + result.status, + mlSettings.baseCooldownMs, + profile, + { + exactCooldownMs: mlSettings.useExponentialBackoff + ? 0 + : mlSettings.baseCooldownMs, + } + ); + lockoutRecorded = true; + } + } + if (lockoutRecorded) { + log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`); + if (i > 0) fallbackCount++; + return null; + } + continue; // Retry same model (transient error, no lockout recorded) } // Done retrying this model @@ -4046,6 +4330,25 @@ export async function handleComboChat({ lastError = errorText || String(result.status); if (!lastStatus) lastStatus = result.status; if (i > 0) fallbackCount++; + // Wire combo failures into the resilience dashboard (model-level lockout) + // alongside the provider-level cooldown below — they govern different scopes. + if (provider && rawModel) { + const mlSettings = resolveModelLockoutSettings(settings); + if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) { + recordModelLockoutFailure( + provider, + target.connectionId || "", + rawModel, + classifyLockoutReason(result.status), + result.status, + mlSettings.baseCooldownMs, + profile, + { + exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs, + } + ); + } + } log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") { @@ -4222,6 +4525,7 @@ async function handleRoundRobinCombo({ const maxRetries = config.maxRetries ?? 1; const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000); const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0); + const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false; const resilienceSettings: ResilienceSettings = settings ? resolveResilienceSettings(settings) @@ -4381,27 +4685,30 @@ async function handleRoundRobinCombo({ `[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}` ); - // Issue #3587: Reasoning models consume ALL max_tokens for reasoning_tokens. - // Add buffer to ensure reasoning + content both fit. Apply the buffer to a - // per-attempt COPY — never mutate the shared `body` — so it does not compound - // across round-robin iterations/retries (otherwise 4096 -> 6144 -> 9216 -> ... - // as each reasoning model re-reads an already-buffered value and overshoots the - // model's real limit, triggering 400s). + // Issue #3587: Reasoning models can spend the whole output budget on + // reasoning. Apply any safe buffer to a per-attempt copy so round-robin + // retries never compound across models. let attemptBody = body; - if (supportsReasoning(modelStr)) { - const currentMaxTokens = Number((body as Record).max_tokens) || 0; - if (currentMaxTokens > 0) { - const bufferedMaxTokens = Math.max( - currentMaxTokens + 1000, - Math.ceil(currentMaxTokens * 1.5) - ); + { + const bodyRecord = body as Record; + const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens); + const bufferedMaxTokens = resolveReasoningBufferedMaxTokens( + modelStr, + bodyRecord.max_tokens, + { enabled: reasoningTokenBufferEnabled } + ); + if ( + currentMaxTokens !== null && + bufferedMaxTokens !== null && + bufferedMaxTokens !== currentMaxTokens + ) { attemptBody = { - ...(body as Record), + ...bodyRecord, max_tokens: bufferedMaxTokens, } as typeof body; log.info( "COMBO-RR", - `Reasoning model ${modelStr}: buffered max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}` + `Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}` ); } } diff --git a/open-sse/services/comboConfig.ts b/open-sse/services/comboConfig.ts index e8c9c33a52..5afe81f6aa 100644 --- a/open-sse/services/comboConfig.ts +++ b/open-sse/services/comboConfig.ts @@ -26,6 +26,7 @@ const DEFAULT_COMBO_CONFIG = { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + reasoningTokenBufferEnabled: true, manifestRouting: false, resetAwareSessionWeight: 0.35, resetAwareWeeklyWeight: 0.65, diff --git a/open-sse/services/emergencyFallback.ts b/open-sse/services/emergencyFallback.ts index 349b5f83ce..3819848291 100644 --- a/open-sse/services/emergencyFallback.ts +++ b/open-sse/services/emergencyFallback.ts @@ -7,6 +7,9 @@ * * Inspired by ClawRouter: "gpt-oss-120b costs nothing and serves as * automatic fallback when wallet is empty." + * + * Operators can disable the redirect entirely with + * `OMNIROUTE_EMERGENCY_FALLBACK=false` (or `0`). Default remains enabled. */ export interface EmergencyFallbackConfig { @@ -63,6 +66,11 @@ export interface NoFallbackDecision { export type FallbackResult = FallbackDecision | NoFallbackDecision; +export function isEmergencyFallbackEnvEnabled(): boolean { + const raw = process.env.OMNIROUTE_EMERGENCY_FALLBACK; + return raw !== "false" && raw !== "0"; +} + export function shouldUseFallback( status: number, errorBody: string, @@ -70,6 +78,12 @@ export function shouldUseFallback( config: EmergencyFallbackConfig = EMERGENCY_FALLBACK_CONFIG ): FallbackResult { if (!config.enabled) return { shouldFallback: false, reason: "emergency fallback disabled" }; + if (!isEmergencyFallbackEnvEnabled()) { + return { + shouldFallback: false, + reason: "emergency fallback disabled via OMNIROUTE_EMERGENCY_FALLBACK", + }; + } if (config.skipForToolRequests && requestHasTools) { return { shouldFallback: false, reason: "skipped: request has tools" }; } diff --git a/open-sse/services/geminiRateLimitTracker.ts b/open-sse/services/geminiRateLimitTracker.ts new file mode 100644 index 0000000000..7ede8a4f88 --- /dev/null +++ b/open-sse/services/geminiRateLimitTracker.ts @@ -0,0 +1,145 @@ +/** + * In-memory request counters for Gemini models — tracks both RPD (daily) + * and RPM (sliding 60s window) so that 429 responses can be classified + * as either quota_exhausted (RPD hit) or rate_limit_exceeded (RPM hit). + * + * Gemini returns identical error bodies for both types, so we rely on + * published per-model limits from geminiRateLimits.json to distinguish them. + * + * Counters are incremented on every Gemini request so that once usage + * reaches the published limit, subsequent 429s are correctly classified. + */ + +import geminiLimits from "../config/geminiRateLimits.json"; + +// ── RPD (daily) state ──────────────────────────────────────────────────────── + +interface DailyCount { + date: string; // "YYYY-MM-DD" + count: number; +} + +const dailyCounts = new Map(); + +// ── RPM (sliding 60s window) state ─────────────────────────────────────────── + +const minuteWindows = new Map(); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function toDateKey(): string { + return new Date().toISOString().slice(0, 10); +} + +function stripModelPrefix(modelId: string): string { + // Only strip the "gemini/" provider prefix, never "gemini-" which is part + // of the actual model name (e.g. "gemini-2.5-flash", "gemini-3.5-live-translate"). + return modelId.replace(/^gemini\//, "").trim(); +} + +function lookupValue(modelId: string, field: "rpm" | "rpd"): number { + if (!modelId) return 0; + const key = stripModelPrefix(modelId); + const entry = (geminiLimits as Record>)[key]; + if (!entry) { + for (const [knownKey, knownEntry] of Object.entries(geminiLimits)) { + if (key.endsWith(knownKey) || knownKey.endsWith(key)) { + const val = knownEntry[field]; + return typeof val === "number" && val > 0 ? val : 0; + } + } + return 0; + } + const val = entry[field]; + return typeof val === "number" && val > 0 ? val : 0; +} + +// ── RPD exports ────────────────────────────────────────────────────────────── + +export function getModelRpd(modelId: string): number { + return lookupValue(modelId, "rpd"); +} + +export function incrementDailyRequestCount(modelId: string): void { + if (!modelId) return; + const key = stripModelPrefix(modelId); + const today = toDateKey(); + const existing = dailyCounts.get(key); + if (existing && existing.date === today) { + existing.count++; + } else { + dailyCounts.set(key, { date: today, count: 1 }); + } +} + +export function getDailyRequestCount(modelId: string): number { + if (!modelId) return 0; + const key = stripModelPrefix(modelId); + const today = toDateKey(); + const entry = dailyCounts.get(key); + if (entry && entry.date === today) return entry.count; + return 0; +} + +export function isRpdExhausted(modelId: string): boolean { + const rpd = getModelRpd(modelId); + if (rpd <= 0) return false; + return getDailyRequestCount(modelId) >= rpd; +} + +// ── RPM exports ────────────────────────────────────────────────────────────── + +export function getModelRpm(modelId: string): number { + return lookupValue(modelId, "rpm"); +} + +/** Prune timestamps older than 60 seconds from a model's window. */ +function pruneMinuteWindow(key: string): void { + const now = Date.now(); + const cutoff = now - 60_000; + const timestamps = minuteWindows.get(key); + if (!timestamps) return; + // Keep only timestamps >= cutoff + let i = 0; + while (i < timestamps.length && timestamps[i] < cutoff) i++; + if (i > 0) { + minuteWindows.set(key, timestamps.slice(i)); + } +} + +export function incrementMinuteRequestCount(modelId: string): void { + if (!modelId) return; + const key = stripModelPrefix(modelId); + pruneMinuteWindow(key); + const timestamps = minuteWindows.get(key) ?? []; + timestamps.push(Date.now()); + minuteWindows.set(key, timestamps); +} + +export function getMinuteRequestCount(modelId: string): number { + if (!modelId) return 0; + const key = stripModelPrefix(modelId); + pruneMinuteWindow(key); + return minuteWindows.get(key)?.length ?? 0; +} + +export function isRpmExhausted(modelId: string): boolean { + const rpm = getModelRpm(modelId); + if (rpm <= 0) return false; + return getMinuteRequestCount(modelId) >= rpm; +} + +// ── Increment both (convenience) ───────────────────────────────────────────── + +/** Increment both daily and minute counters for a Gemini request. */ +export function incrementRequestCount(modelId: string): void { + incrementDailyRequestCount(modelId); + incrementMinuteRequestCount(modelId); +} + +// ── Reset (testing) ────────────────────────────────────────────────────────── + +export function resetCounters(): void { + dailyCounts.clear(); + minuteWindows.clear(); +} diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 03d4c34e57..216c2748b7 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -150,9 +150,11 @@ export function getNextFamilyFallback( const provider = parsed.provider || parsed.providerAlias || ""; const prefix = provider ? `${provider}/` : ""; - // Normalize dots to hyphens for the lookup so kiro/claude-opus-4.8 finds the right entry + // Normalize dots to hyphens so kiro/claude-opus-4.8 finds the right entry. + // Fall back to the bare model name to support keys like "gemini-3.1-pro-high" + // whose dots are part of the literal name, not a version separator. const lookupKey = bareModel.replace(/\./g, "-"); - const family = MODEL_FAMILIES[lookupKey]; + const family = MODEL_FAMILIES[lookupKey] ?? MODEL_FAMILIES[bareModel]; if (!family) return null; // Resolve the provider's supported model IDs so we can match notation (dot vs hyphen) diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts new file mode 100644 index 0000000000..23290de713 --- /dev/null +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -0,0 +1,40 @@ +import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts"; +import { MODEL_SPECS } from "../../src/shared/constants/modelSpecs.ts"; + +const DEFAULT_MAX_OUTPUT_TOKENS = MODEL_SPECS.__default__.maxOutputTokens; + +export function toPositiveInteger(value: unknown): number | null { + const numericValue = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : null; + if (numericValue === null || !Number.isFinite(numericValue)) return null; + const normalized = Math.floor(numericValue); + return normalized > 0 ? normalized : null; +} + +export function resolveReasoningBufferedMaxTokens( + modelStr: string, + currentMaxTokens: unknown, + options: { enabled?: boolean } = {} +): number | null { + if (options.enabled === false) return null; + + const current = toPositiveInteger(currentMaxTokens); + if (current === null) return null; + + const capabilities = getResolvedModelCapabilities(modelStr); + if (capabilities.supportsThinking !== true) return null; + + const maxOutputTokens = toPositiveInteger(capabilities.maxOutputTokens); + if (maxOutputTokens === null || maxOutputTokens === DEFAULT_MAX_OUTPUT_TOKENS) return null; + if (current > maxOutputTokens) return maxOutputTokens; + if (current === maxOutputTokens) return current; + + const buffered = Math.max(current + 1000, Math.ceil(current * 1.5)); + if (buffered > maxOutputTokens) return current; + + return buffered; +} diff --git a/open-sse/services/tokenExtractionConfig.ts b/open-sse/services/tokenExtractionConfig.ts index d9f6f0a86c..f82804e2e0 100644 --- a/open-sse/services/tokenExtractionConfig.ts +++ b/open-sse/services/tokenExtractionConfig.ts @@ -168,16 +168,24 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [ ), // ── Qwen Web ────────────────────────────────────────────── + // The v2 API sits behind Alibaba's "baxia" WAF, which needs the full browser + // cookie jar (cna + ssxmod_itna/itna2 + token), not just the bearer token. + // Capture the WAF cookies alongside the localStorage token (#3288). config( "qwen-web", "Qwen Web (Tongyi)", "https://chat.qwen.ai/", "https://chat.qwen.ai", [ - { type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" }, { type: "localStorage", key: "token" }, + { type: "cookie", name: "token", domain: ".chat.qwen.ai" }, + { type: "cookie", name: "cna", domain: ".chat.qwen.ai" }, + { type: "cookie", name: "ssxmod_itna", domain: ".chat.qwen.ai" }, + { type: "cookie", name: "ssxmod_itna2", domain: ".chat.qwen.ai" }, + { type: "cookie", name: "XSRF_TOKEN", domain: ".chat.qwen.ai" }, ], - "Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token will be extracted.", + "Log in to Qwen at chat.qwen.ai using your Alibaba account. The session token and the " + + "Alibaba WAF cookies (cna, ssxmod_itna) will be extracted — all are required by the v2 API.", { cookieDomain: ".chat.qwen.ai" } ), diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 4abb7acbee..0581a77bf6 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -181,6 +181,93 @@ function getRefreshCacheKey(provider, refreshToken) { return `${provider}:${tokenHash}`; } +/** + * OAuth2 error codes that mean the refresh token is permanently dead and + * retrying will never succeed → callers must emit the unrecoverable sentinel + * so the HealthCheck deactivates the account instead of looping every 60s. + * Deliberately EXCLUDES transient codes (server_error, temporarily_unavailable, + * slow_down) so we never deactivate an account over a recoverable blip. + */ +const UNRECOVERABLE_OAUTH_ERROR_CODES = new Set([ + "invalid_grant", + "invalid_request", + "refresh_token_reused", + "invalid_token", + "expired_token", + "unauthorized_client", + "access_denied", +]); + +/** + * Extract a canonical OAuth error code from a refresh-endpoint error body of + * ANY shape. Production proxies/MITMs deliver the same `invalid_grant` 400 in + * several shapes — a plain object `{error:"invalid_grant"}`, a nested + * `{error:{code:"invalid_grant"}}`, a JSON **string** (double-encoded body), + * or the raw JSON text wrapped as `{error:""}` by a catch branch. + * The old `errorBody.error === "invalid_grant"` only matched the first shape, + * so the others returned `null` → the HealthCheck refresh loop (root cause of + * the 1352× claude/aa5dd5cf invalidation storm). + * + * Returns the matched code (only if it is in UNRECOVERABLE_OAUTH_ERROR_CODES) + * or null. Never matches loosely — a known code is accepted only when it is a + * bare code string or the value of an `"error"`/`"error_code"` field, so a 502 + * HTML page or a `server_error` body never becomes a false positive. + */ +export function extractOAuthErrorCode(raw: unknown, depth = 0): string | null { + if (raw == null || depth > 6) return null; + + if (typeof raw === "string") { + const s = raw.trim(); + if (!s) return null; + if (UNRECOVERABLE_OAUTH_ERROR_CODES.has(s)) return s; + // The string may itself be JSON (a double-encoded body, or the raw text). + if (s[0] === "{" || s[0] === "[" || s[0] === '"') { + try { + const nested = extractOAuthErrorCode(JSON.parse(s), depth + 1); + if (nested) return nested; + } catch { + // not valid JSON — fall through to the field scan + } + } + // Safety net: a known code appearing as the value of an "error"/"error_code" + // field inside otherwise-unparsed text. Scoped to avoid false positives. + const m = s.match(/"error(?:_code)?"\s*:\s*"([a-z_]+)"/i); + if (m && UNRECOVERABLE_OAUTH_ERROR_CODES.has(m[1])) return m[1]; + return null; + } + + if (typeof raw === "object") { + const o = raw as Record; + return ( + extractOAuthErrorCode(o.error, depth + 1) ?? + extractOAuthErrorCode(o.code, depth + 1) ?? + extractOAuthErrorCode(o.error_code, depth + 1) + ); + } + + return null; +} + +/** + * Read an error response body ONCE and classify it. Returns the raw text (for + * logging) and the extracted unrecoverable OAuth code (or null). Reading once + * avoids the double-read bug where `response.json()` consumes the stream and a + * later `response.text()` returns empty. + */ +async function readRefreshErrorBody( + response: Response +): Promise<{ rawText: string; code: string | null }> { + const rawText = await response.text().catch(() => ""); + let parsed: unknown = rawText; + try { + parsed = JSON.parse(rawText); + } catch { + // keep rawText as-is + } + const code = extractOAuthErrorCode(parsed) ?? extractOAuthErrorCode(rawText); + return { rawText, code }; +} + /** * Refresh OAuth access token using refresh token */ @@ -229,6 +316,10 @@ export async function refreshAccessToken( status: response.status, error: errorText, }); + const code = extractOAuthErrorCode(errorText); + if (code === "invalid_grant" || code === "invalid_request") { + return { error: "unrecoverable_refresh_error", code }; + } return null; } @@ -401,6 +492,10 @@ export async function refreshClineToken(refreshToken, log, proxyConfig: unknown status: response.status, error: errorText, }); + const code = extractOAuthErrorCode(errorText); + if (code === "invalid_grant" || code === "invalid_request") { + return { error: "unrecoverable_refresh_error", code }; + } return null; } @@ -664,19 +759,17 @@ export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: un ); if (!response.ok) { - let errorBody: { error?: string; error_description?: string } = {}; - try { - errorBody = await response.json(); - } catch { - const text = await response.text().catch(() => "unknown"); - errorBody = { error: text }; - } + // Read + classify the body ONCE, shape-agnostic. A proxy/MITM can deliver + // the invalid_grant 400 as a JSON string, a double-encoded string, a + // nested {error:{code}}, or raw text — all must yield the sentinel so the + // HealthCheck deactivates instead of looping every 60s. + const { rawText, code } = await readRefreshErrorBody(response); log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, - error: errorBody, + error: rawText.slice(0, 300), }); - if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") { - return { error: "unrecoverable_refresh_error", code: errorBody.error }; + if (code === "invalid_grant" || code === "invalid_request") { + return { error: "unrecoverable_refresh_error", code }; } return null; } @@ -1186,6 +1279,10 @@ export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown status: response.status, error: errorText, }); + const code = extractOAuthErrorCode(errorText); + if (code === "invalid_grant" || code === "invalid_request") { + return { error: "unrecoverable_refresh_error", code }; + } return null; } @@ -1230,6 +1327,10 @@ export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown status: response.status, error: errorText, }); + const code = extractOAuthErrorCode(errorText); + if (code === "invalid_grant" || code === "invalid_request") { + return { error: "unrecoverable_refresh_error", code }; + } return null; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index d33cbcea4a..171f68dfe4 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -1477,6 +1477,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "opencode", "opencode-zen", "xiaomi-mimo", + "vertex", + "vertex-partner", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -1516,6 +1518,9 @@ export async function getUsageForProvider( case "kiro": case "amazon-q": return await getKiroUsage(accessToken, providerSpecificData); + case "vertex": + case "vertex-partner": + return await getVertexUsage(id || "", provider); case "kimi-coding": return await getKimiUsage(accessToken); case "qwen": @@ -2995,24 +3000,65 @@ export function buildKiroUsageResult( }; } +/** + * Discover a Kiro/CodeWhisperer profile ARN for an account that didn't persist one (common for + * AWS IAM Identity Center logins and kiro-cli imports). Calls ListAvailableProfiles on the + * region-matched endpoint and prefers a profile whose ARN is in the same region. Returns + * undefined when no profile is available (e.g. the org/token has no Kiro entitlement). + * Exported for testing. + */ +export async function discoverKiroProfileArn( + accessToken: string, + usageBaseUrl: string, + region: string +): Promise { + try { + const response = await fetch(usageBaseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + Accept: "application/json", + }, + body: JSON.stringify({ maxResults: 10 }), + // Don't let a hung profile lookup block the usage/quota refresh indefinitely. + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return undefined; + + const data = toRecord(await response.json()); + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + const normalizedRegion = region.toLowerCase(); + const matched = + profiles.find((profile: unknown) => { + const arn = toRecord(profile).arn; + return typeof arn === "string" && arn.toLowerCase().includes(`:${normalizedRegion}:`); + }) || profiles[0]; + const arn = toRecord(matched).arn; + return typeof arn === "string" && arn.length > 0 ? arn : undefined; + } catch { + return undefined; + } +} + /** * Kiro (AWS CodeWhisperer) Usage */ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRecord) { try { - const profileArn = providerSpecificData?.profileArn; - if (!profileArn) { - return { message: "Kiro connected. Profile ARN not available for quota tracking." }; - } + let profileArn = + typeof providerSpecificData?.profileArn === "string" + ? providerSpecificData.profileArn + : undefined; // Enterprise IAM Identity Center accounts are region-bound: the profileArn, token and // endpoint must all match the region. Derive the region from the stored region (preferred) // or the profileArn, then route to the regional Amazon Q endpoint (us-east-1 keeps the // legacy codewhisperer host; codewhisperer.{region} does not resolve for other regions). - const regionFromArn = - typeof profileArn === "string" - ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] - : undefined; + const regionFromArn = profileArn + ? profileArn.toLowerCase().match(/^arn:aws:codewhisperer:([a-z0-9-]+):/)?.[1] + : undefined; const region = (typeof providerSpecificData?.region === "string" && providerSpecificData.region.trim().toLowerCase()) || @@ -3021,6 +3067,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", @@ -3051,6 +3108,53 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec } } +/** + * Vertex AI — SELF-TRACKED spend. + * + * Vertex AI exposes no usage/quota API for an API key or Service Account (billing/credit balance + * lives behind the Cloud Billing API, which the proxy credential can't reach). Instead we report + * the USD that OmniRoute has spent through this connection since the account was added — summed + * from `usage_history` and priced via the backend pricing table. Returns a `message` (with the $ + * figure) plus a `spend` quota entry so the limits cache persists it (a message-only result is + * treated as a transient error and not cached). + */ +async function getVertexUsage(connectionId: string, provider: string) { + if (!connectionId) { + return { message: "Vertex connected. Connection id unavailable for usage tracking." }; + } + try { + const { getConnectionSpendUsdSinceAdded } = await import("@/lib/usage/usageStats"); + const { costUsd, requests } = await getConnectionSpendUsdSinceAdded(provider, connectionId); + + const spend: JsonRecord = { + used: Number(costUsd.toFixed(6)), + displayName: "Spend (USD)", + quotaSource: "localUsageHistory", + resetAt: null, + unlimited: false, + }; + + if (requests === 0) { + return { + plan: "Vertex AI", + message: "Vertex connected. No usage recorded through OmniRoute yet for this account.", + quotas: { spend }, + }; + } + + const costStr = costUsd >= 1 ? costUsd.toFixed(2) : costUsd.toFixed(4); + return { + plan: "Vertex AI", + message: `$${costStr} used since this account was added \u00b7 ${requests} request${ + requests === 1 ? "" : "s" + }`, + quotas: { spend }, + }; + } catch (error) { + return { message: `Vertex usage tracking error: ${(error as Error).message}` }; + } +} + /** * Map Kimi membership level to display name * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, @@ -3279,6 +3383,7 @@ export const __testing = { getMiniMaxRemainingPercent, getMiniMaxUsage, getXiaomiMimoUsage, + getVertexUsage, getMiniMaxAuthErrorMessage, getMiniMaxErrorSummary, mapCodeAssistSubscriptionToPlanLabel, diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 2671d7a5e8..7db6062331 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -212,7 +212,7 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.temperature !== undefined) { result.temperature = body.temperature; } - if (body.top_p !== undefined) { + if (body.temperature === undefined && body.top_p !== undefined) { result.top_p = body.top_p; } if (body.stop !== undefined) { diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 8cf9954602..c1453aafef 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -816,7 +816,7 @@ register( FORMATS.GEMINI, (model, body, stream = false, credentials = null) => openaiToGeminiRequest(model, body, stream, credentials, { - signaturelessToolCallMode: "native", + signaturelessToolCallMode: "context", }), null ); diff --git a/open-sse/tsconfig.json b/open-sse/tsconfig.json index 6a35a3d3e1..f64585be7e 100644 --- a/open-sse/tsconfig.json +++ b/open-sse/tsconfig.json @@ -7,6 +7,7 @@ "checkJs": true, "noEmit": true, "allowImportingTsExtensions": true, + "resolveJsonModule": true, "skipLibCheck": true, "esModuleInterop": true, "strict": false, diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 86c4e31461..5d84d1b52e 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -42,11 +42,10 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean { * * Optional `sourceFormat` argument lets callers apply spec-correct defaults * when both `stream` and `Accept` are ambiguous. The Anthropic Messages API - * defaults to non-stream when the body omits `stream`, regardless of Accept - * header. Without this hint, OmniRoute previously routed Anthropic /v1/messages - * requests with a curl-default wildcard Accept header through the streaming - * branch even though upstream returned JSON, producing STREAM_EARLY_EOF / - * HTTP 502 errors. + * and the OpenAI Responses API both default to non-stream when the body omits + * `stream`. Without this hint, OmniRoute previously routed those requests with + * a curl-default wildcard Accept header through the streaming branch even + * though upstream returned JSON, producing STREAM_EARLY_EOF / HTTP 502. */ export function resolveStreamFlag( bodyStream: unknown, @@ -64,11 +63,11 @@ export function resolveStreamFlag( const acceptsEventStream = typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader); - // Anthropic Messages API spec: stream defaults to false when body omits it. - // Only honor an explicit text/event-stream Accept header as a streaming opt-in - // for /v1/messages — otherwise default to non-stream so upstream JSON responses - // are surfaced correctly instead of triggering stream_early_eof. - if (sourceFormat === "claude") { + // Anthropic Messages API and OpenAI Responses API both specify stream=false + // when the body omits `stream`. Honor an explicit text/event-stream Accept + // header as a streaming opt-in; otherwise default to non-stream so + // spec-compliant upstreams that return JSON don't trigger STREAM_EARLY_EOF. + if (sourceFormat === "claude" || sourceFormat === "openai-responses") { if (acceptsEventStream) return true; return false; } diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index acd399315d..4b49eaf770 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -138,8 +138,15 @@ function buildProxyUrlString(parsed: URL, port: string): string { return `${parsed.protocol}//${auth}${parsed.hostname}:${port}`; } +/** + * SOCKS5 proxy support defaults ON (opt-OUT). A fresh deploy with no env set + * should honour SOCKS5 proxies out of the box — they were silently rejected + * before (default OFF), making accounts fall back to the host IP. Only an + * explicit falsey value (false/0/no/off) disables it. + */ export function isSocks5ProxyEnabled(): boolean { - return process.env.ENABLE_SOCKS5_PROXY === "true"; + const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase(); + return !["false", "0", "no", "off"].includes(raw); } export function proxyUrlForLogs(proxyUrl: string): string { @@ -173,7 +180,7 @@ export function normalizeProxyUrl( } if (parsed.protocol === "socks5:" && !allowSocks5) { throw new Error( - "[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)" + "[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)" ); } if (!parsed.hostname) { @@ -233,7 +240,7 @@ export function proxyConfigToUrl( } if (protocol === "socks5:" && !allowSocks5) { throw new Error( - "[ProxyDispatcher] SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)" + "[ProxyDispatcher] SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)" ); } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 4091d5cc38..48633fdc7c 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -109,7 +109,11 @@ function normalizeResponsesSseIds(payload: JsonRecord): boolean { } } - if (payload.response && typeof payload.response === "object" && !Array.isArray(payload.response)) { + if ( + payload.response && + typeof payload.response === "object" && + !Array.isArray(payload.response) + ) { const response = payload.response as JsonRecord; let responseChanged = false; const normalizedResponse = { ...response }; @@ -1016,6 +1020,32 @@ export function createSSEStream(options: StreamOptions = {}) { } }; + const emitClaudeEmptyStreamErrorAndAbort = ( + controller: TransformStreamDefaultController, + decrementPendingRequest = true + ) => { + clearIdleTimer(); + const msg = "Claude returned an empty response (no content block)"; + console.warn( + `[STREAM] Empty Claude stream at flush - emitting error (${provider || "provider"}:${model || "unknown"})` + ); + const errorBody = buildErrorBody(502, msg); + const errorEvent: Record = { type: "error", error: errorBody.error }; + const errOutput = formatSSE(errorEvent, FORMATS.CLAUDE); + reqLogger?.appendConvertedChunk?.(errOutput); + clientPayloadCollector.push(errorEvent); + controller.enqueue(encoder.encode(errOutput)); + if (onFailure) { + try { + void onFailure({ status: 502, message: msg, code: "empty_response" }); + } catch {} + } + if (decrementPendingRequest) { + trackPendingRequest(model, provider, connectionId, false); + } + controller.error(markPendingRequestCleared(new Error(msg))); + }; + const emitTranslatedClientItem = ( controller: TransformStreamDefaultController, item: Record @@ -1059,13 +1089,8 @@ export function createSSEStream(options: StreamOptions = {}) { sourceFormat === FORMATS.CLAUDE && shouldInjectClaudeEmptyResponseBeforeCurrentEvent(claudeEmptyResponseLifecycle, itemSanitized) ) { - const eventType = getClaudeEventType(itemSanitized); - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } if (sourceFormat === FORMATS.CLAUDE && isClaudeEventPayload(itemSanitized)) { @@ -1300,12 +1325,8 @@ export function createSSEStream(options: StreamOptions = {}) { type: eventType, }) ) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } pendingPassthroughEventLine = line; @@ -1337,7 +1358,8 @@ export function createSSEStream(options: StreamOptions = {}) { // clients like OpenCode, so drop it only for Responses-native consumers. const hasActiveDeltaValue = (value: unknown): boolean => { if (typeof value === "string") return value.length > 0; - if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry)); + if (Array.isArray(value)) + return value.some((entry) => hasActiveDeltaValue(entry)); if (value && typeof value === "object") { return Object.values(value).some((entry) => hasActiveDeltaValue(entry)); } @@ -1605,7 +1627,12 @@ export function createSSEStream(options: StreamOptions = {}) { parsed, passthroughResponsesOutputItems ); - if (stripped || backfilled || textualToolCallBackfilled || responsesIdsNormalized) { + if ( + stripped || + backfilled || + textualToolCallBackfilled || + responsesIdsNormalized + ) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } @@ -1632,13 +1659,8 @@ export function createSSEStream(options: StreamOptions = {}) { parsed ) ) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - parsed.type === "message_stop" && - !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller); + return; } updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, parsed); const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap); @@ -1708,14 +1730,16 @@ export function createSSEStream(options: StreamOptions = {}) { !parsed.choices[0].delta.reasoning_content ); const hadNonStringToolCallId = Array.isArray(parsed.choices) - ? parsed.choices.some((choice) => - Array.isArray(choice?.delta?.tool_calls) && - choice.delta.tool_calls.some( - (tc) => tc?.id != null && typeof tc.id !== "string" - ) + ? parsed.choices.some( + (choice) => + Array.isArray(choice?.delta?.tool_calls) && + choice.delta.tool_calls.some( + (tc) => tc?.id != null && typeof tc.id !== "string" + ) ) : false; - const hadNonStringTopLevelId = parsed?.id != null && typeof parsed.id !== "string"; + const hadNonStringTopLevelId = + parsed?.id != null && typeof parsed.id !== "string"; parsed = sanitizeStreamingChunk(parsed); if ( @@ -2148,13 +2172,8 @@ export function createSSEStream(options: StreamOptions = {}) { bufferedPayload ) ) { - const eventType = getClaudeEventType(bufferedPayload); - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: - eventType === "message_stop" && !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: false, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } if (isClaudeEventPayload(bufferedPayload)) { updateClaudeEmptyResponseLifecycle(claudeEmptyResponseLifecycle, bufferedPayload); @@ -2164,7 +2183,8 @@ export function createSSEStream(options: StreamOptions = {}) { // Normalize numeric IDs for final buffered data: chunk (same as transform path) if (typeof bufferedPayload === "object" && !Array.isArray(bufferedPayload)) { const flushedParsed = bufferedPayload as JsonRecord; - const flushedType = typeof flushedParsed.type === "string" ? flushedParsed.type : ""; + const flushedType = + typeof flushedParsed.type === "string" ? flushedParsed.type : ""; const isResponses = flushedType.startsWith("response."); const isClaude = isClaudeEventPayload(flushedParsed); if (isResponses) { @@ -2181,7 +2201,9 @@ export function createSSEStream(options: StreamOptions = {}) { } if (Array.isArray(flushedParsed.choices)) { for (const choice of flushedParsed.choices as JsonRecord[]) { - const tcs = (choice as JsonRecord | undefined)?.delta as JsonRecord | undefined; + const tcs = (choice as JsonRecord | undefined)?.delta as + | JsonRecord + | undefined; if (Array.isArray(tcs?.tool_calls)) { for (const tc of tcs.tool_calls as JsonRecord[]) { if (tc?.id != null && typeof tc.id !== "string") { @@ -2208,11 +2230,8 @@ export function createSSEStream(options: StreamOptions = {}) { } if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { emitSyntheticClaudeEmptyResponse(controller, { includeContentBlock: false, @@ -2489,11 +2508,8 @@ export function createSSEStream(options: StreamOptions = {}) { if (sourceFormat === FORMATS.CLAUDE) { if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { - emitSyntheticClaudeEmptyResponse(controller, { - includeContentBlock: true, - includeMessageDelta: !claudeEmptyResponseLifecycle.hasMessageDelta, - includeMessageStop: !claudeEmptyResponseLifecycle.hasMessageStop, - }); + emitClaudeEmptyStreamErrorAndAbort(controller, false); + return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { emitSyntheticClaudeEmptyResponse(controller, { includeContentBlock: false, @@ -2631,7 +2647,7 @@ export function createSSEStream(options: StreamOptions = {}) { ); } -export default createSSEStream +export default createSSEStream; // Convenience functions for backward compatibility export function createSSETransformStreamWithLogger( diff --git a/package-lock.json b/package-lock.json index 78cbbecb02..25c8f8dfc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.22", + "version": "3.8.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.22", + "version": "3.8.23", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -21587,7 +21587,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.8.22" + "version": "3.8.23" } } } diff --git a/package.json b/package.json index 07985c5800..e41b18509c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.22", + "version": "3.8.23", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { diff --git a/public/audio/ui-notify.mp3 b/public/audio/ui-notify.mp3 new file mode 100644 index 0000000000..326d3fa533 Binary files /dev/null and b/public/audio/ui-notify.mp3 differ diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index b999c5c6f5..b5c2e14dfb 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -129,7 +129,10 @@ if (!existsSync(standaloneServerJs)) { stdio: "inherit", }); if (!existsSync(standaloneServerJs)) { - console.error("\n ❌ Standalone build not found after `npm run build` at:", standaloneServerJs); + console.error( + "\n ❌ Standalone build not found after `npm run build` at:", + standaloneServerJs + ); console.error(" Make sure next.config.mjs has: output: 'standalone'"); process.exit(1); } @@ -263,6 +266,53 @@ if (existsSync(cliSrcFile)) { } } +// ── Step 8.8: Build @omniroute/opencode-plugin ────────────── +// The plugin ships bundled inside the omniroute npm package (see root +// package.json "files": ["@omniroute/", ...]). Its built `dist/` MUST be +// present in the publish tarball so `omniroute setup opencode` can copy it +// into the user's OpenCode plugin dir. If the build fails we surface the +// error — shipping without the plugin's dist breaks the documented install +// flow for every downstream user. +const opencodePluginSrc = join(ROOT, "@omniroute", "opencode-plugin"); +const opencodePluginDist = join(opencodePluginSrc, "dist", "index.js"); +const opencodePluginCjs = join(opencodePluginSrc, "dist", "index.cjs"); +if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package.json"))) { + const pluginAlreadyBuilt = existsSync(opencodePluginDist) && existsSync(opencodePluginCjs); + if (!pluginAlreadyBuilt) { + console.log("\n 🔨 Building @omniroute/opencode-plugin (tsup)..."); + try { + // The plugin is a standalone package (not an npm workspace), so the root + // install never populates its node_modules — and tsup with `dts: true` + // needs the plugin's own devDependencies (typescript, @opencode-ai/plugin + // types). Without this install a fresh CI publish fails at this step. + if (!existsSync(join(opencodePluginSrc, "node_modules"))) { + const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm"; + execFileSync(NPM_BIN, ["install", "--no-audit", "--no-fund"], { + cwd: opencodePluginSrc, + stdio: "inherit", + }); + } + execFileSync(NPX_BIN, ["tsup"], { + cwd: opencodePluginSrc, + stdio: "inherit", + env: { ...process.env, NODE_ENV: "production" }, + }); + console.log(" ✅ @omniroute/opencode-plugin bundled to @omniroute/opencode-plugin/dist/"); + } catch (err: any) { + console.error(" ❌ Failed to build @omniroute/opencode-plugin:", err.message); + console.error(" The published package would be missing the plugin dist."); + console.error( + " Run `cd @omniroute/opencode-plugin && npm install && npm run build` to debug." + ); + process.exit(1); + } + } else { + console.log(" ✅ @omniroute/opencode-plugin dist/ already present (skipping rebuild)"); + } +} else { + console.log(" ⏭️ @omniroute/opencode-plugin not found in workspace (skipping build)"); +} + // ── Step 9: Copy shared utilities needed at runtime ──────── const sharedApiKey = join(ROOT, "src", "shared", "utils", "apiKey.js"); const sharedApiKeyDest = join(DIST_DIR, "src", "shared", "utils"); @@ -379,7 +429,9 @@ const remainingUnexpectedFiles = findUnexpectedArtifactPaths(walkFiles(DIST_DIR) if (remainingUnexpectedFiles.length > 0) { console.error("\n ❌ Staged dist/ still contains unexpected publish artifacts:"); - remainingUnexpectedFiles.forEach((violation: string) => console.error(` - dist/${violation}`)); + remainingUnexpectedFiles.forEach((violation: string) => + console.error(` - dist/${violation}`) + ); process.exit(1); } diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 3fae1fdc70..cc5de4f713 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -131,6 +131,12 @@ const IGNORE_FROM_CODE = new Set([ // NVIDIA diagnostic/test helpers used only by ad-hoc scripts. "NVIDIA_BASE_URL", "NVIDIA_MODEL", + // XDG standard data directory — set by OS/desktop session, not OmniRoute config. + // Read by setup-open-code.mjs to locate platform-specific OpenCode data dir. + "XDG_DATA_HOME", + // Test-only override: points setup-open-code.mjs at a fixture plugin dir without + // requiring the real bundled plugin to be built. + "OMNIROUTE_OPENCODE_PLUGIN_DIR", ]); // Vars documented in ENVIRONMENT.md but intentionally absent from .env.example. diff --git a/scripts/dev/webdav-handler.mjs b/scripts/dev/webdav-handler.mjs index 0113cc1b65..3b0a0893b4 100644 --- a/scripts/dev/webdav-handler.mjs +++ b/scripts/dev/webdav-handler.mjs @@ -663,6 +663,7 @@ async function handlePut(req, res, absPath) { limitExceeded = true; req.destroy(); writeStream.destroy(); + resolve(); } else { writeStream.write(chunk); } @@ -670,7 +671,7 @@ async function handlePut(req, res, absPath) { req.on("end", () => { writeStream.end(); - resolve(); + // Don't resolve here — wait for the stream to finish flushing. }); req.on("error", () => { @@ -678,9 +679,10 @@ async function handlePut(req, res, absPath) { resolve(); }); - writeStream.on("error", () => { - resolve(); - }); + // Resolve only after all data has been flushed to the OS (prevents + // fs.renameSync from running before the write stream closes the file). + writeStream.on("finish", resolve); + writeStream.on("error", resolve); }); if (limitExceeded) { diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 43da508ffb..961eb75a92 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback, memo, useRef, useId } from "react"; import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { getProviderDisplayName } from "@/lib/display/names"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; import { ENDPOINT_CATEGORIES } from "@/shared/constants/endpointCategories"; @@ -17,14 +17,8 @@ import { } from "./apiManagerPageUtils"; import type { KeyStatus, KeyType } from "./apiManagerPageUtils"; import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage"; -import { - buildApiKeyCreateScopes, - mergeApiKeyPermissionScopes, -} from "./apiManagerScopes"; -import { - SELF_ACCOUNT_QUOTA_SCOPE, - SELF_USAGE_SCOPE, -} from "@/shared/constants/selfServiceScopes"; +import { buildApiKeyCreateScopes, mergeApiKeyPermissionScopes } from "./apiManagerScopes"; +import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes"; // Constants for validation const MAX_KEY_NAME_LENGTH = 200; @@ -126,9 +120,20 @@ interface ProviderConnection { interface KeyUsageStats { totalRequests: number; + totalCost: number; lastUsed: string | null; } +function formatUsdCost(value: number, locale: string): string { + const amount = Number.isFinite(value) ? value : 0; + return new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: amount > 0 && amount < 1 ? 4 : 2, + maximumFractionDigits: amount > 0 && amount < 1 ? 4 : 2, + }).format(amount); +} + interface Model { id: string; owned_by: string; @@ -146,6 +151,7 @@ type ProviderGroup = [provider: string, models: Model[]]; export default function ApiManagerPageClient() { const t = useTranslations("apiManager"); const tc = useTranslations("common"); + const locale = useLocale(); const newKeyNameInputId = useId(); const createKeyFormRef = useRef(null); const [keys, setKeys] = useState([]); @@ -339,6 +345,10 @@ export default function ApiManagerPageClient() { (sum: number, entry: any) => sum + (Number(entry.requests) || 0), 0 ); + const totalCost = matches.reduce((sum: number, entry: any) => { + const cost = Number(entry.cost); + return sum + (Number.isFinite(cost) ? cost : 0); + }, 0); // Match call logs by unique ID as well for the lastUsed timestamp // Prefer an exact apiKeyId match; fall back to name match for legacy @@ -350,6 +360,7 @@ export default function ApiManagerPageClient() { stats[key.id] = { totalRequests, + totalCost, lastUsed, }; } @@ -422,8 +433,7 @@ export default function ApiManagerPageClient() { const isFiltered = activeOnly || statusFilter !== null || typeFilter !== null || searchQuery.trim() !== ""; - const isQuotaKey = (k: ApiKey) => - Array.isArray(k.allowedQuotas) && k.allowedQuotas.length > 0; + const isQuotaKey = (k: ApiKey) => Array.isArray(k.allowedQuotas) && k.allowedQuotas.length > 0; const quotaKeys = filteredKeys.filter(isQuotaKey); const normalKeys = filteredKeys.filter((k) => !isQuotaKey(k)); @@ -880,8 +890,7 @@ export default function ApiManagerPageClient() { (() => { const renderKeyRow = (key: ApiKey) => { const stats = usageStats[key.id]; - const isRestricted = - Array.isArray(key.allowedModels) && key.allowedModels.length > 0; + const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0; const hasComboRestrictions = Array.isArray(key.allowedCombos) && key.allowedCombos.length > 0; const hasConnectionRestrictions = @@ -893,8 +902,7 @@ export default function ApiManagerPageClient() { ? key.throttleDelayMs : 0; const hasThrottle = throttleDelayMs > 0; - const hasManageScope = - Array.isArray(key.scopes) && key.scopes.includes("manage"); + const hasManageScope = Array.isArray(key.scopes) && key.scopes.includes("manage"); const hasJsonStreamDefault = key.streamDefaultMode === "json"; const maxSessions = typeof key.maxSessions === "number" ? key.maxSessions : 0; const hasSessionLimit = maxSessions > 0; @@ -1072,6 +1080,11 @@ export default function ApiManagerPageClient() { {stats?.totalRequests ?? 0}{" "} {t("reqs")} + {(stats?.totalRequests ?? 0) > 0 && ( + + {formatUsdCost(stats?.totalCost ?? 0, locale)} + + )} {stats?.lastUsed ? ( {t("lastUsedOn", { date: new Date(stats.lastUsed).toLocaleDateString() })} @@ -1084,6 +1097,14 @@ export default function ApiManagerPageClient() { {new Date(key.createdAt).toLocaleDateString()}
+ + payments + - ); - - const clearAllButton = (modelMeta.customModels.length > 0 || - providerAliasEntries.length > 0) && ( - - ); - - if (isManagedAvailableModelsProvider) { - const description = - providerId === "openrouter" - ? t("openRouterAnyModelHint") - : isCcCompatible - ? t("ccCompatibleModelsDescription") - : t("compatibleModelsDescription", { - type: isAnthropicCompatible ? t("anthropic") : t("openai"), - }); - const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); - const inputPlaceholder = - providerId === "openrouter" - ? t("openRouterModelPlaceholder") - : isCcCompatible - ? "claude-sonnet-4-6" - : isAnthropicCompatible - ? t("anthropicCompatibleModelPlaceholder") - : t("openaiCompatibleModelPlaceholder"); - - return ( -
-
- {autoSyncToggle} - {clearAllButton} -
- - handleToggleModelHidden(providerStorageAlias, modelId, hidden) - } - onBulkToggleHidden={(modelIds, hidden) => - handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) - } - bulkTogglePending={bulkVisibilityAction !== null} - togglingModelId={togglingModelId} - onTestModel={onTestModel} - modelTestStatus={modelTestStatus} - testingModelId={testingModelId} - onTestAll={handleTestAll} - testingAll={testingAll} - testProgress={testProgress} - autoHideFailed={autoHideFailed} - onAutoHideFailedChange={setAutoHideFailed} - /> -
- ); - } - - if (providerInfo.passthroughModels) { - const passthroughDescription = - providerId === "openrouter" - ? t("openRouterAnyModelHint") - : providerId === "bedrock" - ? t("bedrockModelsDescription") - : t("passthroughModelsDescription", { provider: providerInfo?.name || providerId }); - const passthroughInputLabel = - providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); - const passthroughInputPlaceholder = - providerId === "openrouter" - ? t("openRouterModelPlaceholder") - : providerId === "bedrock" - ? t("bedrockModelPlaceholder") - : t("openaiCompatibleModelPlaceholder"); - - return ( -
-
- - {autoSyncToggle} - {clearAllButton} - {!canImportModels && ( - {t("addConnectionToImport")} - )} -
- - handleToggleModelHidden(providerStorageAlias, modelId, hidden) - } - onBulkToggleHidden={(modelIds, hidden) => - handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) - } - bulkTogglePending={bulkVisibilityAction !== null} - togglingModelId={togglingModelId} - onTestModel={onTestModel} - modelTestStatus={modelTestStatus} - testingModelId={testingModelId} - providerId={providerId} - connectionId={selectedConnection?.id ?? ""} - autoHideFailed={autoHideFailed} - onAutoHideFailedChange={setAutoHideFailed} - /> -
- ); - } - - const importButton = ( -
- - {autoSyncToggle} - {!canImportModels && ( - {t("addConnectionToImport")} - )} -
- ); - - if (models.length === 0) { - return ( -
- {importButton} -

{t("noModelsConfigured")}

-
- ); - } - const modelsWithVisibility = models.map((model) => ({ - ...model, - isHidden: effectiveModelHidden(model.id), - })); - const filteredModels = modelsWithVisibility.filter((model) => { - const matchesQuery = matchesModelCatalogQuery(modelFilter, { - modelId: model.id, - modelName: model.name, - source: model.source, - }); - const matchesVisibility = - visibilityFilter === "all" - ? true - : visibilityFilter === "visible" - ? !model.isHidden - : model.isHidden; - return matchesQuery && matchesVisibility; - }); - const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length; - const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length; - const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; - const testAllTargets = filteredModels - .filter((m) => !m.isHidden) - .map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` })); - return ( -
- {importButton} - {modelsWithVisibility.length > 0 && ( - - handleBulkToggleModelHidden( - providerId, - filteredModels.map((model) => model.id), - false - ) - } - onDeselectAll={() => - handleBulkToggleModelHidden( - providerId, - filteredModels.map((model) => model.id), - true - ) - } - selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null} - deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null} - onTestAll={() => handleTestAll(testAllTargets)} - testingAll={testingAll} - testProgress={testProgress} - visibilityFilter={visibilityFilter} - onVisibilityFilterChange={setVisibilityFilter} - autoHideFailed={autoHideFailed} - onAutoHideFailedChange={setAutoHideFailed} - /> - )} -
- {filteredModels.map((model) => { - return ( - getUpstreamHeadersRecordForModel(model.id, p)} - saveModelCompatFlags={saveModelCompatFlags} - compatDisabled={compatSavingModelId === model.id} - onToggleHidden={(modelId, hidden) => - handleToggleModelHidden(providerId, modelId, hidden) - } - togglingHidden={togglingModelId === model.id} - onTestModel={onTestModel} - testStatus={modelTestStatus[model.id] || null} - testingModel={testingModelId === model.id} - /> - ); - })} - {filteredModels.length === 0 && modelFilter && ( -

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

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

{providerInfo.name}

- )} -
-

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

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

- download - Import from Zed Keychain -

-

- Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that - Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE - installed on this machine. -

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

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

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

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

-

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

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

{t("ccCompatibleValidationHint")}

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

{t("connections")}

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

{t("noConnectionsYet")}

-

{t("addFirstConnectionHint")}

- {!isCompatible && ( -
- {isCommandCode ? ( - <> - - - - ) : ( - <> - - {providerId === "qoder" && ( - - )} - {providerId === "codex" && ( - - )} - {providerId === "claude" && ( - - )} - {providerId === "gemini-cli" && ( - - )} - - )} -
- )} -
+ setShowOAuthModal(true)} + onOpenImportCodex={() => setImportCodexModalOpen(true)} + onOpenImportClaude={() => setImportClaudeModalOpen(true)} + onOpenImportGemini={() => setImportGeminiModalOpen(true)} + t={t} + /> ) : ( - (() => { - const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); - const hasAnyTag = sorted.some( - (c) => c.providerSpecificData?.tag as string | undefined - ); - const allSelected = selectedIds.size === connections.length && connections.length > 0; - const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length; - const bulkBusy = - batchUpdating !== null || batchRetesting || batchDeleting || batchTesting; - const bulkActions = selectedIds.size > 0 && ( -
- - - - -
- ); - - const isHealthy = (c: ConnectionRowConnection): boolean => { - const s = c.testStatus; - return c.isActive !== false && (!s || s === "active" || s === "success"); - }; - const STATUS_FILTER_OPTIONS = [ - { value: "all", label: t("filterAll", "All") }, - { value: "active", label: t("filterActive", "Active") }, - { value: "error", label: t("filterError", "Error") }, - { value: "banned", label: t("filterBanned", "Banned") }, - { - value: "credits_exhausted", - label: t("filterCreditsExhausted", "Credits Exhausted"), - }, - ]; - const filtered = - healthFilter === "all" - ? sorted - : sorted.filter((c) => { - if (healthFilter === "active") return isHealthy(c); - if (healthFilter === "error") - return ( - !isHealthy(c) && - c.testStatus !== "banned" && - c.testStatus !== "credits_exhausted" - ); - return c.testStatus === healthFilter; - }); - - const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); - const clampedPage = Math.min(page, totalFilteredPages - 1); - const pageStart = clampedPage * PAGE_SIZE; - const pageEnd = pageStart + PAGE_SIZE; - - const filterPills = ( -
- {STATUS_FILTER_OPTIONS.map((opt) => ( - - ))} -
- ); - - const paginationBar = - totalFilteredPages > 1 ? ( -
- - {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} - -
-
-
- ) : null; - - if (!hasAnyTag) { - const pageConnections = filtered.slice(pageStart, pageEnd); - const allSelected = - pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); - const someSelected = pageConnections.some((c) => selectedIds.has(c.id)); - return ( - <> -
-
- - {filterPills} -
- - {bulkActions} -
-
- {pageConnections.length === 0 ? ( -
- {t("noFilteredConnections", "No connections match the current filter.")} -
- ) : ( - pageConnections.map((conn, index) => ( - handleToggleSelectOne(conn.id)} - onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} - onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} - onToggleActive={(isActive) => - handleUpdateConnectionStatus(conn.id, isActive) - } - onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} - onToggleClaudeExtraUsage={(enabled) => - handleToggleClaudeExtraUsage(conn.id, enabled) - } - isCodex={providerId === "codex"} - isGeminiCli={providerId === "gemini-cli"} - isCcCompatible={isCcCompatible} - cliproxyapiEnabled={cpaProviderEnabled} - onToggleCliproxyapiMode={(enabled) => - handleToggleCliproxyapiMode(conn.id, enabled) - } - onToggleCodex5h={(enabled) => - handleToggleCodexLimit(conn.id, "use5h", enabled) - } - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" - ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) - : undefined - } - onRefreshToken={ - conn.authType === "oauth" - ? () => handleRefreshToken(conn.id) - : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => setApplyCodexModalConnectionId(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onApplyClaudeAuthLocal={ - providerId === "claude" - ? () => setApplyClaudeModalConnectionId(conn.id) - : undefined - } - isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} - onExportClaudeAuthFile={ - providerId === "claude" - ? () => handleExportClaudeAuthFile(conn.id) - : undefined - } - isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} - onApplyGeminiAuthLocal={ - providerId === "gemini-cli" - ? () => setApplyGeminiModalConnectionId(conn.id) - : undefined - } - isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} - onExportGeminiAuthFile={ - providerId === "gemini-cli" - ? () => handleExportGeminiAuthFile(conn.id) - : undefined - } - isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} - onProxy={() => - setProxyTarget({ - level: "key", - id: conn.id, - label: pickDisplayValue( - [conn.name, conn.email], - emailsVisible, - conn.id - ), - }) - } - hasProxy={!!connProxyMap[conn.id]?.proxy} - proxySource={connProxyMap[conn.id]?.level || null} - proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} - onToggleProxyEnabled={(enabled) => - handleToggleProxyEnabled(conn.id, enabled) - } - perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} - onTogglePerKeyProxyEnabled={(enabled) => - handleTogglePerKeyProxyEnabled(conn.id, enabled) - } - /> - )) - )} -
- {paginationBar} - - ); - } - - // Build ordered tag groups: untagged first, then alphabetically - const groupMap = new Map(); - for (const conn of filtered) { - const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; - if (!groupMap.has(tag)) groupMap.set(tag, []); - groupMap.get(tag)!.push(conn); - } - const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { - if (a === "") return -1; - if (b === "") return 1; - return compareTr(a, b); - }); - - return ( - <> - {selectedIds.size > 0 || connections.length > 0 ? ( -
-
- - {filterPills} -
- -
- {/* Distribute Proxies lives in the provider toolbar (top action bar); - removed the duplicate here that rendered simultaneously when nothing - was selected. Per-tag groups keep their own scoped button. */} - {bulkActions} -
-
- ) : null} -
- {groupKeys.map((tag, gi) => { - const groupConns = groupMap.get(tag)!; - return ( -
0 - ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" - : "" - } - > - {tag && ( -
- - label - - - {tag} - -
- - - {groupConns.length} - -
- )} -
- {groupConns.map((conn, index) => ( - handleToggleSelectOne(conn.id)} - onMoveUp={() => - handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) - } - onMoveDown={() => - handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) - } - onToggleActive={(isActive) => - handleUpdateConnectionStatus(conn.id, isActive) - } - onToggleRateLimit={(enabled) => - handleToggleRateLimit(conn.id, enabled) - } - onToggleClaudeExtraUsage={(enabled) => - handleToggleClaudeExtraUsage(conn.id, enabled) - } - isCodex={providerId === "codex"} - isGeminiCli={providerId === "gemini-cli"} - isCcCompatible={isCcCompatible} - cliproxyapiEnabled={cpaProviderEnabled} - onToggleCodex5h={(enabled) => - handleToggleCodexLimit(conn.id, "use5h", enabled) - } - onToggleCodexWeekly={(enabled) => - handleToggleCodexLimit(conn.id, "useWeekly", enabled) - } - onRetest={() => handleRetestConnection(conn.id)} - isRetesting={retestingId === conn.id} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - onReauth={ - conn.authType === "oauth" - ? () => gateConnectionFlow(() => setShowOAuthModal(true, conn)) - : undefined - } - onRefreshToken={ - conn.authType === "oauth" - ? () => handleRefreshToken(conn.id) - : undefined - } - isRefreshing={refreshingId === conn.id} - onApplyCodexAuthLocal={ - providerId === "codex" - ? () => setApplyCodexModalConnectionId(conn.id) - : undefined - } - isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} - onExportCodexAuthFile={ - providerId === "codex" - ? () => handleExportCodexAuthFile(conn.id) - : undefined - } - isExportingCodexAuthFile={exportingCodexAuthId === conn.id} - onApplyClaudeAuthLocal={ - providerId === "claude" - ? () => setApplyClaudeModalConnectionId(conn.id) - : undefined - } - isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} - onExportClaudeAuthFile={ - providerId === "claude" - ? () => handleExportClaudeAuthFile(conn.id) - : undefined - } - isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} - onApplyGeminiAuthLocal={ - providerId === "gemini-cli" - ? () => setApplyGeminiModalConnectionId(conn.id) - : undefined - } - isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} - onExportGeminiAuthFile={ - providerId === "gemini-cli" - ? () => handleExportGeminiAuthFile(conn.id) - : undefined - } - isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} - onProxy={() => - setProxyTarget({ - level: "key", - id: conn.id, - label: pickDisplayValue( - [conn.name, conn.email], - emailsVisible, - conn.id - ), - }) - } - hasProxy={!!connProxyMap[conn.id]?.proxy} - proxySource={connProxyMap[conn.id]?.level || null} - proxyHost={connProxyMap[conn.id]?.proxy?.host || null} - proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} - onToggleProxyEnabled={(enabled) => - handleToggleProxyEnabled(conn.id, enabled) - } - perKeyProxyEnabled={readBooleanToggle( - conn.perKeyProxyEnabled, - false - )} - onTogglePerKeyProxyEnabled={(enabled) => - handleTogglePerKeyProxyEnabled(conn.id, enabled) - } - /> - ))} -
-
- ); - })} -
- - ); - })() + { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onOpenOAuth={(conn) => gateConnectionFlow(() => setShowOAuthModal(true, conn))} + onSetProxyTarget={setProxyTarget} + onOpenApplyCodexModal={setApplyCodexModalConnectionId} + onExportCodexAuthFile={handleExportCodexAuthFile} + onOpenApplyClaudeModal={setApplyClaudeModalConnectionId} + onExportClaudeAuthFile={handleExportClaudeAuthFile} + onOpenApplyGeminiModal={setApplyGeminiModalConnectionId} + onExportGeminiAuthFile={handleExportGeminiAuthFile} + gateConnectionFlow={gateConnectionFlow} + t={t} + /> )} )} - - {isUpstreamProxyProvider && ( - -
-
-

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

-

- {providerText( - t, - "upstreamProxyManagedDescription", - "CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls." - )} -

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

{t("availableModels")}

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

{t("searchProvider")}

-

{t("searchProviderDesc")}

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

{t("perplexitySearchSharedKeyInfo")}

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

{t("googlePseInfo")}

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

{t("searxngInfo")}

-
- )} -
- )} + {isSearchProvider && } {/* Playground panel — rendered for providers that declare serviceKinds */} - {/* Modals */} - {showRiskNoticeModal && subscriptionRisk && ( - - )} - {!isUpstreamProxyProvider && - (providerId === "kiro" || providerId === "amazon-q" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : providerId === "cursor" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : providerId === "trae" ? ( - { - setShowOAuthModal(false); - }} - /> - ) : ( - { - setShowOAuthModal(false); - }} - /> - ))} - {providerId === "siliconflow" && ( - { - setSiliconFlowInitialBaseUrl(baseUrl); - setShowSiliconFlowEndpointModal(false); - setShowAddApiKeyModal(true); - }} - onClose={() => { - setShowSiliconFlowEndpointModal(false); - setSiliconFlowInitialBaseUrl(undefined); - }} - /> - )} - {!isUpstreamProxyProvider && ( - - )} - setBatchDeleteConfirmOpen(false)} - onConfirm={handleBatchDeleteConfirm} - title={t("batchDeleteConfirmTitle", "Delete connections")} - message={t("batchDeleteConfirm", { count: selectedIds.size })} - confirmText={t("batchDeleteConfirmButton", "Delete")} - cancelText={t("cancel", "Cancel")} - loading={batchDeleting} + {/* Modals — Phase 1t.5: extracted to components/ProviderModalsPanel.tsx */} + - {providerId === "codex" && applyCodexModalConnectionId && ( - setApplyCodexModalConnectionId(null)} - /> - )} - {!isUpstreamProxyProvider && ( - setShowEditModal(false)} - /> - )} - {!isUpstreamProxyProvider && isCompatible && ( - setShowEditNodeModal(false)} - isAnthropic={isAnthropicProtocolCompatible} - isCcCompatible={isCcCompatible} - /> - )} - {/* Codex CLI Guide Modal */} - setCodexCliGuideOpen(false)} /> - {/* Codex Import Auth Modal */} - {providerId === "codex" && importCodexModalOpen && ( - setImportCodexModalOpen(false)} - onSuccess={() => { - setImportCodexModalOpen(false); - void fetchConnections(); - }} - /> - )} - {providerId === "codex" && externalLinkModalOpen && ( - setExternalLinkModalOpen(false)} - title="Adicionar Externo — link do Codex" - > -
-

- Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a - página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso - único, expira em 15 minutos. -

- {externalLinkLoading ? ( -

Gerando link…

- ) : externalLinkError ? ( -

{externalLinkError}

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

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

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

{t("testResults")}

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

{String(batchTestResults.error)}

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

{importProgress.error}

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

- {log} -

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

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

- -
    -
  1. - - 1 - -
    -

    Acesse o chat do Adapta

    -

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

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

    Abra o DevTools

    -

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

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

    Vá em Application → Cookies

    -

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

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

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

    -

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

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

    Cole aqui e salve

    -

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

    -
    -
  10. -
- -
- Dica: O cookie __client tem - validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta - invalidar a sessão. -
-
-
- )}
); } -// ModelRow, ModelVisibilityToolbar, PassthroughModelsSection, PassthroughModelRow, -// CustomModelsSection, CompatibleModelsSection → components/ (Phase 1e — Issue #3501) - -// Phase 1d: CooldownTimer, inferErrorType, getStatusPresentation, ConnectionRow → components/ConnectionRow.tsx -// Phase 1d: ModelCompatPopover, recordToHeaderRows → components/ModelCompatPopover.tsx -// Phase 1d: SiliconFlowEndpointModal → components/SiliconFlowEndpointModal.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx index 9ee6137d20..3ed902b55d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1f.test.tsx @@ -10,9 +10,10 @@ // Uses createRoot + act to mount each hook inside a minimal wrapper component // so we test real React hook semantics without a full Next.js server context. -import React, { act } from "react"; +import React, { act, useEffect } from "react"; import { createRoot } from "react-dom/client"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import path from "node:path"; // --------------------------------------------------------------------------- // Global mocks required by the extracted hooks @@ -27,10 +28,7 @@ vi.mock("next/navigation", () => ({ vi.mock("next-intl", () => ({ useTranslations: () => (key: string, values?: Record) => { if (values) { - return Object.entries(values).reduce( - (acc, [k, v]) => acc.replace(`{${k}}`, String(v)), - key - ); + return Object.entries(values).reduce((acc, [k, v]) => acc.replace(`{${k}}`, String(v)), key); } return key; }, @@ -83,10 +81,13 @@ describe("useProviderConnections — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderConnections("openai", true, false); + const hookResult = useProviderConnections("openai", true, false); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ( - {String(result.connections.length)}|{String(result.batchTesting)} + {String(hookResult.connections.length)}|{String(hookResult.batchTesting)} ); } @@ -112,7 +113,10 @@ describe("useProviderConnections — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderConnections("openai", true, false); + const hookResult = useProviderConnections("openai", true, false); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ; } @@ -181,7 +185,10 @@ describe("useProviderSettings — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderSettings("openai"); + const hookResult = useProviderSettings("openai"); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ; } @@ -207,7 +214,10 @@ describe("useProviderSettings — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderSettings("codex"); + const hookResult = useProviderSettings("codex"); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ; } @@ -253,7 +263,10 @@ describe("useProviderModels — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderModels("openai", false); + const hookResult = useProviderModels("openai", false); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ; } @@ -275,7 +288,10 @@ describe("useProviderModels — initial state", () => { let result: HookResult | null = null; function TestWrapper() { - result = useProviderModels("openai", false); + const hookResult = useProviderModels("openai", false); + useEffect(() => { + result = hookResult; + }, [hookResult]); return ; } @@ -316,8 +332,13 @@ describe("useProviderModels — initial state", () => { // Cycle-safety: hooks must NOT import from ProviderDetailPageClient // --------------------------------------------------------------------------- -const HOOKS_DIR = - "/home/diegosouzapw/dev/proxys/OmniRoute/.worktrees/fix-3501-phase1f/src/app/(dashboard)/dashboard/providers/[id]/hooks"; +// Resolve the hooks dir from the repo root (vitest runs from cwd). Was a +// hardcoded absolute worktree path that broke the test outside that worktree +// (#3501 Phase 1g-1j). +const HOOKS_DIR = path.join( + process.cwd(), + "src/app/(dashboard)/dashboard/providers/[id]/hooks" +); describe("Cycle-safety — hooks do not import ProviderDetailPageClient", () => { // We allow the name in JSDoc comments; what we forbid is an actual ES import statement. diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx new file mode 100644 index 0000000000..2e416e306a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/AdaptaTutorialModal.tsx @@ -0,0 +1,120 @@ +"use client"; +import { Modal } from "@/shared/components"; + +type AdaptaTutorialModalProps = { + isOpen: boolean; + onClose: () => void; +}; + +export function AdaptaTutorialModal({ isOpen, onClose }: AdaptaTutorialModalProps) { + return ( + +
+

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

+ +
    +
  1. + + 1 + +
    +

    Acesse o chat do Adapta

    +

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

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

    Abra o DevTools

    +

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

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

    Vá em Application → Cookies

    +

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

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

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

    +

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

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

    Cole aqui e salve

    +

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

    +
    +
  10. +
+ +
+ Dica: O cookie __client tem + validade longa (meses). Só será necessário renová-lo se você sair da conta ou o Adapta + invalidar a sessão. +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx new file mode 100644 index 0000000000..8285b0b310 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/BatchTestResultsModal.tsx @@ -0,0 +1,128 @@ +"use client"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; + +type BatchTestResultsModalProps = { + batchTestResults: { + error?: any; + results?: Array<{ + connectionId?: string; + connectionName?: string; + valid?: boolean; + latencyMs?: number; + diagnosis?: { type?: string }; + }>; + summary?: { + passed: number; + failed: number; + total: number; + }; + } | null; + providerInfo: any; + providerId: string; + emailsVisible: boolean; + onClose: () => void; + t: any; +}; + +export default function BatchTestResultsModal({ + batchTestResults, + providerInfo, + providerId, + emailsVisible, + onClose, + t, +}: BatchTestResultsModalProps) { + if (!batchTestResults) return null; + + return ( +
+
+
e.stopPropagation()} + > +
+

{t("testResults")}

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

{String(batchTestResults.error)}

+
+ ) : ( +
+ {batchTestResults.summary && ( +
+ {providerInfo?.name || providerId} + + {t("passedCount", { count: batchTestResults.summary.passed })} + + {batchTestResults.summary.failed > 0 && ( + + {t("failedCount", { count: batchTestResults.summary.failed })} + + )} + + {t("testedCount", { count: batchTestResults.summary.total })} + +
+ )} + {(batchTestResults.results || []).map((r: any, i: number) => ( +
+ + {r.valid ? "check_circle" : "error"} + +
+ + {pickDisplayValue([r.connectionName], emailsVisible, r.connectionName)} + +
+ {r.latencyMs !== undefined && ( + + {t("millisecondsAbbr", { value: r.latencyMs })} + + )} + + {r.valid ? t("okShort") : r.diagnosis?.type || t("errorShort")} + +
+ ))} + {(!batchTestResults.results || batchTestResults.results.length === 0) && ( +
+ {t("noActiveConnectionsInGroup")} +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx new file mode 100644 index 0000000000..1b01f2eb7d --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleNodeCard.tsx @@ -0,0 +1,121 @@ +"use client"; + +// Phase 1t.2 extraction — Issue #3501 +import { useRouter } from "next/navigation"; +import { Card, Button } from "@/shared/components"; +import { getApiLabel, getApiPath } from "../providerPageHelpers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface ProviderNode { + baseUrl?: string; + apiType?: string; + chatPath?: string; + prefix?: string; + [key: string]: unknown; +} + +interface CompatibleNodeCardProps { + providerId: string; + providerNode: ProviderNode; + isCcCompatible: boolean; + isAnthropicCompatible: boolean; + isAnthropicProtocolCompatible: boolean; + gateConnectionFlow: (callback: () => void) => void; + openApiKeyAddFlow: () => void; + onOpenEditNodeModal: () => void; + t: ProviderMessageTranslator; +} + +export default function CompatibleNodeCard({ + providerId, + providerNode, + isCcCompatible, + isAnthropicCompatible, + isAnthropicProtocolCompatible, + gateConnectionFlow, + openApiKeyAddFlow, + onOpenEditNodeModal, + t, +}: CompatibleNodeCardProps) { + const router = useRouter(); + + return ( + +
+
+

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

+

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

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

{t("ccCompatibleValidationHint")}

+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx new file mode 100644 index 0000000000..4fd07cb763 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { Button, Toggle } from "@/shared/components"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; + +type ConnectionsHeaderToolbarProps = { + providerId: string; + providerInfo: any; // resolveDashboardProviderInfo result + isCompatible: boolean; + isCommandCode: boolean; + isOAuth: boolean; + providerSupportsPat: boolean; + connections: any[]; // ConnectionRowConnection[] + batchTesting: boolean; + batchRetesting: boolean; + retestingId: string | null; + distributingProxies: boolean; + proxyConfig: any; + // from useProviderSettings + preferClaudeCodeForUnprefixedClaudeModels: boolean; + claudeRoutingSettingsLoaded: boolean; + claudeRoutingSettingsLoadError: string | null; + savingClaudeRoutingPreference: boolean; + handleToggleClaudeRoutingPreference: () => void; + loadClaudeRoutingSettings: () => Promise; + codexGlobalServiceMode: string; + codexGlobalServiceModeOptions: Array<{ value: string; label: string }>; + codexSettingsLoaded: boolean; + codexSettingsLoadError: string | null; + savingCodexGlobalServiceMode: boolean; + handleChangeCodexGlobalServiceMode: (mode: any) => void; + loadCodexSettings: () => Promise; + // Modal triggers + onSetProxyTarget: (target: { level: string; id: string; label: string }) => void; + handleDistributeProxies: () => void; + handleBatchTestAll: () => void; + gateConnectionFlow: (callback: () => void) => void; + openApiKeyAddFlow: () => void; + openPrimaryAddFlow: () => void; + openExternalLinkFlow: () => void; + handleOpenCommandCodeConnect: () => void; + commandCodeAuthState: { phase: string }; + onOpenOAuthModal: () => void; + onOpenCodexCliGuide: () => void; + onOpenImportCodex: () => void; + onOpenImportClaude: () => void; + onOpenImportGemini: () => void; + t: ProviderMessageTranslator; +}; + +export default function ConnectionsHeaderToolbar({ + providerId, + providerInfo, + isCompatible, + isCommandCode, + isOAuth, + providerSupportsPat, + connections, + batchTesting, + batchRetesting, + retestingId, + distributingProxies, + proxyConfig, + preferClaudeCodeForUnprefixedClaudeModels, + claudeRoutingSettingsLoaded, + claudeRoutingSettingsLoadError, + savingClaudeRoutingPreference, + handleToggleClaudeRoutingPreference, + loadClaudeRoutingSettings, + codexGlobalServiceMode, + codexGlobalServiceModeOptions, + codexSettingsLoaded, + codexSettingsLoadError, + savingCodexGlobalServiceMode, + handleChangeCodexGlobalServiceMode, + loadCodexSettings, + onSetProxyTarget, + handleDistributeProxies, + handleBatchTestAll, + gateConnectionFlow, + openApiKeyAddFlow, + openPrimaryAddFlow, + openExternalLinkFlow, + handleOpenCommandCodeConnect, + commandCodeAuthState, + onOpenOAuthModal, + onOpenCodexCliGuide, + onOpenImportCodex, + onOpenImportClaude, + onOpenImportGemini, + t, +}: ConnectionsHeaderToolbarProps) { + return ( +
+
+

{t("connections")}

+ {providerId === "claude" && ( +
+ + alt_route + + + {providerText( + t, + "preferClaudeCodeForUnprefixedClaudeModelsLabel", + "Claude Code default" + )} + + + + {preferClaudeCodeForUnprefixedClaudeModels + ? providerText(t, "toggleOnShort", "On") + : providerText(t, "toggleOffShort", "Off")} + + {claudeRoutingSettingsLoadError ? ( + + ) : null} +
+ )} + {providerId === "codex" && ( +
+ + {providerText(t, "providerDetailServiceModeLabel", "Global service mode:")} + + + {codexSettingsLoadError ? ( + + ) : null} +
+ )} + {/* Provider-level proxy indicator/button */} + +
+
+ {connections.length > 0 && ( + + )} + {connections.length > 1 && ( + + )} + {!isCompatible ? ( + <> + {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} + + ) : ( + connections.length === 0 && ( + + ) + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx new file mode 100644 index 0000000000..592022be96 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx @@ -0,0 +1,630 @@ +"use client"; +import React from "react"; +import { type ConnectionRowConnection } from "./ConnectionRow"; +import ConnectionRow from "./ConnectionRow"; +import { Button } from "@/shared/components"; +import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import { readBooleanToggle, providerCountText } from "../providerPageHelpers"; +import { compareTr } from "@/shared/utils/turkishText"; +import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; + +type ConnectionsListPanelProps = { + connections: ConnectionRowConnection[]; + providerId: string; + isCcCompatible: boolean; + isOAuth: boolean; + codexGlobalServiceMode: CodexGlobalServiceMode | string; + selectedIds: Set; + batchUpdating: string | null; + batchRetesting: boolean; + batchDeleting: boolean; + batchTesting: boolean; + retestingId: string | null; + refreshingId: string | null; + distributingProxies: boolean; + healthFilter: string; + page: number; + PAGE_SIZE: number; + connProxyMap: Record; + proxyConfig: any; + applyingCodexAuthId: string | null; + exportingCodexAuthId: string | null; + applyingClaudeAuthId: string | null; + exportingClaudeAuthId: string | null; + applyingGeminiAuthId: string | null; + exportingGeminiAuthId: string | null; + emailsVisible: boolean; + // Setters + setSelectedIds: React.Dispatch>>; + setPage: React.Dispatch>; + setHealthFilter: (v: string) => void; + // Callbacks from useProviderConnections + handleDelete: (id: string) => void; + handleUpdateConnectionStatus: (id: string, isActive: boolean) => void; + handleToggleRateLimit: (id: string, enabled: boolean) => void; + handleToggleClaudeExtraUsage: (id: string, enabled: boolean) => void; + handleToggleCliproxyapiMode: (id: string, enabled: boolean) => void; + handleToggleCodexLimit: (id: string, type: "use5h" | "useWeekly", enabled: boolean) => void; + handleToggleProxyEnabled: (id: string, enabled: boolean) => void; + handleTogglePerKeyProxyEnabled: (id: string, enabled: boolean) => void; + handleRetestConnection: (id: string) => void; + handleRefreshToken: (id: string) => void; + handleSwapPriority: (a: ConnectionRowConnection, b: ConnectionRowConnection) => void; + handleBatchSetActive: (active: boolean) => void; + handleBatchDeleteOpenModal: () => void; + handleBatchRetest: () => void; + handleToggleSelectOne: (id: string) => void; + handleToggleSelectAll: () => void; + handleDistributeProxies: (tag?: string) => void; + cpaProviderEnabled: boolean; + // Modal triggers (all pass through from client, no closing over client internals) + onOpenEditModal: (conn: ConnectionRowConnection) => void; + onOpenOAuth: (conn: ConnectionRowConnection) => void; + onSetProxyTarget: (target: { level: string; id: string; label: string }) => void; + onOpenApplyCodexModal: (connId: string) => void; + onExportCodexAuthFile: (connId: string) => void; + onOpenApplyClaudeModal: (connId: string) => void; + onExportClaudeAuthFile: (connId: string) => void; + onOpenApplyGeminiModal: (connId: string) => void; + onExportGeminiAuthFile: (connId: string) => void; + gateConnectionFlow: (callback: () => void) => void; + t: any; // ProviderMessageTranslator +}; + +export default function ConnectionsListPanel({ + connections, + providerId, + isCcCompatible, + isOAuth, + codexGlobalServiceMode, + selectedIds, + batchUpdating, + batchRetesting, + batchDeleting, + batchTesting, + retestingId, + refreshingId, + distributingProxies, + healthFilter, + page, + PAGE_SIZE, + connProxyMap, + proxyConfig, + applyingCodexAuthId, + exportingCodexAuthId, + applyingClaudeAuthId, + exportingClaudeAuthId, + applyingGeminiAuthId, + exportingGeminiAuthId, + emailsVisible, + setSelectedIds, + setPage, + setHealthFilter, + handleDelete, + handleUpdateConnectionStatus, + handleToggleRateLimit, + handleToggleClaudeExtraUsage, + handleToggleCliproxyapiMode, + handleToggleCodexLimit, + handleToggleProxyEnabled, + handleTogglePerKeyProxyEnabled, + handleRetestConnection, + handleRefreshToken, + handleSwapPriority, + handleBatchSetActive, + handleBatchDeleteOpenModal, + handleBatchRetest, + handleToggleSelectOne, + handleToggleSelectAll, + handleDistributeProxies, + cpaProviderEnabled, + onOpenEditModal, + onOpenOAuth, + onSetProxyTarget, + onOpenApplyCodexModal, + onExportCodexAuthFile, + onOpenApplyClaudeModal, + onExportClaudeAuthFile, + onOpenApplyGeminiModal, + onExportGeminiAuthFile, + gateConnectionFlow, + t, +}: ConnectionsListPanelProps) { + const sorted = [...connections].sort((a, b) => (a.priority || 0) - (b.priority || 0)); + const hasAnyTag = sorted.some((c) => c.providerSpecificData?.tag as string | undefined); + const allSelected = selectedIds.size === connections.length && connections.length > 0; + const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length; + const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting; + const bulkActions = selectedIds.size > 0 && ( +
+ + + + +
+ ); + + const isHealthy = (c: ConnectionRowConnection): boolean => { + const s = c.testStatus; + return c.isActive !== false && (!s || s === "active" || s === "success"); + }; + const STATUS_FILTER_OPTIONS = [ + { value: "all", label: t("filterAll", "All") }, + { value: "active", label: t("filterActive", "Active") }, + { value: "error", label: t("filterError", "Error") }, + { value: "banned", label: t("filterBanned", "Banned") }, + { + value: "credits_exhausted", + label: t("filterCreditsExhausted", "Credits Exhausted"), + }, + ]; + const filtered = + healthFilter === "all" + ? sorted + : sorted.filter((c) => { + if (healthFilter === "active") return isHealthy(c); + if (healthFilter === "error") + return ( + !isHealthy(c) && + c.testStatus !== "banned" && + c.testStatus !== "credits_exhausted" + ); + return c.testStatus === healthFilter; + }); + + const totalFilteredPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const clampedPage = Math.min(page, totalFilteredPages - 1); + const pageStart = clampedPage * PAGE_SIZE; + const pageEnd = pageStart + PAGE_SIZE; + + const filterPills = ( +
+ {STATUS_FILTER_OPTIONS.map((opt) => ( + + ))} +
+ ); + + const paginationBar = + totalFilteredPages > 1 ? ( +
+ + {pageStart + 1}–{Math.min(pageEnd, filtered.length)} / {filtered.length} + +
+
+
+ ) : null; + + if (!hasAnyTag) { + const pageConnections = filtered.slice(pageStart, pageEnd); + const allSelectedPage = + pageConnections.length > 0 && pageConnections.every((c) => selectedIds.has(c.id)); + const someSelectedPage = pageConnections.some((c) => selectedIds.has(c.id)); + return ( + <> +
+
+ + {filterPills} +
+ + {bulkActions} +
+
+ {pageConnections.length === 0 ? ( +
+ {t("noFilteredConnections", "No connections match the current filter.")} +
+ ) : ( + pageConnections.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} + onMoveDown={() => handleSwapPriority(conn, sorted[index + 1])} + onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } + onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled)} + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => onOpenEditModal(conn)} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => onOpenOAuth(conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" ? () => handleRefreshToken(conn.id) : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" ? () => onOpenApplyCodexModal(conn.id) : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" ? () => onExportCodexAuthFile(conn.id) : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" ? () => onOpenApplyClaudeModal(conn.id) : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" ? () => onExportClaudeAuthFile(conn.id) : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => onOpenApplyGeminiModal(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => onExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + onSetProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => handleToggleProxyEnabled(conn.id, enabled)} + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} + onTogglePerKeyProxyEnabled={(enabled) => + handleTogglePerKeyProxyEnabled(conn.id, enabled) + } + /> + )) + )} +
+ {paginationBar} + + ); + } + + // Build ordered tag groups: untagged first, then alphabetically + const groupMap = new Map(); + for (const conn of filtered) { + const tag = (conn.providerSpecificData?.tag as string | undefined)?.trim() || ""; + if (!groupMap.has(tag)) groupMap.set(tag, []); + groupMap.get(tag)!.push(conn); + } + const groupKeys = Array.from(groupMap.keys()).sort((a, b) => { + if (a === "") return -1; + if (b === "") return 1; + return compareTr(a, b); + }); + + return ( + <> + {selectedIds.size > 0 || connections.length > 0 ? ( +
+
+ + {filterPills} +
+ +
+ {/* Distribute Proxies lives in the provider toolbar (top action bar); + removed the duplicate here that rendered simultaneously when nothing + was selected. Per-tag groups keep their own scoped button. */} + {bulkActions} +
+
+ ) : null} +
+ {groupKeys.map((tag, gi) => { + const groupConns = groupMap.get(tag)!; + return ( +
0 + ? "border-t border-black/[0.06] dark:border-white/[0.06] mt-1 pt-1" + : "" + } + > + {tag && ( +
+ + label + + + {tag} + +
+ + {groupConns.length} +
+ )} +
+ {groupConns.map((conn, index) => ( + handleToggleSelectOne(conn.id)} + onMoveUp={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) - 1]) + } + onMoveDown={() => + handleSwapPriority(conn, sorted[sorted.indexOf(conn) + 1]) + } + onToggleActive={(isActive) => + handleUpdateConnectionStatus(conn.id, isActive) + } + onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } + isCodex={providerId === "codex"} + isGeminiCli={providerId === "gemini-cli"} + isCcCompatible={isCcCompatible} + cliproxyapiEnabled={cpaProviderEnabled} + onToggleCliproxyapiMode={(enabled) => + handleToggleCliproxyapiMode(conn.id, enabled) + } + onToggleCodex5h={(enabled) => + handleToggleCodexLimit(conn.id, "use5h", enabled) + } + onToggleCodexWeekly={(enabled) => + handleToggleCodexLimit(conn.id, "useWeekly", enabled) + } + onRetest={() => handleRetestConnection(conn.id)} + isRetesting={retestingId === conn.id} + onEdit={() => onOpenEditModal(conn)} + onDelete={() => handleDelete(conn.id)} + onReauth={ + conn.authType === "oauth" + ? () => gateConnectionFlow(() => onOpenOAuth(conn)) + : undefined + } + onRefreshToken={ + conn.authType === "oauth" + ? () => handleRefreshToken(conn.id) + : undefined + } + isRefreshing={refreshingId === conn.id} + onApplyCodexAuthLocal={ + providerId === "codex" + ? () => onOpenApplyCodexModal(conn.id) + : undefined + } + isApplyingCodexAuthLocal={applyingCodexAuthId === conn.id} + onExportCodexAuthFile={ + providerId === "codex" + ? () => onExportCodexAuthFile(conn.id) + : undefined + } + isExportingCodexAuthFile={exportingCodexAuthId === conn.id} + onApplyClaudeAuthLocal={ + providerId === "claude" + ? () => onOpenApplyClaudeModal(conn.id) + : undefined + } + isApplyingClaudeAuthLocal={applyingClaudeAuthId === conn.id} + onExportClaudeAuthFile={ + providerId === "claude" + ? () => onExportClaudeAuthFile(conn.id) + : undefined + } + isExportingClaudeAuthFile={exportingClaudeAuthId === conn.id} + onApplyGeminiAuthLocal={ + providerId === "gemini-cli" + ? () => onOpenApplyGeminiModal(conn.id) + : undefined + } + isApplyingGeminiAuthLocal={applyingGeminiAuthId === conn.id} + onExportGeminiAuthFile={ + providerId === "gemini-cli" + ? () => onExportGeminiAuthFile(conn.id) + : undefined + } + isExportingGeminiAuthFile={exportingGeminiAuthId === conn.id} + onProxy={() => + onSetProxyTarget({ + level: "key", + id: conn.id, + label: pickDisplayValue([conn.name, conn.email], emailsVisible, conn.id), + }) + } + hasProxy={!!connProxyMap[conn.id]?.proxy} + proxySource={connProxyMap[conn.id]?.level || null} + proxyHost={connProxyMap[conn.id]?.proxy?.host || null} + proxyEnabled={readBooleanToggle(conn.proxyEnabled, true)} + onToggleProxyEnabled={(enabled) => + handleToggleProxyEnabled(conn.id, enabled) + } + perKeyProxyEnabled={readBooleanToggle(conn.perKeyProxyEnabled, false)} + onTogglePerKeyProxyEnabled={(enabled) => + handleTogglePerKeyProxyEnabled(conn.id, enabled) + } + /> + ))} +
+
+ ); + })} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx new file mode 100644 index 0000000000..79d073ecf3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx @@ -0,0 +1,131 @@ +"use client"; + +// Phase 1t.6 extraction — Issue #3501 +import { Button } from "@/shared/components"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface CommandCodeAuthState { + phase: string; + [key: string]: unknown; +} + +interface EmptyConnectionsPlaceholderProps { + isOAuth: boolean; + isCompatible: boolean; + isCommandCode: boolean; + providerId: string; + providerSupportsPat: boolean; + commandCodeAuthState: CommandCodeAuthState; + gateConnectionFlow: (callback: () => void) => void; + openApiKeyAddFlow: () => void; + openPrimaryAddFlow: () => void; + handleOpenCommandCodeConnect: () => void; + onOpenOAuthModal: () => void; + onOpenImportCodex: () => void; + onOpenImportClaude: () => void; + onOpenImportGemini: () => void; + t: ProviderMessageTranslator; +} + +export default function EmptyConnectionsPlaceholder({ + isOAuth, + isCompatible, + isCommandCode, + providerId, + providerSupportsPat, + commandCodeAuthState, + gateConnectionFlow, + openApiKeyAddFlow, + openPrimaryAddFlow, + handleOpenCommandCodeConnect, + onOpenOAuthModal, + onOpenImportCodex, + onOpenImportClaude, + onOpenImportGemini, + t, +}: EmptyConnectionsPlaceholderProps) { + return ( +
+
+ + {isOAuth ? "lock" : "key"} + +
+

{t("noConnectionsYet")}

+

{t("addFirstConnectionHint")}

+ {!isCompatible && ( +
+ {isCommandCode ? ( + <> + + + + ) : ( + <> + + {providerId === "qoder" && ( + + )} + {providerId === "codex" && ( + + )} + {providerId === "claude" && ( + + )} + {providerId === "gemini-cli" && ( + + )} + + )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx new file mode 100644 index 0000000000..02a4930465 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ExternalLinkModal.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Modal, Button } from "@/shared/components"; + +type ExternalLinkModalProps = { + isOpen: boolean; + onClose: () => void; + loading: boolean; + error: string | null; + url: string; + copied: string | false; + onCopy: (text: string, key: string) => void; +}; + +export default function ExternalLinkModal({ + isOpen, + onClose, + loading, + error, + url, + copied, + onCopy, +}: ExternalLinkModalProps) { + return ( + +
+

+ Compartilhe este link com quem vai autenticar a conta do Codex. A pessoa abre a + página, faz o login da OpenAI no próprio navegador e a conexão é cadastrada aqui. Uso + único, expira em 15 minutos. +

+ {loading ? ( +

Gerando link…

+ ) : error ? ( +

{error}

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

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

+ + ) : null} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx new file mode 100644 index 0000000000..905749a8bf --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ImportProgressModal.tsx @@ -0,0 +1,142 @@ +"use client"; + +/** + * ImportProgressModal — Issue #3501 Phase 1k + * + * Extracted from the inline Import Progress Modal JSX in ProviderDetailPageClient. + * Pure presentational component driven entirely by props. + * + * Cycle-safe: no import from ProviderDetailPageClient. + */ + +import { Modal } from "@/shared/components"; +import type { ImportProgress } from "../hooks/useModelImportHandlers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface ImportProgressModalProps { + importProgress: ImportProgress; + isOpen: boolean; + onClose: () => void; + t: ProviderMessageTranslator; +} + +export default function ImportProgressModal({ + importProgress, + isOpen, + onClose, + t, +}: ImportProgressModalProps) { + return ( + +
+ {/* Status text */} +
+ {importProgress.phase === "fetching" && ( + + progress_activity + + )} + {importProgress.phase === "importing" && ( + + progress_activity + + )} + {importProgress.phase === "done" && ( + check_circle + )} + {importProgress.phase === "error" && ( + error + )} + {importProgress.status} +
+ + {/* Progress bar */} + {(importProgress.phase === "importing" || importProgress.phase === "done") && + importProgress.total > 0 && ( +
+
+ + {importProgress.current} / {importProgress.total} + + + {Math.round((importProgress.current / importProgress.total) * 100)}% + +
+
+
+
+
+ )} + + {/* Fetching indeterminate bar */} + {importProgress.phase === "fetching" && ( +
+
+
+ )} + + {/* Error message */} + {importProgress.phase === "error" && importProgress.error && ( +
+

{importProgress.error}

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

+ {log} +

+ ))} +
+
+ )} + + {/* Close button */} + {importProgress.phase === "done" && ( +
+ +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx index 222016da5d..eeaff28cd9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx @@ -14,11 +14,16 @@ */ import React, { useState, useMemo } from "react"; import { Button } from "@/shared/components"; -import { matchesModelCatalogQuery, normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; +import { + matchesModelCatalogQuery, + normalizeModelCatalogSource, +} from "@/shared/utils/modelCatalogSearch"; import { useNotificationStore } from "@/store/notificationStore"; import { buildCompatMap, providerText, + testAllResultsText, + evaluateTestAllEntry, buildPassthroughTestBody, shouldSwitchToVisibleFilter, type CompatModelRow, @@ -53,10 +58,7 @@ export interface PassthroughModelsSectionProps { effectiveModelNormalize: (alias: string) => boolean; effectiveModelPreserveDeveloper: (alias: string) => boolean; getUpstreamHeadersRecord: (modelId: string, protocol: string) => Record; - saveModelCompatFlags: ( - modelId: string, - flags: ModelCompatSavePatchPassthrough - ) => Promise; + saveModelCompatFlags: (modelId: string, flags: ModelCompatSavePatchPassthrough) => Promise; compatSavingModelId?: string; isModelHidden: (modelId: string) => boolean; onToggleHidden: (modelId: string, hidden: boolean) => Promise; @@ -65,6 +67,8 @@ export interface PassthroughModelsSectionProps { togglingModelId?: string | null; onTestModel?: (modelId: string, fullModel: string) => Promise; modelTestStatus?: Record; + /** Report a model's test-all result so the parent updates the green/red icon. */ + onModelTestStatusChange?: (modelId: string, status: "ok" | "error") => void; testingModelId?: string | null; providerId: string; connectionId: string; @@ -102,6 +106,7 @@ export default function PassthroughModelsSection({ togglingModelId, onTestModel, modelTestStatus, + onModelTestStatusChange, testingModelId, providerId, connectionId, @@ -116,7 +121,8 @@ export default function PassthroughModelsSection({ const [testingAll, setTestingAll] = useState(false); const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); const [localAutoHideFailed, setLocalAutoHideFailed] = useState(true); - const autoHideFailed = autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed; + const autoHideFailed = + autoHideFailedProp !== undefined ? autoHideFailedProp : localAutoHideFailed; const setAutoHideFailed = onAutoHideFailedChange ?? setLocalAutoHideFailed; const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); const notify = useNotificationStore(); @@ -162,22 +168,26 @@ export default function PassthroughModelsSection({ }).then((r) => r.json()); const entry = result.results?.[model.modelId]; - if (entry?.status === "ok") { + const outcome = evaluateTestAllEntry(entry, autoHideFailed); + // Paint the per-model icon green/red, same as the single-model ▶ test. + onModelTestStatusChange?.(model.modelId, outcome.status); + if (outcome.status === "ok") { ok++; } else { error++; - if (autoHideFailed && !entry?.rateLimited && !entry?.isTimeout) { + if (outcome.shouldHide) { await onToggleHidden(model.modelId, true); hiddenCount++; } } } catch (e) { error++; + onModelTestStatusChange?.(model.modelId, "error"); } setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null)); } - notify.info(providerText(t, "testAllResults", "{ok} ok, {error} error", { ok, error })); + notify.info(testAllResultsText(t, ok, ok + error)); if (hiddenCount > 0) { notify.info(providerText(t, "testAllFailedHidden", "{count} hidden", { count: hiddenCount })); // Bug #3610 fix 3: switch to "visible" filter so hidden models disappear on-screen diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx new file mode 100644 index 0000000000..7959d7e431 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -0,0 +1,431 @@ +"use client"; + +// Phase 1t.5 extraction — Issue #3501 +// Pure composition of all modal elements rendered by ProviderDetailPageClient. +import { ConfirmModal, OAuthModal, KiroOAuthWrapper, CursorAuthModal, TraeAuthModal, ProxyConfigModal } from "@/shared/components"; +import RiskNoticeModal from "../../components/RiskNoticeModal"; +import CodexCliGuideModal from "../../components/CodexCliGuideModal"; +import SiliconFlowEndpointModal from "./SiliconFlowEndpointModal"; +import AddApiKeyModal from "./modals/AddApiKeyModal"; +import EditConnectionModal from "./modals/EditConnectionModal"; +import EditCompatibleNodeModal from "./modals/EditCompatibleNodeModal"; +import ExternalLinkModal from "./ExternalLinkModal"; +import BatchTestResultsModal from "./BatchTestResultsModal"; +import ImportProgressModal from "./ImportProgressModal"; +import { AdaptaTutorialModal } from "./AdaptaTutorialModal"; +import { + ImportCodexAuthModal, + ApplyCodexAuthModal, +} from "./modals/ImportCodexAuthModal"; +import { + ImportClaudeAuthModal, + ApplyClaudeAuthModal, +} from "./modals/ImportClaudeAuthModal"; +import { + ImportGeminiAuthModal, + ApplyGeminiAuthModal, +} from "./modals/ImportGeminiAuthModal"; +import { type ConnectionRowConnection } from "./ConnectionRow"; +import { type BatchTestResults } from "../hooks/useProviderConnections"; +import { type ImportProgress } from "../hooks/useModelImportHandlers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface ProviderInfo { + name: string; + riskNoticeVariant?: string; + [key: string]: unknown; +} + +interface ProxyTarget { + level: string; + id: string; + label: string; +} + +interface ProviderModalsPanelProps { + providerId: string; + providerInfo: ProviderInfo; + isCompatible: boolean; + isAnthropicProtocolCompatible: boolean; + isCcCompatible: boolean; + isCommandCode: boolean; + isUpstreamProxyProvider: boolean; + subscriptionRisk: boolean; + // Risk notice + showRiskNoticeModal: boolean; + handleConfirmRiskNotice: () => void; + handleCancelRiskNotice: () => void; + // OAuth + showOAuthModal: boolean; + reauthConnection: ConnectionRowConnection | null; + handleOAuthSuccess: () => void; + setShowOAuthModal: (show: boolean) => void; + // SiliconFlow + showSiliconFlowEndpointModal: boolean; + setSiliconFlowInitialBaseUrl: (url: string | undefined) => void; + setShowSiliconFlowEndpointModal: (open: boolean) => void; + setShowAddApiKeyModal: (open: boolean) => void; + // AddApiKey + showAddApiKeyModal: boolean; + siliconFlowInitialBaseUrl: string | undefined; + commandCodeAuthState: { phase: string; [key: string]: unknown }; + handleStartCommandCodeAuth: () => void; + handleSaveApiKey: (data: any) => Promise; + handleCloseAddApiKeyModal: () => void; + // Batch delete confirm + batchDeleteConfirmOpen: boolean; + setBatchDeleteConfirmOpen: (open: boolean) => void; + handleBatchDeleteConfirm: () => void; + selectedIds: Set; + batchDeleting: boolean; + // Codex auth + applyCodexModalConnectionId: string | null; + setApplyCodexModalConnectionId: (id: string | null) => void; + applyingCodexAuthId: string | null; + handleApplyCodexAuthLocal: (id: string) => Promise; + importCodexModalOpen: boolean; + setImportCodexModalOpen: (open: boolean) => void; + fetchConnections: () => Promise; + // External link + externalLinkModalOpen: boolean; + setExternalLinkModalOpen: (open: boolean) => void; + externalLinkLoading: boolean; + externalLinkError: string | null; + externalLinkUrl: string | null; + externalLinkCopied: boolean; + externalLinkCopy: () => void; + // Edit connection + showEditModal: boolean; + setShowEditModal: (open: boolean) => void; + selectedConnection: { id: string } | null; + handleUpdateConnection: (data: any) => Promise; + // Edit compatible node + showEditNodeModal: boolean; + setShowEditNodeModal: (open: boolean) => void; + providerNode: any; + handleUpdateNode: (data: any) => Promise; + // Codex CLI guide + codexCliGuideOpen: boolean; + setCodexCliGuideOpen: (open: boolean) => void; + // Claude auth + applyClaudeModalConnectionId: string | null; + setApplyClaudeModalConnectionId: (id: string | null) => void; + applyingClaudeAuthId: string | null; + handleApplyClaudeAuthLocal: (id: string) => Promise; + importClaudeModalOpen: boolean; + setImportClaudeModalOpen: (open: boolean) => void; + // Gemini auth + applyGeminiModalConnectionId: string | null; + setApplyGeminiModalConnectionId: (id: string | null) => void; + applyingGeminiAuthId: string | null; + handleApplyGeminiAuthLocal: (id: string) => Promise; + importGeminiModalOpen: boolean; + setImportGeminiModalOpen: (open: boolean) => void; + // Batch test results + batchTestResults: BatchTestResults | null; + setBatchTestResults: (r: BatchTestResults | null) => void; + emailsVisible: boolean; + // Proxy config + proxyTarget: ProxyTarget | null; + setProxyTarget: (t: ProxyTarget | null) => void; + fetchProxyConfig: () => Promise; + // Import progress + importProgress: ImportProgress; + showImportModal: boolean; + setShowImportModal: (open: boolean) => void; + // Tutorial + showTutorialModal: boolean; + setShowTutorialModal: (open: boolean) => void; + t: ProviderMessageTranslator; +} + +export default function ProviderModalsPanel({ + providerId, + providerInfo, + isCompatible, + isAnthropicProtocolCompatible, + isCcCompatible, + isUpstreamProxyProvider, + subscriptionRisk, + showRiskNoticeModal, + handleConfirmRiskNotice, + handleCancelRiskNotice, + showOAuthModal, + reauthConnection, + handleOAuthSuccess, + setShowOAuthModal, + showSiliconFlowEndpointModal, + setSiliconFlowInitialBaseUrl, + setShowSiliconFlowEndpointModal, + setShowAddApiKeyModal, + showAddApiKeyModal, + siliconFlowInitialBaseUrl, + commandCodeAuthState, + handleStartCommandCodeAuth, + handleSaveApiKey, + handleCloseAddApiKeyModal, + isCommandCode, + batchDeleteConfirmOpen, + setBatchDeleteConfirmOpen, + handleBatchDeleteConfirm, + selectedIds, + batchDeleting, + applyCodexModalConnectionId, + setApplyCodexModalConnectionId, + applyingCodexAuthId, + handleApplyCodexAuthLocal, + importCodexModalOpen, + setImportCodexModalOpen, + fetchConnections, + externalLinkModalOpen, + setExternalLinkModalOpen, + externalLinkLoading, + externalLinkError, + externalLinkUrl, + externalLinkCopied, + externalLinkCopy, + showEditModal, + setShowEditModal, + selectedConnection, + handleUpdateConnection, + showEditNodeModal, + setShowEditNodeModal, + providerNode, + handleUpdateNode, + codexCliGuideOpen, + setCodexCliGuideOpen, + applyClaudeModalConnectionId, + setApplyClaudeModalConnectionId, + applyingClaudeAuthId, + handleApplyClaudeAuthLocal, + importClaudeModalOpen, + setImportClaudeModalOpen, + applyGeminiModalConnectionId, + setApplyGeminiModalConnectionId, + applyingGeminiAuthId, + handleApplyGeminiAuthLocal, + importGeminiModalOpen, + setImportGeminiModalOpen, + batchTestResults, + setBatchTestResults, + emailsVisible, + proxyTarget, + setProxyTarget, + fetchProxyConfig, + importProgress, + showImportModal, + setShowImportModal, + showTutorialModal, + setShowTutorialModal, + t, +}: ProviderModalsPanelProps) { + return ( + <> + {showRiskNoticeModal && subscriptionRisk && ( + + )} + {!isUpstreamProxyProvider && + (providerId === "kiro" || providerId === "amazon-q" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "cursor" ? ( + setShowOAuthModal(false)} + /> + ) : providerId === "trae" ? ( + setShowOAuthModal(false)} + /> + ) : ( + setShowOAuthModal(false)} + /> + ))} + {providerId === "siliconflow" && ( + { + setSiliconFlowInitialBaseUrl(baseUrl); + setShowSiliconFlowEndpointModal(false); + setShowAddApiKeyModal(true); + }} + onClose={() => { + setShowSiliconFlowEndpointModal(false); + setSiliconFlowInitialBaseUrl(undefined); + }} + /> + )} + {!isUpstreamProxyProvider && ( + + )} + setBatchDeleteConfirmOpen(false)} + onConfirm={handleBatchDeleteConfirm} + title={t("batchDeleteConfirmTitle", "Delete connections")} + message={t("batchDeleteConfirm", { count: selectedIds.size })} + confirmText={t("batchDeleteConfirmButton", "Delete")} + cancelText={t("cancel", "Cancel")} + loading={batchDeleting} + /> + {providerId === "codex" && applyCodexModalConnectionId && ( + setApplyCodexModalConnectionId(null)} + /> + )} + {!isUpstreamProxyProvider && ( + setShowEditModal(false)} + /> + )} + {!isUpstreamProxyProvider && isCompatible && ( + setShowEditNodeModal(false)} + isAnthropic={isAnthropicProtocolCompatible} + isCcCompatible={isCcCompatible} + /> + )} + setCodexCliGuideOpen(false)} /> + {providerId === "codex" && importCodexModalOpen && ( + setImportCodexModalOpen(false)} + onSuccess={() => { + setImportCodexModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "codex" && externalLinkModalOpen && ( + setExternalLinkModalOpen(false)} + loading={externalLinkLoading} + error={externalLinkError} + url={externalLinkUrl} + copied={externalLinkCopied} + onCopy={externalLinkCopy} + /> + )} + {providerId === "claude" && applyClaudeModalConnectionId && ( + setApplyClaudeModalConnectionId(null)} + /> + )} + {providerId === "claude" && importClaudeModalOpen && ( + setImportClaudeModalOpen(false)} + onSuccess={() => { + setImportClaudeModalOpen(false); + void fetchConnections(); + }} + /> + )} + {providerId === "gemini-cli" && applyGeminiModalConnectionId && ( + setApplyGeminiModalConnectionId(null)} + /> + )} + {providerId === "gemini-cli" && importGeminiModalOpen && ( + setImportGeminiModalOpen(false)} + onSuccess={() => { + setImportGeminiModalOpen(false); + void fetchConnections(); + }} + /> + )} + setBatchTestResults(null)} + t={t} + /> + {proxyTarget && ( + setProxyTarget(null)} + level={proxyTarget.level} + levelId={proxyTarget.id} + levelLabel={proxyTarget.label} + onSaved={() => { + void fetchProxyConfig(); + }} + /> + )} + { + if (importProgress.phase === "done" || importProgress.phase === "error") { + setShowImportModal(false); + } + }} + t={t} + /> + {providerId === "adapta-web" && ( + setShowTutorialModal(false)} + /> + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx new file mode 100644 index 0000000000..c8f0d7c572 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx @@ -0,0 +1,469 @@ +"use client"; + +/** + * ProviderModelsSection — Issue #3501 Phase 1m + * + * Extracted from the renderModelsSection() inline function in + * ProviderDetailPageClient. Receives all model/compat state + handlers + * as props (from useModelImportHandlers, useModelVisibilityHandlers, + * useModelCompatState, useProviderModels). + * + * Cycle-safe: no import from ProviderDetailPageClient. + */ + +import { Button } from "@/shared/components"; +import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch"; +import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import ModelRow, { ModelVisibilityToolbar } from "./ModelRow"; +import PassthroughModelsSection from "./PassthroughModelsSection"; +import CompatibleModelsSection from "./CompatibleModelsSection"; +import type { ModelCompatSavePatch } from "../hooks/useModelVisibilityHandlers"; + +export interface ProviderModelsSectionProps { + // Provider identity + providerId: string; + providerAlias: string; + providerStorageAlias: string; + providerDisplayAlias: string; + providerInfo: { + name?: string; + passthroughModels?: boolean; + } | null; + + // Provider-type flags + isCcCompatible: boolean; + isAnthropicCompatible: boolean; + isAnthropicProtocolCompatible: boolean; + isManagedAvailableModelsProvider: boolean; + compatibleSupportsModelImport: boolean; + + // Models data + models: Array<{ id: string; name?: string; source?: string }>; + modelMeta: { customModels: any[]; modelCompatOverrides?: any[] }; + modelAliases: Record; + syncedAvailableModels: any[]; + compatibleFallbackModels: any[]; + + // Clipboard + copied: string | null; + onCopy: (text: string) => void; + + // Model alias handlers + onSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + onDeleteAlias: (alias: string) => Promise; + fetchProviderModelMeta: () => Promise; + + // Connections + connections: any[]; + selectedConnection: any; + + // Phase 1k: import handlers + canImportModels: boolean; + importingModels: boolean; + handleImportModels: () => Promise; + isAutoSyncEnabled: boolean; + togglingAutoSync: boolean; + handleToggleAutoSync: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + + // Phase 1l: visibility handlers + compatSavingModelId: string | null; + togglingModelId: string | null; + bulkVisibilityAction: "select" | "deselect" | null; + clearingModels: boolean; + modelFilter: string; + testingModelId: string | null; + modelTestStatus: Record; + onModelTestStatusChange: (modelId: string, status: "ok" | "error") => void; + testingAll: boolean; + testProgress: { done: number; total: number } | null; + autoHideFailed: boolean; + visibilityFilter: "all" | "visible" | "hidden"; + providerAliasEntries: [string, string][]; + setModelFilter: (v: string) => void; + setAutoHideFailed: (v: boolean) => void; + setVisibilityFilter: (v: "all" | "visible" | "hidden") => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + + // Compat state (from useModelCompatState) + effectiveModelNormalize: (modelId: string, protocol?: string) => boolean; + effectiveModelPreserveDeveloper: (modelId: string, protocol?: string) => boolean; + effectiveModelHidden: (modelId: string) => boolean; + getUpstreamHeadersRecordForModel: (modelId: string, protocol: string) => Record; + + // Translation + t: ProviderMessageTranslator; +} + +export default function ProviderModelsSection({ + providerId, + providerAlias, + providerStorageAlias, + providerDisplayAlias, + providerInfo, + isCcCompatible, + isAnthropicCompatible, + isAnthropicProtocolCompatible, + isManagedAvailableModelsProvider, + compatibleSupportsModelImport, + models, + modelMeta, + modelAliases, + syncedAvailableModels, + compatibleFallbackModels, + copied, + onCopy, + onSetAlias, + onDeleteAlias, + fetchProviderModelMeta, + connections, + selectedConnection, + canImportModels, + importingModels, + handleImportModels, + isAutoSyncEnabled, + togglingAutoSync, + handleToggleAutoSync, + handleCompatibleImportWithProgress, + compatSavingModelId, + togglingModelId, + bulkVisibilityAction, + clearingModels, + modelFilter, + testingModelId, + modelTestStatus, + onModelTestStatusChange, + testingAll, + testProgress, + autoHideFailed, + visibilityFilter, + providerAliasEntries, + setModelFilter, + setAutoHideFailed, + setVisibilityFilter, + saveModelCompatFlags, + handleToggleModelHidden, + handleBulkToggleModelHidden, + handleClearAllModels, + onTestModel, + handleTestAll, + effectiveModelNormalize, + effectiveModelPreserveDeveloper, + effectiveModelHidden, + getUpstreamHeadersRecordForModel, + t, +}: ProviderModelsSectionProps) { + const autoSyncToggle = compatibleSupportsModelImport && canImportModels && ( + + ); + + const clearAllButton = (modelMeta.customModels.length > 0 || + providerAliasEntries.length > 0) && ( + + ); + + if (isManagedAvailableModelsProvider) { + const description = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : isCcCompatible + ? t("ccCompatibleModelsDescription") + : t("compatibleModelsDescription", { + type: isAnthropicCompatible ? t("anthropic") : t("openai"), + }); + const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const inputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : isCcCompatible + ? "claude-sonnet-4-6" + : isAnthropicCompatible + ? t("anthropicCompatibleModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ {autoSyncToggle} + {clearAllButton} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + testingModelId={testingModelId} + onTestAll={handleTestAll} + testingAll={testingAll} + testProgress={testProgress} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + if (providerInfo?.passthroughModels) { + const passthroughDescription = + providerId === "openrouter" + ? t("openRouterAnyModelHint") + : providerId === "bedrock" + ? t("bedrockModelsDescription") + : t("passthroughModelsDescription", { provider: providerInfo?.name || providerId }); + const passthroughInputLabel = + providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId"); + const passthroughInputPlaceholder = + providerId === "openrouter" + ? t("openRouterModelPlaceholder") + : providerId === "bedrock" + ? t("bedrockModelPlaceholder") + : t("openaiCompatibleModelPlaceholder"); + + return ( +
+
+ + {autoSyncToggle} + {clearAllButton} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ + handleToggleModelHidden(providerStorageAlias, modelId, hidden) + } + onBulkToggleHidden={(modelIds, hidden) => + handleBulkToggleModelHidden(providerStorageAlias, modelIds, hidden) + } + bulkTogglePending={bulkVisibilityAction !== null} + togglingModelId={togglingModelId} + onTestModel={onTestModel} + modelTestStatus={modelTestStatus} + onModelTestStatusChange={onModelTestStatusChange} + testingModelId={testingModelId} + providerId={providerId} + connectionId={selectedConnection?.id ?? ""} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> +
+ ); + } + + const importButton = ( +
+ + {autoSyncToggle} + {!canImportModels && ( + {t("addConnectionToImport")} + )} +
+ ); + + if (models.length === 0) { + return ( +
+ {importButton} +

{t("noModelsConfigured")}

+
+ ); + } + + const modelsWithVisibility = models.map((model) => ({ + ...model, + isHidden: effectiveModelHidden(model.id), + })); + const filteredModels = modelsWithVisibility.filter((model) => { + const matchesQuery = matchesModelCatalogQuery(modelFilter, { + modelId: model.id, + modelName: model.name, + source: model.source, + }); + const matchesVisibility = + visibilityFilter === "all" + ? true + : visibilityFilter === "visible" + ? !model.isHidden + : model.isHidden; + return matchesQuery && matchesVisibility; + }); + const activeCount = modelsWithVisibility.filter((m) => !m.isHidden).length; + const hiddenFilteredCount = filteredModels.filter((m) => m.isHidden).length; + const visibleFilteredCount = filteredModels.length - hiddenFilteredCount; + const testAllTargets = filteredModels + .filter((m) => !m.isHidden) + .map((m) => ({ modelId: m.id, fullModel: `${providerDisplayAlias}/${m.id}` })); + + return ( +
+ {importButton} + {modelsWithVisibility.length > 0 && ( + + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + false + ) + } + onDeselectAll={() => + handleBulkToggleModelHidden( + providerId, + filteredModels.map((model) => model.id), + true + ) + } + selectAllDisabled={hiddenFilteredCount === 0 || bulkVisibilityAction !== null} + deselectAllDisabled={visibleFilteredCount === 0 || bulkVisibilityAction !== null} + onTestAll={() => handleTestAll(testAllTargets)} + testingAll={testingAll} + testProgress={testProgress} + visibilityFilter={visibilityFilter} + onVisibilityFilterChange={setVisibilityFilter} + autoHideFailed={autoHideFailed} + onAutoHideFailedChange={setAutoHideFailed} + /> + )} +
+ {filteredModels.map((model) => { + return ( + getUpstreamHeadersRecordForModel(model.id, p)} + saveModelCompatFlags={saveModelCompatFlags} + compatDisabled={compatSavingModelId === model.id} + onToggleHidden={(modelId, hidden) => + handleToggleModelHidden(providerId, modelId, hidden) + } + togglingHidden={togglingModelId === model.id} + onTestModel={onTestModel} + testStatus={modelTestStatus[model.id] || null} + testingModel={testingModelId === model.id} + /> + ); + })} + {filteredModels.length === 0 && modelFilter && ( +

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

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx new file mode 100644 index 0000000000..2179a0f11b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPageHeader.tsx @@ -0,0 +1,96 @@ +"use client"; + +// Phase 1t.1 extraction — Issue #3501 +import Link from "next/link"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import { getHeaderIconProviderId } from "../providerPageHelpers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface ProviderInfo { + id: string; + name: string; + website?: string; + color: string; + apiType?: string; +} + +interface ProviderPageHeaderProps { + providerId: string; + providerInfo: ProviderInfo; + connectionsCount: number; + isOpenAICompatible: boolean; + isAnthropicProtocolCompatible: boolean; + onOpenTutorial: () => void; + t: ProviderMessageTranslator; +} + +export default function ProviderPageHeader({ + providerId, + providerInfo, + connectionsCount, + isOpenAICompatible, + isAnthropicProtocolCompatible, + onOpenTutorial, + t, +}: ProviderPageHeaderProps) { + return ( +
+ + arrow_back + {t("backToProviders")} + +
+
+ +
+
+ {providerInfo.website ? ( + + {providerInfo.name} + open_in_new + + ) : ( +

{providerInfo.name}

+ )} +
+

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

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

Playground

+ + {renderKindPanel(activeKind, providerId)} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx new file mode 100644 index 0000000000..0972380816 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/SearchProviderCard.tsx @@ -0,0 +1,37 @@ +"use client"; + +// Phase 1t.7 extraction — Issue #3501 +import { Card } from "@/shared/components"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface SearchProviderCardProps { + providerId: string; + t: ProviderMessageTranslator; +} + +export default function SearchProviderCard({ providerId, t }: SearchProviderCardProps) { + return ( + +

{t("searchProvider")}

+

{t("searchProviderDesc")}

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

{t("perplexitySearchSharedKeyInfo")}

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

{t("googlePseInfo")}

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

{t("searxngInfo")}

+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx new file mode 100644 index 0000000000..f93810c4d1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/UpstreamProxyCard.tsx @@ -0,0 +1,48 @@ +"use client"; + +// Phase 1t.7 extraction — Issue #3501 +import Link from "next/link"; +import { Card } from "@/shared/components"; +import { providerText } from "../providerPageHelpers"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface UpstreamProxyCardProps { + t: ProviderMessageTranslator; +} + +export default function UpstreamProxyCard({ t }: UpstreamProxyCardProps) { + return ( + +
+
+

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

+

+ {providerText( + t, + "upstreamProxyManagedDescription", + "CLIProxyAPI is configured as an upstream proxy layer, not as a direct provider connection. Manage the binary/runtime in CLI Tools and enable proxy routing on each provider via the provider proxy controls." + )} +

+
+
+ + terminal + {t("openCliTools")} + + + settings + {t("openSettings")} + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx new file mode 100644 index 0000000000..d120dcff1f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ZedImportCard.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button, Card } from "@/shared/components"; + +type ZedImportCardProps = { + fetchConnections: () => Promise; + notify: { success: (msg: string) => void; error: (msg: string) => void; info: (msg: string) => void }; +}; + +export default function ZedImportCard({ fetchConnections, notify }: ZedImportCardProps) { + const [importingZed, setImportingZed] = useState(false); + const [showZedManual, setShowZedManual] = useState(false); + const [zedManualProvider, setZedManualProvider] = useState("openai"); + const [zedManualToken, setZedManualToken] = useState(""); + const [importingZedManual, setImportingZedManual] = useState(false); + + const handleZedImport = useCallback(async () => { + if (importingZed) return; + setImportingZed(true); + try { + const res = await fetch("/api/providers/zed/import", { method: "POST" }); + const data = await res.json(); + if (!res.ok || !data.success) { + if (data.zedDockerEnvironment) { + setShowZedManual(true); + } + notify.error(data.error || "Zed import failed"); + } else if (!data.count) { + const found = data.credentials?.length ?? 0; + if (found === 0) { + notify.info("No Zed credentials found in keychain"); + } else { + notify.info( + `Found ${found} keychain credential(s), but none matched supported providers` + ); + } + } else { + notify.success( + `Imported ${data.count} credential(s) from Zed for ${data.providers?.length ?? 0} provider(s)` + ); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Zed import failed"); + } finally { + setImportingZed(false); + } + }, [importingZed, notify, fetchConnections]); + + const handleZedManualImport = useCallback(async () => { + if (importingZedManual || !zedManualToken.trim()) return; + setImportingZedManual(true); + try { + const res = await fetch("/api/providers/zed/manual-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: zedManualProvider, token: zedManualToken.trim() }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + notify.error(data.error?.message ?? data.error ?? "Manual import failed"); + } else { + notify.success(`Imported ${zedManualProvider} token from Zed`); + setZedManualToken(""); + await fetchConnections(); + } + } catch (e: any) { + notify.error(e?.message || "Manual import failed"); + } finally { + setImportingZedManual(false); + } + }, [importingZedManual, zedManualProvider, zedManualToken, notify, fetchConnections]); + + return ( + <> + +
+
+

+ download + Import from Zed Keychain +

+

+ Discover AI provider credentials (OpenAI, Anthropic, Google, Mistral, xAI) that + Zed IDE stored in the OS keychain and import them as connections. Requires Zed IDE + installed on this machine. +

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

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

+
+ + setZedManualToken(e.target.value)} + /> + +
+
+ )} +
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts new file mode 100644 index 0000000000..a6e3d4fcee --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts @@ -0,0 +1,146 @@ +"use client"; + +/** + * useApiKeySave — Issue #3501 Phase 1s + * + * Owns the handleSaveApiKey async function that was previously inline in + * ProviderDetailPageClient. Extracts it into a custom hook so the client + * can simply destructure the callback. + * + * Cycle-safe: no import from ProviderDetailPageClient. + */ + +import { useCallback } from "react"; +import type React from "react"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; +import type { ImportProgress } from "./useModelImportHandlers"; + +type UseApiKeySaveParams = { + providerId: string; + fetchConnections: () => Promise; + fetchProviderModelMeta: () => Promise; + setImportProgress: React.Dispatch>; + setShowImportModal: (open: boolean) => void; + setShowAddApiKeyModal: (open: boolean) => void; + setSiliconFlowInitialBaseUrl: (url: string | undefined) => void; + notify: { success: (msg: string) => void; error: (msg: string) => void; info?: (msg: string) => void }; + t: ProviderMessageTranslator; +}; + +export function useApiKeySave({ + providerId, + fetchConnections, + fetchProviderModelMeta, + setImportProgress, + setShowImportModal, + setShowAddApiKeyModal, + setSiliconFlowInitialBaseUrl, + t, +}: UseApiKeySaveParams) { + const handleSaveApiKey = useCallback( + async (formData: Record) => { + try { + const res = await fetch("/api/providers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: providerId, ...formData }), + }); + if (res.ok) { + const connectionData = await res.json(); + const newConnection = connectionData?.connection; + await fetchConnections(); + setShowAddApiKeyModal(false); + setSiliconFlowInitialBaseUrl(undefined); + + // Universal: sync models from the provider endpoint on every new connection + // (was previously Gemini-only). Do NOT re-introduce a providerId guard here. + if (newConnection?.id) { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const syncRes = await fetch(`/api/providers/${newConnection.id}/sync-models`, { + method: "POST", + signal: AbortSignal.timeout(30_000), // 30s timeout — model sync shouldn't hang + }); + const syncData = await syncRes.json(); + + if (!syncRes.ok || syncData.error) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: syncData.error?.message || syncData.error || t("failedImportModels"), + })); + return null; + } + + const syncedCount = syncData.syncedModels || 0; + const availableCount = + typeof syncData.availableModelsCount === "number" + ? syncData.availableModelsCount + : Array.isArray(syncData.models) + ? syncData.models.length + : syncedCount; + const syncedModelList: Array<{ id: string; name?: string }> = syncData.models || []; + const logs: string[] = []; + if (syncedModelList.length > 0) { + logs.push(`✓ ${availableCount} models available`); + logs.push(""); + for (const m of syncedModelList) { + logs.push(` ${m.name || m.id}`); + } + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("modelsImported", { count: availableCount }), + total: availableCount, + current: availableCount, + importedCount: availableCount, + logs, + })); + + await fetchProviderModelMeta(); + } catch (syncError) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: String(syncError), + })); + } + } + return null; + } + const data = await res.json().catch(() => ({})); + const errorMsg = data.error?.message || data.error || t("failedSaveConnection"); + return errorMsg; + } catch (error) { + console.log("Error saving connection:", error); + return t("failedSaveConnectionRetry"); + } + }, + [ + providerId, + fetchConnections, + fetchProviderModelMeta, + setImportProgress, + setShowImportModal, + setShowAddApiKeyModal, + setSiliconFlowInitialBaseUrl, + t, + ] + ); + + return { handleSaveApiKey }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts new file mode 100644 index 0000000000..be1638da6a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useAuthFileHandlers.ts @@ -0,0 +1,300 @@ +"use client"; + +// Phase 1j extraction — Issue #3501 +// Manages Codex / Claude / Gemini auth-file apply+export state and handlers. + +import { useState } from "react"; + +type Notify = { success: (msg: string) => void; error: (msg: string) => void }; + +type UseAuthFileHandlersParams = { + parseApiErrorMessage: (res: Response, fallback: string) => Promise; + getAttachmentFilename: (res: Response, fallback: string) => string; + notify: Notify; + t: (key: string) => string; +}; + +export function useAuthFileHandlers({ + parseApiErrorMessage, + getAttachmentFilename, + notify, + t, +}: UseAuthFileHandlersParams) { + // ── Codex ────────────────────────────────────────────────────────────────── + const [applyingCodexAuthId, setApplyingCodexAuthId] = useState(null); + const [applyCodexModalConnectionId, setApplyCodexModalConnectionId] = useState( + null + ); + const [exportingCodexAuthId, setExportingCodexAuthId] = useState(null); + + // ── Claude ───────────────────────────────────────────────────────────────── + const [applyingClaudeAuthId, setApplyingClaudeAuthId] = useState(null); + const [applyClaudeModalConnectionId, setApplyClaudeModalConnectionId] = useState( + null + ); + const [exportingClaudeAuthId, setExportingClaudeAuthId] = useState(null); + + // ── Gemini ───────────────────────────────────────────────────────────────── + const [applyingGeminiAuthId, setApplyingGeminiAuthId] = useState(null); + const [applyGeminiModalConnectionId, setApplyGeminiModalConnectionId] = useState( + null + ); + const [exportingGeminiAuthId, setExportingGeminiAuthId] = useState(null); + + // ── Handlers ─────────────────────────────────────────────────────────────── + + const handleApplyCodexAuthLocal = async (connectionId: string) => { + if (applyingCodexAuthId) return; + setApplyingCodexAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("codexAuthAppliedLocal") + ? t("codexAuthAppliedLocal") + : "Codex auth.json applied locally"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("codexAuthApplyFailed") + ? t("codexAuthApplyFailed") + : "Failed to apply Codex auth.json locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyCodexModalConnectionId(null); + } catch (error) { + console.error("Error applying Codex auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingCodexAuthId(null); + } + }; + + const handleExportCodexAuthFile = async (connectionId: string) => { + if (exportingCodexAuthId) return; + setExportingCodexAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("codexAuthExported") + ? t("codexAuthExported") + : "Codex auth.json exported"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("codexAuthExportFailed") + ? t("codexAuthExportFailed") + : "Failed to export Codex auth.json"; + + try { + const res = await fetch(`/api/providers/${connectionId}/codex-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "codex-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Codex auth file:", error); + notify.error(defaultError); + } finally { + setExportingCodexAuthId(null); + } + }; + + const handleApplyClaudeAuthLocal = async (connectionId: string) => { + if (applyingClaudeAuthId) return; + setApplyingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("claudeAuthAppliedLocal") + ? t("claudeAuthAppliedLocal") + : "Claude auth applied locally"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("claudeAuthApplyFailed") + ? t("claudeAuthApplyFailed") + : "Failed to apply Claude auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyClaudeModalConnectionId(null); + } catch (error) { + console.error("Error applying Claude auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingClaudeAuthId(null); + } + }; + + const handleExportClaudeAuthFile = async (connectionId: string) => { + if (exportingClaudeAuthId) return; + setExportingClaudeAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("claudeAuthExported") + ? t("claudeAuthExported") + : "Claude auth file exported"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("claudeAuthExportFailed") + ? t("claudeAuthExportFailed") + : "Failed to export Claude auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/claude-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "claude-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Claude auth file:", error); + notify.error(defaultError); + } finally { + setExportingClaudeAuthId(null); + } + }; + + const handleApplyGeminiAuthLocal = async (connectionId: string) => { + if (applyingGeminiAuthId) return; + setApplyingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("geminiAuthAppliedLocal") + ? t("geminiAuthAppliedLocal") + : "Gemini auth applied locally"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("geminiAuthApplyFailed") + ? t("geminiAuthApplyFailed") + : "Failed to apply Gemini auth locally"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/apply-local`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + notify.success(defaultSuccess); + setApplyGeminiModalConnectionId(null); + } catch (error) { + console.error("Error applying Gemini auth locally:", error); + notify.error(defaultError); + } finally { + setApplyingGeminiAuthId(null); + } + }; + + const handleExportGeminiAuthFile = async (connectionId: string) => { + if (exportingGeminiAuthId) return; + setExportingGeminiAuthId(connectionId); + + const defaultSuccess = + typeof (t as any).has === "function" && (t as any).has("geminiAuthExported") + ? t("geminiAuthExported") + : "Gemini auth file exported"; + const defaultError = + typeof (t as any).has === "function" && (t as any).has("geminiAuthExportFailed") + ? t("geminiAuthExportFailed") + : "Failed to export Gemini auth file"; + + try { + const res = await fetch(`/api/providers/${connectionId}/gemini-cli-auth/export`, { + method: "POST", + }); + + if (!res.ok) { + notify.error(await parseApiErrorMessage(res, defaultError)); + return; + } + + const blob = await res.blob(); + const filename = getAttachmentFilename(res, "gemini-auth.json"); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(() => window.URL.revokeObjectURL(objectUrl), 1000); + + notify.success(defaultSuccess); + } catch (error) { + console.error("Error exporting Gemini auth file:", error); + notify.error(defaultError); + } finally { + setExportingGeminiAuthId(null); + } + }; + + return { + // Codex + applyingCodexAuthId, + applyCodexModalConnectionId, + setApplyCodexModalConnectionId, + exportingCodexAuthId, + handleApplyCodexAuthLocal, + handleExportCodexAuthFile, + // Claude + applyingClaudeAuthId, + applyClaudeModalConnectionId, + setApplyClaudeModalConnectionId, + exportingClaudeAuthId, + handleApplyClaudeAuthLocal, + handleExportClaudeAuthFile, + // Gemini + applyingGeminiAuthId, + applyGeminiModalConnectionId, + setApplyGeminiModalConnectionId, + exportingGeminiAuthId, + handleApplyGeminiAuthLocal, + handleExportGeminiAuthFile, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts new file mode 100644 index 0000000000..ea1a6809d4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts @@ -0,0 +1,290 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { type CommandCodeAuthFlowState } from "../providerPageHelpers"; + +export type UseCommandCodeAuthParams = { + providerId: string; + fetchConnections: () => Promise | void; + setSiliconFlowInitialBaseUrl: (url: string | undefined) => void; + setShowAddApiKeyModal: (show: boolean) => void; + notify: { success: (msg: string) => void; error: (msg: string) => void }; +}; + +export function useCommandCodeAuth({ + fetchConnections, + setSiliconFlowInitialBaseUrl, + setShowAddApiKeyModal, + notify, +}: UseCommandCodeAuthParams) { + const [commandCodeAuthState, setCommandCodeAuthState] = useState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + + const commandCodeAuthWindowRef = useRef(null); + const commandCodeAuthTimerRef = useRef(null); + + const clearCommandCodeAuthTimer = useCallback(() => { + if (commandCodeAuthTimerRef.current !== null) { + window.clearTimeout(commandCodeAuthTimerRef.current); + commandCodeAuthTimerRef.current = null; + } + }, []); + + useEffect(() => { + return () => { + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + }; + }, [clearCommandCodeAuthTimer]); + + const handleCloseAddApiKeyModal = useCallback(() => { + clearCommandCodeAuthTimer(); + setSiliconFlowInitialBaseUrl(undefined); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + setCommandCodeAuthState({ + phase: "idle", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "", + }); + setShowAddApiKeyModal(false); + }, [clearCommandCodeAuthTimer, setSiliconFlowInitialBaseUrl, setShowAddApiKeyModal]); + + const handleCommandCodeAuthApply = useCallback( + async (state: string, connectionId?: string, name?: string, setDefault?: boolean) => { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applying", + message: "Applying browser-approved key…", + })); + + try { + const res = await fetch("/api/providers/command-code/auth/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state, connectionId, name, setDefault }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + const errorMessage = data.error || "Failed to apply Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + return false; + } + + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + return true; + } catch (error) { + console.error("Error applying Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to apply Command Code auth", + })); + notify.error("Failed to apply Command Code auth"); + return false; + } + }, + [fetchConnections, handleCloseAddApiKeyModal, notify] + ); + + const handleStartCommandCodeAuth = useCallback(async () => { + if (commandCodeAuthState.phase === "starting" || commandCodeAuthState.phase === "polling") { + return; + } + + clearCommandCodeAuthTimer(); + commandCodeAuthWindowRef.current?.close?.(); + + const popup = window.open("about:blank", "_blank"); + setCommandCodeAuthState({ + phase: "starting", + state: "", + authUrl: "", + callbackUrl: "", + expiresAt: null, + message: "Opening Command Code Studio…", + }); + + try { + const res = await fetch("/api/providers/command-code/auth/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok || !data.state || !data.authUrl) { + const errorMessage = data.error || "Failed to start Command Code auth"; + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: errorMessage, + })); + notify.error(errorMessage); + popup?.close?.(); + return; + } + + setCommandCodeAuthState({ + phase: "polling", + state: data.state, + authUrl: data.authUrl, + callbackUrl: data.callbackUrl || "", + expiresAt: data.expiresAt || null, + message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…", + }); + + if (popup) { + try { + popup.opener = null; + } catch { + // Ignore opener cleanup failures. + } + popup.location.href = data.authUrl; + commandCodeAuthWindowRef.current = popup; + } else { + const fallbackPopup = window.open(data.authUrl, "_blank", "noopener,noreferrer"); + if (!fallbackPopup) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Popup blocked. Please allow popups and try Command Code Connect again.", + })); + notify.error("Popup blocked. Please allow popups and try Command Code Connect again."); + return; + } + commandCodeAuthWindowRef.current = fallbackPopup; + } + + const deadline = data.expiresAt ? new Date(data.expiresAt).getTime() : Date.now() + 180000; + const poll = async () => { + if (Date.now() >= deadline) { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + try { + const statusRes = await fetch( + `/api/providers/command-code/auth/status?state=${encodeURIComponent(data.state)}`, + { method: "GET", cache: "no-store" } + ); + const statusData = await statusRes.json().catch(() => ({})); + const status = String(statusData.status || statusData.state || statusData.phase || "") + .toLowerCase() + .trim(); + + if (status === "expired") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "expired", + message: "Command Code link expired", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + notify.error("Command Code auth expired"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "applied") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "applied", + message: "Command Code connected", + })); + commandCodeAuthWindowRef.current?.close?.(); + commandCodeAuthWindowRef.current = null; + await fetchConnections(); + handleCloseAddApiKeyModal(); + notify.success("Command Code connection added"); + clearCommandCodeAuthTimer(); + return; + } + + if (status === "received") { + setCommandCodeAuthState((current) => ({ + ...current, + phase: "received", + message: "Browser approved, applying…", + })); + clearCommandCodeAuthTimer(); + await handleCommandCodeAuthApply( + data.state, + statusData.connectionId, + statusData.name, + statusData.setDefault + ); + return; + } + } catch { + // Keep polling until the contract reports a terminal state or timeout. + } + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 2000); + }; + + commandCodeAuthTimerRef.current = window.setTimeout(poll, 1000); + } catch (error) { + console.error("Error starting Command Code auth:", error); + setCommandCodeAuthState((current) => ({ + ...current, + phase: "error", + message: "Failed to start Command Code auth", + })); + notify.error("Failed to start Command Code auth"); + popup?.close?.(); + commandCodeAuthWindowRef.current = null; + clearCommandCodeAuthTimer(); + } + }, [ + clearCommandCodeAuthTimer, + handleCloseAddApiKeyModal, + commandCodeAuthState.phase, + fetchConnections, + handleCommandCodeAuthApply, + notify, + ]); + + const handleOpenCommandCodeConnect = useCallback(() => { + setShowAddApiKeyModal(true); + void handleStartCommandCodeAuth(); + }, [handleStartCommandCodeAuth, setShowAddApiKeyModal]); + + return { + commandCodeAuthState, + clearCommandCodeAuthTimer, + handleCloseAddApiKeyModal, + handleCommandCodeAuthApply, + handleStartCommandCodeAuth, + handleOpenCommandCodeConnect, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts new file mode 100644 index 0000000000..c892fece2a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts @@ -0,0 +1,48 @@ +// Phase 1t.3 extraction — Issue #3501 +// Encapsulates gateConnectionFlow + risk-notice modal state + confirm/cancel handlers. +import { useState, useRef, useCallback } from "react"; +import { isRiskAcknowledged, useRiskAcknowledged } from "../../hooks/useRiskAcknowledged"; + +interface UseConnectionGateParams { + providerId: string; + subscriptionRisk: boolean; +} + +export function useConnectionGate({ providerId, subscriptionRisk }: UseConnectionGateParams) { + const [showRiskNoticeModal, setShowRiskNoticeModal] = useState(false); + const pendingRiskActionRef = useRef<(() => void) | null>(null); + const { acknowledged: riskAcknowledged, acknowledge: acknowledgeRisk } = + useRiskAcknowledged(providerId); + + const gateConnectionFlow = useCallback( + (callback: () => void) => { + if (subscriptionRisk && !riskAcknowledged && !isRiskAcknowledged(providerId)) { + pendingRiskActionRef.current = callback; + setShowRiskNoticeModal(true); + return; + } + callback(); + }, + [providerId, riskAcknowledged, subscriptionRisk] + ); + + const handleConfirmRiskNotice = useCallback(() => { + acknowledgeRisk(); + setShowRiskNoticeModal(false); + const pendingAction = pendingRiskActionRef.current; + pendingRiskActionRef.current = null; + pendingAction?.(); + }, [acknowledgeRisk]); + + const handleCancelRiskNotice = useCallback(() => { + pendingRiskActionRef.current = null; + setShowRiskNoticeModal(false); + }, []); + + return { + showRiskNoticeModal, + gateConnectionFlow, + handleConfirmRiskNotice, + handleCancelRiskNotice, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts new file mode 100644 index 0000000000..125e663c03 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts @@ -0,0 +1,96 @@ +import { useState, useEffect, useCallback } from "react"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +type UseExternalLinkFlowParams = { + providerId: string; + notify: { success: (msg: string) => void; error: (msg: string) => void }; + fetchConnections: () => Promise | void; +}; + +export function useExternalLinkFlow({ + providerId, + notify, + fetchConnections, +}: UseExternalLinkFlowParams) { + const [externalLinkModalOpen, setExternalLinkModalOpen] = useState(false); + const [externalLinkUrl, setExternalLinkUrl] = useState(""); + const [externalLinkToken, setExternalLinkToken] = useState(null); + const [externalLinkLoading, setExternalLinkLoading] = useState(false); + const [externalLinkError, setExternalLinkError] = useState(null); + const { copied: externalLinkCopied, copy: externalLinkCopy } = useCopyToClipboard(); + + // "Adicionar Externo": generate a single-use public link so a third party can + // complete the Codex device flow in their own browser. + const openExternalLinkFlow = useCallback(async () => { + setExternalLinkModalOpen(true); + setExternalLinkUrl(""); + setExternalLinkToken(null); + setExternalLinkError(null); + setExternalLinkLoading(true); + try { + const res = await fetch(`/api/oauth/${providerId}/public-link`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data?.url) { + setExternalLinkUrl(data.url); + setExternalLinkToken(data.token || null); + } else { + setExternalLinkError(data?.error || "Falha ao gerar o link."); + } + } catch { + setExternalLinkError("Não foi possível contatar o servidor."); + } finally { + setExternalLinkLoading(false); + } + }, [providerId]); + + // While the share popup is open, poll the ticket status so the dashboard can + // notify + refresh the connections the moment the external visitor finishes. + useEffect(() => { + if (!externalLinkModalOpen || !externalLinkToken) return; + let active = true; + const interval = setInterval(async () => { + if (!active) return; + try { + const res = await fetch( + `/api/oauth/${providerId}/public-link-status?token=${encodeURIComponent(externalLinkToken)}` + ); + const data = await res.json().catch(() => ({})); + if (!active) return; + if (data?.status === "completed") { + active = false; + clearInterval(interval); + notify.success("Conta Codex conectada pelo link externo."); + fetchConnections(); + setExternalLinkModalOpen(false); + setExternalLinkToken(null); + } else if (data?.status === "expired") { + active = false; + clearInterval(interval); + setExternalLinkError("O link expirou sem ser concluído."); + } + } catch { + /* transient network error — keep polling */ + } + }, 3000); + return () => { + active = false; + clearInterval(interval); + }; + }, [externalLinkModalOpen, externalLinkToken, providerId, notify, fetchConnections]); + + return { + externalLinkModalOpen, + setExternalLinkModalOpen, + externalLinkUrl, + externalLinkToken, + externalLinkLoading, + externalLinkError, + externalLinkCopied, + externalLinkCopy, + openExternalLinkFlow, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts new file mode 100644 index 0000000000..9357405ff3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts @@ -0,0 +1,382 @@ +"use client"; + +/** + * useModelImportHandlers — Issue #3501 Phase 1k + * + * Owns import-progress state and handlers that were previously inline in + * ProviderDetailPageClient: + * - importingModels, showImportModal, importProgress, togglingAutoSync + * - handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync + * - canImportModels (derived), isAutoSyncEnabled (derived), autoSyncConnection (derived) + * + * Cycle-safe: imports only from leaf modules and React. + * No import from ProviderDetailPageClient. + */ + +import React, { useState } from "react"; +import type { ProviderMessageTranslator } from "../providerPageHelpers"; +import { useNotificationStore } from "@/store/notificationStore"; + +type NotifyStore = ReturnType; + +// ──── types ────────────────────────────────────────────────────────────────── + +export interface ImportProgress { + current: number; + total: number; + phase: "idle" | "fetching" | "importing" | "done" | "error"; + status: string; + logs: string[]; + error: string; + importedCount: number; +} + +export interface UseModelImportHandlersParams { + providerId: string; + models: Array<{ id: string; name?: string }>; + modelMeta: { customModels: Array<{ id: string }>; modelCompatOverrides?: unknown[] }; + modelAliases: Record; + connections: Array<{ id?: string; isActive?: boolean; providerSpecificData?: Record }>; + isFreeNoAuth: boolean; + handleSetAlias: (modelId: string, alias: string, providerAlias: string) => Promise; + fetchAliases: () => Promise; + fetchProviderModelMeta: () => Promise; + fetchConnections: () => Promise; + notify: NotifyStore; + t: ProviderMessageTranslator; + providerStorageAlias: string; +} + +export interface UseModelImportHandlersReturn { + importingModels: boolean; + showImportModal: boolean; + importProgress: ImportProgress; + togglingAutoSync: boolean; + canImportModels: boolean; + isAutoSyncEnabled: boolean; + autoSyncConnection: UseModelImportHandlersParams["connections"][number] | undefined; + setShowImportModal: (v: boolean) => void; + setImportProgress: React.Dispatch>; + handleImportModels: () => Promise; + handleCompatibleImportWithProgress: (connectionId: string) => Promise; + handleToggleAutoSync: () => Promise; +} + +// ──── hook ─────────────────────────────────────────────────────────────────── + +export function useModelImportHandlers({ + providerId, + models, + modelMeta, + modelAliases, + connections, + isFreeNoAuth, + handleSetAlias, + fetchAliases, + fetchProviderModelMeta, + fetchConnections, + notify, + t, + providerStorageAlias, +}: UseModelImportHandlersParams): UseModelImportHandlersReturn { + const [importingModels, setImportingModels] = useState(false); + const [showImportModal, setShowImportModal] = useState(false); + const [importProgress, setImportProgress] = useState({ + current: 0, + total: 0, + phase: "idle", + status: "", + logs: [], + error: "", + importedCount: 0, + }); + const [togglingAutoSync, setTogglingAutoSync] = useState(false); + + // Derived + const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); + const autoSyncConnection = connections.find((conn) => conn.isActive !== false); + const isAutoSyncEnabled = !!(autoSyncConnection as any)?.providerSpecificData?.autoSync; + + const handleImportModels = async () => { + if (importingModels) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection && !isFreeNoAuth) return; + const importTargetId = activeConnection?.id ?? providerId; + + setImportingModels(true); + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const res = await fetch(`/api/providers/${importTargetId}/models?refresh=true`); + const data = await res.json(); + if (!res.ok) { + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("failedFetchModels"), + error: data.error || t("failedImportModels"), + })); + return; + } + const fetchedModels = data.models || []; + if (fetchedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("noModelsFound"), + logs: [t("noModelsReturnedFromEndpoint")], + })); + return; + } + + const existingIds = new Set([ + ...(modelMeta.customModels || []).map((m: any) => m.id), + ...models.map((m: any) => m.id), + ]); + const newModels = fetchedModels.filter( + (model: any) => !existingIds.has(model.id || model.name || model.model) + ); + + if (newModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: t("allModelsAlreadyImported") || "All models already imported", + logs: [t("noNewModelsToImport") || "No new models to import"], + importedCount: 0, + total: 0, + current: 0, + })); + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "importing", + total: newModels.length, + current: 0, + status: t("importingModelsProgress", { current: 0, total: newModels.length }), + logs: [ + t("foundModelsStartingImport", { count: newModels.length }), + ...(newModels.length < fetchedModels.length + ? [ + t("skippingExistingModels", { count: fetchedModels.length - newModels.length }) || + `Skipping ${fetchedModels.length - newModels.length} existing models`, + ] + : []), + ], + })); + + let importedCount = 0; + for (let i = 0; i < newModels.length; i++) { + const model = newModels[i]; + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const parts = modelId.split("/"); + const baseAlias = parts[parts.length - 1]; + + setImportProgress((prev) => ({ + ...prev, + current: i + 1, + status: t("importingModelsProgress", { current: i + 1, total: newModels.length }), + logs: [...prev.logs, t("importingModelById", { modelId })], + })); + + await fetch("/api/provider-models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: providerId, + modelId, + modelName: model.name || modelId, + source: "imported", + ...(typeof model.apiFormat === "string" ? { apiFormat: model.apiFormat } : {}), + ...(Array.isArray(model.supportedEndpoints) + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + }), + }); + if (!modelAliases[baseAlias]) { + await handleSetAlias(modelId, baseAlias, providerStorageAlias); + } + importedCount += 1; + } + + await fetchAliases(); + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + current: newModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAddedExisting"), + logs: [ + ...prev.logs, + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + if (importedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } finally { + setImportingModels(false); + } + }; + + const handleCompatibleImportWithProgress = async (connectionId: string) => { + setShowImportModal(true); + setImportProgress({ + current: 0, + total: 0, + phase: "fetching", + status: t("fetchingModels"), + logs: [], + error: "", + importedCount: 0, + }); + + try { + const response = await fetch(`/api/providers/${connectionId}/sync-models?mode=import`, { + method: "POST", + signal: AbortSignal.timeout(60_000), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || t("failedImportModels")); + } + + const importedModels = Array.isArray(data.importedModels) ? data.importedModels : []; + const importedCount = + typeof data.importedCount === "number" ? data.importedCount : importedModels.length; + const changedCount = + typeof data.importedChanges?.total === "number" + ? data.importedChanges.total + : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); + + if (importedModels.length === 0) { + setImportProgress((prev) => ({ + ...prev, + phase: "done", + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + return; + } + + setImportProgress((prev) => ({ + ...prev, + phase: "done", + total: importedModels.length, + current: importedModels.length, + status: + importedCount > 0 + ? t("importSuccessCount", { count: importedCount }) + : t("noNewModelsAdded"), + logs: [ + t("foundModelsStartingImport", { count: importedModels.length }), + ...importedModels.map((model: any) => + t("importingModelById", { modelId: model.id || model.name || model.model }) + ), + importedCount > 0 + ? t("importDoneCount", { count: importedCount }) + : t("noNewModelsAdded"), + ], + importedCount, + })); + + if (totalChangedCount > 0) { + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error) { + console.log("Error importing models:", error); + setImportProgress((prev) => ({ + ...prev, + phase: "error", + status: t("importFailed"), + error: error instanceof Error ? error.message : t("unexpectedErrorOccurred"), + })); + } + }; + + const handleToggleAutoSync = async () => { + if (!autoSyncConnection || togglingAutoSync) return; + setTogglingAutoSync(true); + try { + const newValue = !isAutoSyncEnabled; + await fetch(`/api/providers/${(autoSyncConnection as any).id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { autoSync: newValue }, + }), + }); + await fetchConnections(); + notify[newValue ? "success" : "info"]( + newValue ? t("autoSyncEnabled") : t("autoSyncDisabled") + ); + } catch (error) { + console.log("Error toggling auto-sync:", error); + notify.error(t("autoSyncToggleFailed")); + } finally { + setTogglingAutoSync(false); + } + }; + + return { + importingModels, + showImportModal, + importProgress, + togglingAutoSync, + canImportModels, + isAutoSyncEnabled, + autoSyncConnection, + setShowImportModal, + setImportProgress, + handleImportModels, + handleCompatibleImportWithProgress, + handleToggleAutoSync, + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts new file mode 100644 index 0000000000..72f8e719aa --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts @@ -0,0 +1,429 @@ +"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, + testAllResultsText, + evaluateTestAllEntry, + type ProviderMessageTranslator, + type CompatByProtocolMap, +} from "../providerPageHelpers"; +import { useNotificationStore } from "@/store/notificationStore"; + +type NotifyStore = ReturnType; + +// ──── types ────────────────────────────────────────────────────────────────── + +/** Subset of ModelCompatSavePatch fields needed by this hook. */ +export interface ModelCompatSavePatch { + normalizeToolCallId?: boolean; + preserveOpenAIDeveloperRole?: boolean; + upstreamHeaders?: Record; + compatByProtocol?: CompatByProtocolMap; + isHidden?: boolean; +} + +export interface UseModelVisibilityHandlersParams { + providerId: string; + modelAliases: Record; + /** The computed custom-model map from useModelCompatState. */ + customMap: Map; + providerStorageAlias: string; + fetchProviderModelMeta: () => Promise; + fetchAliases: () => Promise; + notify: NotifyStore; + t: ProviderMessageTranslator; + formatProviderModelsErrorResponse?: typeof formatProviderModelsErrorResponse; + /** The current selected connection (may be null). */ + selectedConnection: any; + /** The provider node (may be null). */ + providerNode: any; +} + +export interface UseModelVisibilityHandlersReturn { + compatSavingModelId: string | null; + togglingModelId: string | null; + bulkVisibilityAction: "select" | "deselect" | null; + clearingModels: boolean; + modelFilter: string; + testingModelId: string | null; + modelTestStatus: Record; + testingAll: boolean; + testProgress: { done: number; total: number } | null; + autoHideFailed: boolean; + visibilityFilter: "all" | "visible" | "hidden"; + providerAliasEntries: [string, string][]; + setModelFilter: (v: string) => void; + setAutoHideFailed: (v: boolean) => void; + setVisibilityFilter: (v: "all" | "visible" | "hidden") => void; + saveModelCompatFlags: (modelId: string, patch: ModelCompatSavePatch) => Promise; + handleToggleModelHidden: ( + providerKey: string, + modelId: string, + hidden: boolean + ) => Promise; + handleBulkToggleModelHidden: ( + providerKey: string, + modelIds: string[], + hidden: boolean + ) => Promise; + handleClearAllModels: () => Promise; + onTestModel: (modelId: string, fullModel: string) => Promise; + handleTestAll: (targets: Array<{ modelId: string; fullModel: string }>) => Promise; + /** Apply a model's test-all result to the per-row status icon (used by the + * passthrough section, which runs its own test-all loop). */ + onModelTestStatusChange: (modelId: string, status: "ok" | "error") => void; +} + +// ──── hook ─────────────────────────────────────────────────────────────────── + +export function useModelVisibilityHandlers({ + providerId, + modelAliases, + customMap, + providerStorageAlias, + fetchProviderModelMeta, + fetchAliases, + notify, + t, + selectedConnection, + providerNode, +}: UseModelVisibilityHandlersParams): UseModelVisibilityHandlersReturn { + const [compatSavingModelId, setCompatSavingModelId] = useState(null); + const [togglingModelId, setTogglingModelId] = useState(null); + const [bulkVisibilityAction, setBulkVisibilityAction] = useState< + "select" | "deselect" | null + >(null); + const [clearingModels, setClearingModels] = useState(false); + const [modelFilter, setModelFilter] = useState(""); + const [testingModelId, setTestingModelId] = useState(null); + const [modelTestStatus, setModelTestStatus] = useState>({}); + const [testingAll, setTestingAll] = useState(false); + const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); + const [autoHideFailed, setAutoHideFailed] = useState(true); + const [visibilityFilter, setVisibilityFilter] = useState<"all" | "visible" | "hidden">("all"); + + const providerAliasEntries = useMemo( + () => + Object.entries(modelAliases).filter( + ([, model]) => typeof model === "string" && model.startsWith(`${providerStorageAlias}/`) + ) as [string, string][], + [modelAliases, providerStorageAlias] + ); + + const saveModelCompatFlags = async (modelId: string, patch: ModelCompatSavePatch) => { + setCompatSavingModelId(modelId); + try { + const c = customMap.get(modelId) as Record | undefined; + let body: Record; + const onlyCompatByProtocol = + patch.compatByProtocol && + patch.normalizeToolCallId === undefined && + patch.preserveOpenAIDeveloperRole === undefined && + !("upstreamHeaders" in patch); + + if (c) { + if (onlyCompatByProtocol) { + body = { + provider: providerId, + modelId, + compatByProtocol: patch.compatByProtocol, + }; + } else { + body = { + provider: providerId, + modelId, + modelName: (c.name as string) || modelId, + source: (c.source as string) || "manual", + apiFormat: (c.apiFormat as string) || "chat-completions", + supportedEndpoints: + Array.isArray(c.supportedEndpoints) && (c.supportedEndpoints as unknown[]).length + ? c.supportedEndpoints + : ["chat"], + normalizeToolCallId: + patch.normalizeToolCallId !== undefined + ? patch.normalizeToolCallId + : Boolean(c.normalizeToolCallId), + preserveOpenAIDeveloperRole: + patch.preserveOpenAIDeveloperRole !== undefined + ? patch.preserveOpenAIDeveloperRole + : Object.prototype.hasOwnProperty.call(c, "preserveOpenAIDeveloperRole") + ? Boolean(c.preserveOpenAIDeveloperRole) + : true, + }; + if (patch.compatByProtocol) body.compatByProtocol = patch.compatByProtocol; + } + } else { + body = { provider: providerId, modelId, ...patch }; + } + const res = await fetch("/api/provider-models", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const detail = await formatProviderModelsErrorResponse(res); + notify.error( + detail ? `${t("failedSaveCustomModel")} — ${detail}` : t("failedSaveCustomModel") + ); + return; + } + } catch { + notify.error(t("failedSaveCustomModel")); + return; + } finally { + setCompatSavingModelId(null); + } + try { + await fetchProviderModelMeta(); + } catch { + /* refresh failure is non-critical — data was already saved */ + } + }; + + const handleToggleModelHidden = async ( + providerKey: string, + modelId: string, + hidden: boolean + ): Promise => { + setTogglingModelId(modelId); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerKey)}&modelId=${encodeURIComponent(modelId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden }), + } + ); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setTogglingModelId(null); + } + }; + + const handleBulkToggleModelHidden = async ( + providerKey: string, + modelIds: string[], + hidden: boolean + ): Promise => { + if (modelIds.length === 0) return; + setBulkVisibilityAction(hidden ? "deselect" : "select"); + try { + const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerKey)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isHidden: hidden, modelIds }), + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + notify.error(detail || t("failedSaveCustomModel")); + return; + } + await Promise.all([fetchProviderModelMeta().catch(() => {}), fetchAliases().catch(() => {})]); + } catch { + notify.error(t("failedSaveCustomModel")); + } finally { + setBulkVisibilityAction(null); + } + }; + + const handleClearAllModels = async () => { + if (clearingModels) return; + if (!confirm(t("clearAllModelsConfirm"))) return; + setClearingModels(true); + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerStorageAlias)}&all=true`, + { method: "DELETE" } + ); + if (res.ok) { + // Also delete all aliases that belong to this provider + await Promise.all( + providerAliasEntries.map(([alias]) => + fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { + method: "DELETE", + }).catch(() => {}) + ) + ); + await fetchProviderModelMeta(); + await fetchAliases(); + notify.success(t("clearAllModelsSuccess")); + } else { + notify.error(t("clearAllModelsFailed")); + } + } catch { + notify.error(t("clearAllModelsFailed")); + } finally { + setClearingModels(false); + } + }; + + const onTestModel = async (modelId: string, fullModel: string) => { + setTestingModelId(modelId); + setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined as any })); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: selectedConnection?.provider || providerNode?.id || providerId, + modelId: fullModel, + connectionId: selectedConnection?.id, + }), + }); + const data = await res.json(); + if (res.ok && data.status === "ok") { + notify.success( + providerText(t, "testModelSuccess", `Model ${modelId} is working. Latency: ${data.latencyMs}ms`, { + modelId, + latencyMs: data.latencyMs, + }) + ); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" })); + } else { + notify.error(data.error || "Model test failed"); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + // Hidden flag keyed by providerId — same as the manual eye toggle and the read + // (fetchProviderModelMeta). providerStorageAlias wrote it under the alias while the + // read looked under the canonical id, so auto-hide never reflected. + await handleToggleModelHidden(providerId, modelId, true); + } + } catch (err) { + notify.error("Network error testing model"); + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + // Hidden flag keyed by providerId (see the test-failure branch above). + await handleToggleModelHidden(providerId, modelId, true); + } finally { + setTestingModelId(null); + } + }; + + const handleTestAll = async ( + targets: Array<{ modelId: string; fullModel: string }> + ): Promise => { + if (testingAll) return; + if (targets.length === 0) { + notify.error(providerText(t, "noModelsToTest", "No models to test")); + return; + } + setTestingAll(true); + setTestProgress({ done: 0, total: targets.length }); + + let ok = 0; + let error = 0; + let hiddenCount = 0; + + const CHUNK_SIZE = 3; + for (let i = 0; i < targets.length; i += CHUNK_SIZE) { + const chunk = targets.slice(i, i + CHUNK_SIZE); + await Promise.all( + chunk.map(async ({ modelId, fullModel }) => { + try { + const result: { + results?: Record< + string, + { + status?: "ok" | "error"; + rateLimited?: boolean; + isTimeout?: boolean; + error?: string; + } + >; + } = await fetch("/api/models/test-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerId: providerId, + connectionId: selectedConnection?.id, + modelIds: [fullModel], + }), + }).then((r) => r.json()); + + const entry = result.results?.[fullModel]; + const outcome = evaluateTestAllEntry(entry, autoHideFailed); + // Paint the per-model icon green/red, same as the single-model ▶ test. + setModelTestStatus((prev) => ({ ...prev, [modelId]: outcome.status })); + if (outcome.status === "ok") { + ok++; + } else { + error++; + if (outcome.shouldHide) { + // Hidden flag keyed by providerId — same as the manual eye toggle and the read + // (fetchProviderModelMeta). providerStorageAlias wrote it under the alias while the + // read looked under the canonical id, so auto-hide never reflected. + await handleToggleModelHidden(providerId, modelId, true); + hiddenCount++; + } + } + } catch (e) { + error++; + setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); + } + setTestProgress((prev) => (prev ? { done: prev.done + 1, total: prev.total } : null)); + }) + ); + } + + notify.info(testAllResultsText(t, ok, 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, + onModelTestStatusChange: (modelId: string, status: "ok" | "error") => + setModelTestStatus((prev) => ({ ...prev, [modelId]: status })), + }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts new file mode 100644 index 0000000000..0b7c29979e --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderNodeActions.ts @@ -0,0 +1,63 @@ +// Phase 1t.4 extraction — Issue #3501 +// Encapsulates handleUpdateNode and handleUpdateConnection async handlers. +import type { ProviderMessageTranslator } from "../providerPageHelpers"; + +interface UseProviderNodeActionsParams { + providerId: string; + fetchConnections: () => Promise; + selectedConnection: { id: string } | null; + setProviderNode: (node: any) => void; + setShowEditNodeModal: (open: boolean) => void; + setShowEditModal: (open: boolean) => void; + t: ProviderMessageTranslator; +} + +export function useProviderNodeActions({ + providerId, + fetchConnections, + selectedConnection, + setProviderNode, + setShowEditNodeModal, + setShowEditModal, + t, +}: UseProviderNodeActionsParams) { + const handleUpdateNode = async (formData: any) => { + try { + const res = await fetch(`/api/provider-nodes/${providerId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + const data = await res.json(); + if (res.ok) { + setProviderNode(data.node); + await fetchConnections(); + setShowEditNodeModal(false); + } + } catch (error) { + console.log("Error updating provider node:", error); + } + }; + + const handleUpdateConnection = async (formData: any) => { + try { + const res = await fetch(`/api/providers/${selectedConnection?.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + if (res.ok) { + await fetchConnections(); + setShowEditModal(false); + return null; + } + const data = await res.json().catch(() => ({})); + return data.error?.message || data.error || t("failedSaveConnection"); + } catch (error) { + console.log("Error updating connection:", error); + return t("failedSaveConnectionRetry"); + } + }; + + return { handleUpdateNode, handleUpdateConnection }; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index da5b053e0a..51caf7c31c 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -16,6 +16,7 @@ import { } from "@/lib/providers/requestDefaults"; import { type CodexGlobalServiceMode } from "@/lib/providers/codexFastTier"; import { type WebSessionCredentialRequirement } from "./webSessionCredentials"; +import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; // --------------------------------------------------------------------------- // Types shared between page + modals @@ -105,6 +106,51 @@ export function providerText( return fallback; } +/** A single model's outcome from a `/api/models/test-all` response. */ +export interface TestAllModelOutcome { + status: "ok" | "error"; + shouldHide: boolean; +} + +/** + * Decide a model's per-row test status (the green/red icon) and whether it should + * be auto-hidden, from one `/api/models/test-all` result entry. + * + * Centralised so both "Test all models" handlers (ProviderDetailPageClient and + * PassthroughModelsSection) derive — and then apply — the same per-model status. + * Previously test-all only counted ok/error for a toast and never updated + * `modelTestStatus`, so the icons stayed blank and users could not tell which + * model failed (unlike the single-model ▶ test). When `autoHideFailed` is on, + * ANY non-ok result is auto-hidden — including rate-limited / timed-out failures + * (the user opted for "hide every failure"). + */ +export function evaluateTestAllEntry( + entry: { status?: "ok" | "error"; rateLimited?: boolean; isTimeout?: boolean } | null | undefined, + autoHideFailed: boolean +): TestAllModelOutcome { + const ok = entry?.status === "ok"; + return { + status: ok ? "ok" : "error", + // User opted for "hide every failure": any non-ok result is auto-hidden when + // the toggle is on, including rate-limited / timed-out failures. + shouldHide: !ok && autoHideFailed, + }; +} + +/** + * "Test all models" result toast. Centralises the i18n variable contract so the + * call sites cannot drift from the `testAllResults` template again — the template + * is `"{ok} of {total} models working"`, so it MUST receive `ok` and `total` + * (passing `{ ok, error }` previously raised next-intl's FORMATTING_ERROR). + */ +export function testAllResultsText( + t: ProviderMessageTranslator, + ok: number, + total: number +): string { + return providerText(t, "testAllResults", "{ok} of {total} models working", { ok, total }); +} + export function providerCountText( t: ProviderMessageTranslator, key: string, @@ -748,10 +794,7 @@ export function shouldSwitchToVisibleFilter(opts: { // --------------------------------------------------------------------------- // Error-type label map — shared by ConnectionRow and EditConnectionModal // --------------------------------------------------------------------------- -export const ERROR_TYPE_LABELS: Record< - string, - { labelKey: string; variant: string } -> = { +export const ERROR_TYPE_LABELS: Record = { runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" }, upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" }, account_deactivated: { labelKey: "Account Deactivated", variant: "error" }, @@ -819,3 +862,77 @@ export function formatTimeAgo(dateStr: string): string { if (days < 30) return `${days}d ago`; return new Date(dateStr).toLocaleDateString(); } + +// --------------------------------------------------------------------------- +// Provider-detail page pure helpers (Phase 1s — extracted from god-component) +// --------------------------------------------------------------------------- + +export function getApiLabel( + t: ProviderMessageTranslator, + isAnthropicProtocolCompatible: boolean, + apiType: string | undefined +): string { + if (isAnthropicProtocolCompatible) return t("messagesApi"); + switch (apiType) { + case "responses": + return t("responsesApi"); + case "embeddings": + return t("embeddings"); + case "audio-transcriptions": + return t("audioTranscriptions"); + case "audio-speech": + return t("audioSpeech"); + case "images-generations": + return t("imagesGenerations"); + default: + return t("chatCompletions"); + } +} + +export function getApiDefaultPath( + isCcCompatible: boolean, + isAnthropicCompatible: boolean, + apiType: string | undefined +): string { + if (isCcCompatible) return CC_COMPATIBLE_DEFAULT_CHAT_PATH; + if (isAnthropicCompatible) return "/messages"; + switch (apiType) { + case "responses": + return "/responses"; + case "embeddings": + return "/embeddings"; + case "audio-transcriptions": + return "/audio/transcriptions"; + case "audio-speech": + return "/audio/speech"; + case "images-generations": + return "/images/generations"; + default: + return "/chat/completions"; + } +} + +export function getApiPath( + isCcCompatible: boolean, + isAnthropicCompatible: boolean, + apiType: string | undefined, + chatPath: string | undefined +): string { + const defaultPath = getApiDefaultPath(isCcCompatible, isAnthropicCompatible, apiType); + return (chatPath || defaultPath).replace(/^\//, ""); +} + +export function getHeaderIconProviderId( + isOpenAICompatible: boolean, + isAnthropicProtocolCompatible: boolean, + providerInfoId: string, + providerInfoApiType: string | undefined +): string { + if (isOpenAICompatible && providerInfoApiType) { + return providerInfoApiType === "responses" ? "oai-r" : "oai-cc"; + } + if (isAnthropicProtocolCompatible) { + return "anthropic-m"; + } + return providerInfoId; +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderDisplayModeControl.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderDisplayModeControl.tsx new file mode 100644 index 0000000000..d833b9522f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderDisplayModeControl.tsx @@ -0,0 +1,120 @@ +"use client"; + +import type { ProviderDisplayMode } from "../providerPageStorage"; + +type ProviderMessageTranslator = ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; +}; + +function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + +interface ProviderDisplayModeControlProps { + disabledConfigured: boolean; + mode: ProviderDisplayMode; + onChange(mode: ProviderDisplayMode): void; + t: ProviderMessageTranslator; +} + +export default function ProviderDisplayModeControl({ + disabledConfigured, + mode, + onChange, + t, +}: ProviderDisplayModeControlProps) { + const options: Array<{ + mode: ProviderDisplayMode; + label: string; + icon: string; + disabled?: boolean; + title: string; + }> = [ + { + mode: "all", + label: providerText(t, "providerDisplayModeAll", "All"), + icon: "view_module", + title: providerText( + t, + "providerDisplayModeAllDesc", + "Show every provider in grouped sections." + ), + }, + { + mode: "configured", + label: providerText(t, "providerDisplayModeConfigured", "Configured"), + icon: "check_circle", + disabled: disabledConfigured, + title: providerText( + t, + "providerDisplayModeConfiguredDesc", + "Show providers with saved connections." + ), + }, + { + mode: "compact", + label: providerText(t, "providerDisplayModeCompact", "Compact"), + icon: "view_agenda", + title: providerText( + t, + "providerDisplayModeCompactDesc", + "Show configured and no-auth providers once in a single flat list." + ), + }, + ]; + + return ( +
+ {options.map((option) => { + const isActive = mode === option.mode; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx new file mode 100644 index 0000000000..fb304dae13 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { Button, Card, Input } from "@/shared/components"; +import type { ProviderDisplayMode } from "../providerPageStorage"; +import { CategoryDot } from "./CategoryDot"; +import ProviderDisplayModeControl from "./ProviderDisplayModeControl"; + +type ProviderMessageTranslator = ((key: string, values?: Record) => string) & { + has?: (key: string) => boolean; +}; + +type SummaryStat = { + configured: number; + total: number; +}; + +export interface ProviderSummaryStats { + all: SummaryStat; + free: SummaryStat; + noauth: SummaryStat; + oauth: SummaryStat; + apikey: SummaryStat; + compatible: SummaryStat; + webcookie: SummaryStat; + search: SummaryStat; + audio: SummaryStat; + local: SummaryStat; + upstreamproxy: SummaryStat; + cloudagent: SummaryStat; + ide: SummaryStat; + webfetch: SummaryStat; +} + +interface ProviderSummaryCardProps { + activeCategory: string | null; + disabledConfigured: boolean; + displayMode: ProviderDisplayMode; + onBatchTest(mode: string): void; + onCategoryChange(category: string | null, freeOnly: boolean): void; + onDisplayModeChange(mode: ProviderDisplayMode): void; + onNewProvider(): void; + searchQuery: string; + setSearchQuery(value: string): void; + showFreeOnly: boolean; + summaryStats: ProviderSummaryStats; + t: ProviderMessageTranslator; + tc: ProviderMessageTranslator; + testingMode: string | null; +} + +function providerText( + t: ProviderMessageTranslator, + key: string, + fallback: string, + values?: Record +): string { + if (typeof t.has === "function" && t.has(key)) { + return t(key, values); + } + if (values) { + return Object.entries(values).reduce( + (acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)), + fallback + ); + } + return fallback; +} + +export default function ProviderSummaryCard({ + activeCategory, + disabledConfigured, + displayMode, + onBatchTest, + onCategoryChange, + onDisplayModeChange, + onNewProvider, + searchQuery, + setSearchQuery, + showFreeOnly, + summaryStats, + t, + tc, + testingMode, +}: ProviderSummaryCardProps) { + const categories = [ + { key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all }, + { key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth }, + { key: "ide", color: "bg-cyan-500", label: "IDE", stat: summaryStats.ide }, + { + key: "free", + color: "bg-green-500", + label: t("freeTier"), + stat: summaryStats.free, + title: t("freeAggregated"), + }, + { key: "no-auth", color: "bg-stone-500", label: t("noAuthLabel"), stat: summaryStats.noauth }, + { + key: "upstream-proxy", + color: "bg-indigo-500", + label: t("upstreamProxyLabel"), + stat: summaryStats.upstreamproxy, + }, + { key: "apikey", color: "bg-amber-500", label: t("apiKeyLabel"), stat: summaryStats.apikey }, + { + key: "compatible", + color: "bg-orange-500", + label: t("compatibleLabel"), + stat: summaryStats.compatible, + }, + { key: "webcookie", color: "bg-purple-500", label: "Web Cookie", stat: summaryStats.webcookie }, + { key: "search", color: "bg-teal-500", label: "Search", stat: summaryStats.search }, + { + key: "webfetch", + color: "bg-orange-500", + label: t("webFetch"), + stat: summaryStats.webfetch, + title: t("webFetchTooltip"), + }, + { key: "audio", color: "bg-rose-500", label: "Audio", stat: summaryStats.audio }, + { key: "local", color: "bg-emerald-500", label: "Local", stat: summaryStats.local }, + { + key: "cloudagent", + color: "bg-violet-500", + label: "Cloud Agent", + stat: summaryStats.cloudagent, + }, + ]; + + return ( + +
+
+
+ setSearchQuery(e.target.value)} + placeholder={t("searchProviders")} + aria-label={t("searchProviders")} + icon="search" + inputClassName={searchQuery ? "pr-9" : ""} + /> + {searchQuery && ( + + )} +
+ + + +
+ +
+ {categories.map((cat) => { + const isActive = + (cat.key === null && !activeCategory && !showFreeOnly) || + (cat.key === "free" && showFreeOnly) || + (cat.key !== "free" && + cat.key !== null && + !showFreeOnly && + activeCategory === cat.key); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 764b571acb..2d59a0aca3 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,15 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { - Card, - CardSkeleton, - Badge, - Button, - Input, - Toggle, - CollapsibleSection, -} from "@/shared/components"; +import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components"; import { AGGREGATOR_PROVIDER_IDS, EMBEDDING_RERANK_PROVIDER_IDS, @@ -28,11 +20,15 @@ import { useTranslations } from "next-intl"; import { buildStaticProviderEntries, filterConfiguredProviderEntries, - shouldApplyConfiguredOnlyFilter, + shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; -import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; +import { + readProviderDisplayModePreference, + writeProviderDisplayModePreference, + type ProviderDisplayMode, +} from "./providerPageStorage"; import { getCodexEffectiveServiceTier, getCodexGlobalServiceMode, @@ -42,6 +38,11 @@ import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal" import { CategoryDot } from "./components/CategoryDot"; import ProviderCard from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; +import ProviderSummaryCard from "./components/ProviderSummaryCard"; +import { + buildCompactProviderEntriesForPage, + getCompactProviderAuthType, +} from "./providerCompactMode"; type DashboardProviderInfo = { id?: string; @@ -174,8 +175,8 @@ export default function ProvidersPage() { const [showAddCcCompatibleModal, setShowAddCcCompatibleModal] = useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); - const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); - const [configuredOnlyPreferenceReady, setConfiguredOnlyPreferenceReady] = useState(false); + const [providerDisplayMode, setProviderDisplayMode] = useState("all"); + const [displayModePreferenceReady, setDisplayModePreferenceReady] = useState(false); const [oauthEnvRepairStatus, setOauthEnvRepairStatus] = useState<{ available: boolean; missingCount: number; @@ -210,8 +211,8 @@ export default function ProvidersPage() { const searchParams = useSearchParams(); useEffect(() => { - setShowConfiguredOnly(readConfiguredOnlyPreference()); - setConfiguredOnlyPreferenceReady(true); + setProviderDisplayMode(readProviderDisplayModePreference()); + setDisplayModePreferenceReady(true); }, []); useEffect(() => { @@ -251,10 +252,21 @@ export default function ProvidersPage() { }, []); useEffect(() => { - if (!configuredOnlyPreferenceReady) return; + if (!displayModePreferenceReady) return; - writeConfiguredOnlyPreference(showConfiguredOnly); - }, [configuredOnlyPreferenceReady, showConfiguredOnly]); + const storedDisplayMode = + connections.length === 0 && providerDisplayMode === "configured" + ? "all" + : providerDisplayMode; + writeProviderDisplayModePreference(storedDisplayMode); + }, [connections.length, displayModePreferenceReady, providerDisplayMode]); + + useEffect(() => { + if (!displayModePreferenceReady) return; + if (connections.length === 0 && providerDisplayMode === "configured") { + setProviderDisplayMode("all"); + } + }, [connections.length, displayModePreferenceReady, providerDisplayMode]); const fetchOauthEnvRepairStatus = useCallback(async () => { try { @@ -492,10 +504,13 @@ export default function ProvidersPage() { textIcon: "CC", })); - const effectiveShowConfiguredOnly = shouldApplyConfiguredOnlyFilter( - showConfiguredOnly, + const effectiveProviderDisplayMode = + providerDisplayMode === "configured" && connections.length === 0 ? "all" : providerDisplayMode; + const effectiveShowConfiguredOnly = shouldFilterProviderEntriesForDisplayMode( + effectiveProviderDisplayMode, connections.length ); + const isCompactProviderDisplay = effectiveProviderDisplayMode === "compact"; const oauthProviderEntriesAll = buildStaticProviderEntries("oauth", getProviderStats); const oauthProviderEntries = filterConfiguredProviderEntries( @@ -705,6 +720,29 @@ export default function ProvidersPage() { showFreeOnly ); + const compactProviderEntries = buildCompactProviderEntriesForPage({ + activeCategory, + showFreeOnly, + freeSectionEntries, + compatibleProviderEntries, + oauthProviderEntries, + ideProviderEntries, + noAuthEntries, + upstreamProxyEntries, + llmProviderEntries, + aggregatorProviderEntries, + enterpriseProviderEntries, + embeddingRerankProviderEntries, + imageProviderEntries, + videoProviderEntries, + webCookieProviderEntries, + searchProviderEntries, + webFetchEntries, + audioProviderEntries, + localProviderEntries, + cloudAgentProviderEntries, + }); + const summaryStats = { all: countConfigured(dashboardProviderEntriesAll), free: countConfigured(freeSectionEntriesAll), @@ -721,7 +759,6 @@ export default function ProvidersPage() { ide: countConfigured(ideProviderEntriesAll), webfetch: countConfigured(webFetchEntriesAll), }; - if (loading) { return (
@@ -767,192 +804,25 @@ export default function ProvidersPage() { )} - {/* Provider Summary Card */} - -
- {/* Row 1: Search + Controls */} -
-
- setSearchQuery(e.target.value)} - placeholder={t("searchProviders")} - aria-label={t("searchProviders")} - icon="search" - inputClassName={searchQuery ? "pr-9" : ""} - /> - {searchQuery && ( - - )} -
- - - -
- - {/* Category filter pills with embedded counters */} -
- {( - [ - { key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all }, - { - key: "oauth", - color: "bg-blue-500", - label: t("oauthLabel"), - stat: summaryStats.oauth, - }, - { - key: "ide", - color: "bg-cyan-500", - label: "IDE", - stat: summaryStats.ide, - }, - { - key: "free", - color: "bg-green-500", - label: t("freeTier"), - stat: summaryStats.free, - title: t("freeAggregated"), - }, - { - key: "no-auth", - color: "bg-stone-500", - label: t("noAuthLabel"), - stat: summaryStats.noauth, - }, - { - key: "upstream-proxy", - color: "bg-indigo-500", - label: t("upstreamProxyLabel"), - stat: summaryStats.upstreamproxy, - }, - { - key: "apikey", - color: "bg-amber-500", - label: t("apiKeyLabel"), - stat: summaryStats.apikey, - }, - { - key: "compatible", - color: "bg-orange-500", - label: t("compatibleLabel"), - stat: summaryStats.compatible, - }, - { - key: "webcookie", - color: "bg-purple-500", - label: "Web Cookie", - stat: summaryStats.webcookie, - }, - { - key: "search", - color: "bg-teal-500", - label: "Search", - stat: summaryStats.search, - }, - { - key: "webfetch", - color: "bg-orange-500", - label: t("webFetch"), - stat: summaryStats.webfetch, - title: t("webFetchTooltip"), - }, - { - key: "audio", - color: "bg-rose-500", - label: "Audio", - stat: summaryStats.audio, - }, - { - key: "local", - color: "bg-emerald-500", - label: "Local", - stat: summaryStats.local, - }, - { - key: "cloudagent", - color: "bg-violet-500", - label: "Cloud Agent", - stat: summaryStats.cloudagent, - }, - ] as Array<{ - key: string | null; - color: string | null; - label: string; - stat: { configured: number; total: number }; - title?: string; - }> - ).map((cat) => { - const isActive = - (cat.key === null && !activeCategory && !showFreeOnly) || - (cat.key === "free" && showFreeOnly) || - (cat.key !== "free" && - cat.key !== null && - !showFreeOnly && - activeCategory === cat.key); - return ( - - ); - })} -
-
-
+ { + setShowFreeOnly(freeOnly); + setActiveCategory(freeOnly ? null : category); + }} + onDisplayModeChange={setProviderDisplayMode} + onNewProvider={() => router.push("/dashboard/providers/new")} + searchQuery={searchQuery} + setSearchQuery={setSearchQuery} + showFreeOnly={showFreeOnly} + summaryStats={summaryStats} + t={t} + tc={tc} + testingMode={testingMode} + /> {/* Expiration Banner */} {expirations?.summary && @@ -990,322 +860,520 @@ export default function ProvidersPage() {
)} - {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} - {showSection("compatible") && ( -
-
-

- {t("compatibleProviders")}{" "} - - -

-
- {(compatibleProviders.length > 0 || - anthropicCompatibleProviders.length > 0 || - ccCompatibleProviders.length > 0) && ( + {isCompactProviderDisplay ? ( + compactProviderEntries.length > 0 ? ( +
+ {compactProviderEntries.map((entry) => ( + + handleToggleProvider(entry.providerId, entry.toggleAuthType, active) + } + /> + ))} +
+ ) : ( +
+ search_off + {providerText(t, "noProvidersMatch", "No providers match your search.")} +
+ ) + ) : ( + <> + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} + {showSection("compatible") && ( +
+
+

+ {t("compatibleProviders")}{" "} + + +

+
+ {(compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0 || + ccCompatibleProviders.length > 0) && ( + + )} + {ccCompatibleProviderEnabled && ( + + )} + + +
+
+

{t("compatibleProvidersDesc")}

+ {compatibleProviders.length === 0 && + anthropicCompatibleProviders.length === 0 && + ccCompatibleProviders.length === 0 ? ( +
+ extension + {t("noCompatibleYet")} +
+ ) : ( +
+ {compatibleProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+ )} +
+ )} + + {/* OAuth Providers (including providers that expose free tiers via OAuth) */} + {showSection("oauth") && ( +
+
+

+ {t("oauthProviders")}{" "} + + !IDE_PROVIDER_IDS.has(e.providerId)) + )} + /> +

+
+ {oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && ( + + )} + +
+
+

{t("oauthProvidersDesc")}

+
+ {oauthProviderEntries + .filter((e) => !IDE_PROVIDER_IDS.has(e.providerId)) + .map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ))} +
+
+ )} + + {/* IDE Providers (Cursor, Zed, Trae) — editors with built-in AI subscription */} + {showSection("ide") && ( +
+
+

+ {t("ideProviders") || "IDE Providers"}{" "} + + +

+
+

+ {t("ideProvidersDesc") || + "Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."} +

+ {ideProviderEntries.length === 0 ? ( +
+ {t("noIdeProviders") || "No IDE providers match the current filters."} +
+ ) : ( +
+ {ideProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
)} - {ccCompatibleProviderEnabled && ( - - )} - -
-
-

{t("compatibleProvidersDesc")}

- {compatibleProviders.length === 0 && - anthropicCompatibleProviders.length === 0 && - ccCompatibleProviders.length === 0 ? ( -
- extension - {t("noCompatibleYet")} -
- ) : ( -
- {compatibleProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + )} + + {/* Web / Cookie Providers */} + {showSection("web") && webCookieProviderEntries.length > 0 && ( +
+
+

+ {t("webCookieProviders")}{" "} + + +

+ +
+

{t("webCookieProvidersDesc")}

+
+ {webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( handleToggleProvider(providerId, toggleAuthType, active)} /> - ) - )} + ))} +
)} -
- )} - {/* OAuth Providers (including providers that expose free tiers via OAuth) */} - {showSection("oauth") && ( -
-
-

- {t("oauthProviders")}{" "} - - !IDE_PROVIDER_IDS.has(e.providerId)) - )} - /> -

-
- {oauthEnvRepairStatus?.available && oauthEnvRepairStatus.missingCount > 0 && ( + {/* Free Tier Providers */} + {showSection("free") && freeSectionEntries.length > 0 && ( +
+
+
+

+ {t("freeTierProviders")} + + +

+

{t("freeAggregated")}

+
- )} - +
+
+ {freeSectionEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
-
-

{t("oauthProvidersDesc")}

-
- {oauthProviderEntries - .filter((e) => !IDE_PROVIDER_IDS.has(e.providerId)) - .map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + )} - {/* IDE Providers (Cursor, Zed, Trae) — editors with built-in AI subscription */} - {showSection("ide") && ( -
-
-

- {t("ideProviders") || "IDE Providers"}{" "} - - -

- -
-

- {t("ideProvidersDesc") || - "Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."} -

- {ideProviderEntries.length === 0 ? ( -
- {t("noIdeProviders") || "No IDE providers match the current filters."} -
- ) : ( -
- {ideProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) + {/* API Key Providers — fixed list */} + {showSection("apikey") && ( +
+
+

+ {t("apiKeyProviders")}{" "} + + +

+ +
+

{t("apiKeyProvidersDesc")}

+ {llmProviderEntries.length > 0 && ( +
+

+ {t("llmProviders")} +

+
+ {llmProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
)}
)} -
- )} - {/* Web / Cookie Providers */} - {showSection("web") && webCookieProviderEntries.length > 0 && ( -
-
-

- {t("webCookieProviders")}{" "} - - -

- -
-

{t("webCookieProvidersDesc")}

-
- {webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Free Tier Providers */} - {showSection("free") && freeSectionEntries.length > 0 && ( -
-
-
-

- {t("freeTierProviders")} - - -

-

{t("freeAggregated")}

-
- -
-
- {freeSectionEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} - - {/* API Key Providers — fixed list */} - {showSection("apikey") && ( -
-
-

- {t("apiKeyProviders")}{" "} - - -

- -
-

{t("apiKeyProvidersDesc")}

- {llmProviderEntries.length > 0 && ( -
-

- {t("llmProviders")} -

+ {/* No Auth Providers */} + {showSection("noauth") && noAuthEntries.length > 0 && ( +
+
+

+ {t("noAuthProviders")}{" "} + + +

+ +
+

{t("noAuthProvidersDesc")}

- {llmProviderEntries.map( + {noAuthEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))} +
+
+ )} + + {/* Upstream Proxy Providers */} + {showSection("proxy") && upstreamProxyEntries.length > 0 && ( +
+
+

+ {t("upstreamProxyProviders")}{" "} + + +

+ +
+

{t("upstreamProxyProvidersDesc")}

+
+ {upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))} +
+
+ )} + + {/* Web Fetch Providers */} + {showSection("webfetch") && webFetchEntries.length > 0 && ( +
+
+

+ {t("webFetchProvidersHeading")}{" "} + + +

+
+

{t("webFetchProvidersDesc")}

+
+ {webFetchEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + + {/* Aggregators Gateways */} + {showSection("apikey") && aggregatorProviderEntries.length > 0 && ( +
+
+

+ {t("aggregatorsGateways")}{" "} + + +

+
+

{t("aggregatorsGatewaysDesc")}

+
+ {aggregatorProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
)} -
- )} - {/* No Auth Providers */} - {showSection("noauth") && noAuthEntries.length > 0 && ( -
-
-

- {t("noAuthProviders")}{" "} - - -

- -
-

{t("noAuthProvidersDesc")}

-
- {noAuthEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + {/* Enterprise & Cloud */} + {showSection("apikey") && enterpriseProviderEntries.length > 0 && ( +
+
+

+ {t("enterpriseCloud")}{" "} + + +

+
+

{t("enterpriseCloudDesc")}

+
+ {enterpriseProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} - {/* Upstream Proxy Providers */} - {showSection("proxy") && upstreamProxyEntries.length > 0 && ( -
-
-

- {t("upstreamProxyProviders")}{" "} - - -

- -
-

{t("upstreamProxyProvidersDesc")}

-
- {upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + {/* Cloud Agent Providers */} + {showSection("cloud") && cloudAgentProviderEntries.length > 0 && ( +
+
+

+ {t("cloudAgentProviders")}{" "} + + +

+ +
+

{t("cloudAgentProvidersDesc")}

+
+ {cloudAgentProviderEntries.map( + ({ providerId, provider, stats, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} - {/* Web Fetch Providers */} - {showSection("webfetch") && webFetchEntries.length > 0 && ( -
-
-

- {t("webFetchProvidersHeading")}{" "} - - -

-
-

{t("webFetchProvidersDesc")}

-
- {webFetchEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} + {/* Local / Self-Hosted Providers */} + {showSection("local") && localProviderEntries.length > 0 && ( +
+
+

+ {t("localProviders")}{" "} + + +

+ +
+

{t("localProvidersDesc")}

+
+ {localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))} +
+
+ )} - {/* Aggregators Gateways */} - {showSection("apikey") && aggregatorProviderEntries.length > 0 && ( -
-
-

- {t("aggregatorsGateways")}{" "} - - -

-
-

{t("aggregatorsGatewaysDesc")}

-
- {aggregatorProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} + {/* Search Providers */} + {showSection("search") && searchProviderEntries.length > 0 && ( +
+
+

+ {t("searchProvidersHeading")}{" "} + + +

+ +
+

{t("searchProvidersDesc")}

+
+ {searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))} +
+
+ )} - {/* Enterprise & Cloud */} - {showSection("apikey") && enterpriseProviderEntries.length > 0 && ( -
-
-

- {t("enterpriseCloud")}{" "} - - -

-
-

{t("enterpriseCloudDesc")}

-
- {enterpriseProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} + {/* Embeddings & Rerank */} + {showSection("apikey") && embeddingRerankProviderEntries.length > 0 && ( +
+
+

+ {t("embeddingRerankProviders")}{" "} + + +

+
+

{t("embeddingRerankProvidersDesc")}

+
+ {embeddingRerankProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} - {/* Cloud Agent Providers */} - {showSection("cloud") && cloudAgentProviderEntries.length > 0 && ( -
-
-

- {t("cloudAgentProviders")}{" "} - - -

- -
-

{t("cloudAgentProvidersDesc")}

-
- {cloudAgentProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + {/* Image Providers */} + {showSection("apikey") && imageProviderEntries.length > 0 && ( +
+
+

+ {t("imageProviders")}{" "} + + +

+
+

{t("imageProvidersDesc")}

+
+ {imageProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} - {/* Local / Self-Hosted Providers */} - {showSection("local") && localProviderEntries.length > 0 && ( -
-
-

- {t("localProviders")}{" "} - - -

- -
-

{t("localProvidersDesc")}

-
- {localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} + {/* Audio Only Providers */} + {showSection("audio") && audioProviderEntries.length > 0 && ( +
+
+

+ {t("audioProvidersHeading")}{" "} + + +

+ +
+

{t("audioProvidersDesc")}

+
+ {audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))} +
+
+ )} - {/* Search Providers */} - {showSection("search") && searchProviderEntries.length > 0 && ( -
-
-

- {t("searchProvidersHeading")}{" "} - - -

- -
-

{t("searchProvidersDesc")}

-
- {searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Embeddings & Rerank */} - {showSection("apikey") && embeddingRerankProviderEntries.length > 0 && ( -
-
-

- {t("embeddingRerankProviders")}{" "} - - -

-
-

{t("embeddingRerankProvidersDesc")}

-
- {embeddingRerankProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} - - {/* Image Providers */} - {showSection("apikey") && imageProviderEntries.length > 0 && ( -
-
-

- {t("imageProviders")}{" "} - - -

-
-

{t("imageProvidersDesc")}

-
- {imageProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
- )} - - {/* Audio Only Providers */} - {showSection("audio") && audioProviderEntries.length > 0 && ( -
-
-

- {t("audioProvidersHeading")}{" "} - - -

- -
-

{t("audioProvidersDesc")}

-
- {audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ))} -
-
- )} - - {/* Video Generation */} - {showSection("apikey") && videoProviderEntries.length > 0 && ( -
-
-

- {t("videoProviders")}{" "} - - -

-
-

{t("videoProvidersDesc")}

-
- {videoProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
-
+ {/* Video Generation */} + {showSection("apikey") && videoProviderEntries.length > 0 && ( +
+
+

+ {t("videoProviders")}{" "} + + +

+
+

{t("videoProvidersDesc")}

+
+ {videoProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + + handleToggleProvider(providerId, toggleAuthType, active) + } + /> + ) + )} +
+
+ )} + )} = ProviderEntry[]; + +export interface CompactProviderEntryOptions { + activeCategory: string; + showFreeOnly: boolean; + freeSectionEntries: ProviderCategoryEntries; + compatibleProviderEntries: ProviderCategoryEntries; + oauthProviderEntries: ProviderCategoryEntries; + ideProviderEntries: ProviderCategoryEntries; + noAuthEntries: ProviderCategoryEntries; + upstreamProxyEntries: ProviderCategoryEntries; + llmProviderEntries: ProviderCategoryEntries; + aggregatorProviderEntries: ProviderCategoryEntries; + enterpriseProviderEntries: ProviderCategoryEntries; + embeddingRerankProviderEntries: ProviderCategoryEntries; + imageProviderEntries: ProviderCategoryEntries; + videoProviderEntries: ProviderCategoryEntries; + webCookieProviderEntries: ProviderCategoryEntries; + searchProviderEntries: ProviderCategoryEntries; + webFetchEntries: ProviderCategoryEntries; + audioProviderEntries: ProviderCategoryEntries; + localProviderEntries: ProviderCategoryEntries; + cloudAgentProviderEntries: ProviderCategoryEntries; +} + +function getCompactProviderEntryGroups({ + activeCategory, + showFreeOnly, + freeSectionEntries, + compatibleProviderEntries, + oauthProviderEntries, + ideProviderEntries, + noAuthEntries, + upstreamProxyEntries, + llmProviderEntries, + aggregatorProviderEntries, + enterpriseProviderEntries, + embeddingRerankProviderEntries, + imageProviderEntries, + videoProviderEntries, + webCookieProviderEntries, + searchProviderEntries, + webFetchEntries, + audioProviderEntries, + localProviderEntries, + cloudAgentProviderEntries, +}: CompactProviderEntryOptions): ProviderEntry[][] { + const oauthEntries = oauthProviderEntries.filter( + (entry) => !IDE_PROVIDER_IDS.has(entry.providerId) + ); + const apiKeyEntries = [ + llmProviderEntries, + aggregatorProviderEntries, + enterpriseProviderEntries, + embeddingRerankProviderEntries, + imageProviderEntries, + videoProviderEntries, + ]; + + if (showFreeOnly) return [freeSectionEntries]; + + if (activeCategory === "compatible") return [compatibleProviderEntries]; + if (activeCategory === "oauth") return [oauthEntries]; + if (activeCategory === "ide") return [ideProviderEntries]; + if (activeCategory === "no-auth") return [noAuthEntries]; + if (activeCategory === "upstream-proxy") return [upstreamProxyEntries]; + if (activeCategory === "apikey") return apiKeyEntries; + if (activeCategory === "webcookie") return [webCookieProviderEntries]; + if (activeCategory === "search") return [searchProviderEntries]; + if (activeCategory === "webfetch") return [webFetchEntries]; + if (activeCategory === "audio") return [audioProviderEntries]; + if (activeCategory === "local") return [localProviderEntries]; + if (activeCategory === "cloudagent") return [cloudAgentProviderEntries]; + + return [ + compatibleProviderEntries, + oauthEntries, + ideProviderEntries, + webCookieProviderEntries, + llmProviderEntries, + upstreamProxyEntries, + aggregatorProviderEntries, + enterpriseProviderEntries, + cloudAgentProviderEntries, + localProviderEntries, + searchProviderEntries, + embeddingRerankProviderEntries, + imageProviderEntries, + audioProviderEntries, + videoProviderEntries, + noAuthEntries, + ]; +} + +export function buildCompactProviderEntriesForPage( + options: CompactProviderEntryOptions +): ProviderEntry[] { + return buildCompactProviderEntries(getCompactProviderEntryGroups(options), { + deferNoAuth: options.activeCategory !== "no-auth", + }); +} + +const CATEGORY_AUTH_TYPES: Record = { + "cloud-agent": "cloud-agent", + "no-auth": "no-auth", + "upstream-proxy": "upstream-proxy", + "web-cookie": "web-cookie", + audio: "audio", + local: "local", + search: "search", +}; + +export function getCompactProviderAuthType( + entry: ProviderEntry, + showFreeOnly: boolean +): string { + if (showFreeOnly && entry.toggleAuthType === "free") return "free"; + if (entry.displayAuthType === "compatible") return "compatible"; + + const info = resolveDashboardProviderInfo(entry.providerId); + if (!info) return entry.displayAuthType; + + return CATEGORY_AUTH_TYPES[info.category] ?? entry.displayAuthType; +} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts b/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts index ff7df5b817..92ad2cf1ad 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageStorage.ts @@ -1,4 +1,7 @@ export const SHOW_CONFIGURED_ONLY_STORAGE_KEY = "omniroute-providers-show-configured-only"; +export const PROVIDER_DISPLAY_MODE_STORAGE_KEY = "omniroute-providers-display-mode"; + +export type ProviderDisplayMode = "all" | "configured" | "compact"; interface StorageReader { getItem(key: string): string | null; @@ -9,10 +12,20 @@ interface StorageWriter extends StorageReader { removeItem(key: string): void; } +type StorageReaderWriter = StorageReader & Partial; + export function parseConfiguredOnlyPreference(value: string | null | undefined): boolean { return value === "true"; } +export function parseProviderDisplayModePreference( + value: string | null | undefined +): ProviderDisplayMode | null { + if (value === "all" || value === "configured" || value === "compact") return value; + + return null; +} + function getBrowserStorage(): StorageWriter | null { try { return globalThis.localStorage ?? null; @@ -40,3 +53,36 @@ export function writeConfiguredOnlyPreference( storage.removeItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY); } + +export function readProviderDisplayModePreference( + storage: StorageReaderWriter | null = getBrowserStorage() +): ProviderDisplayMode { + if (!storage) return "all"; + + const storedMode = parseProviderDisplayModePreference( + storage.getItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY) + ); + if (storedMode) return storedMode; + + if (!readConfiguredOnlyPreference(storage)) return "all"; + + storage.setItem?.(PROVIDER_DISPLAY_MODE_STORAGE_KEY, "configured"); + storage.removeItem?.(SHOW_CONFIGURED_ONLY_STORAGE_KEY); + return "configured"; +} + +export function writeProviderDisplayModePreference( + mode: ProviderDisplayMode, + storage: StorageWriter | null = getBrowserStorage() +) { + if (!storage) return; + + storage.removeItem(SHOW_CONFIGURED_ONLY_STORAGE_KEY); + + if (mode === "all") { + storage.removeItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY); + return; + } + + storage.setItem(PROVIDER_DISPLAY_MODE_STORAGE_KEY, mode); +} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 8da41a3e1b..ba1afe170d 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -8,6 +8,7 @@ import { type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; +import type { ProviderDisplayMode } from "./providerPageStorage"; export interface ProviderStatsSnapshot { total?: number; @@ -29,6 +30,15 @@ export function shouldApplyConfiguredOnlyFilter( return showConfiguredOnly && connectionCount > 0; } +export function shouldFilterProviderEntriesForDisplayMode( + displayMode: ProviderDisplayMode, + connectionCount: number +): boolean { + if (displayMode === "compact") return true; + + return shouldApplyConfiguredOnlyFilter(displayMode === "configured", connectionCount); +} + export function shouldShowFirstProviderHint( connectionCount: number, searchQuery?: string @@ -135,6 +145,44 @@ export function filterConfiguredProviderEntries( return sortProviderEntriesByName(filtered); } +function pushUniqueProviderEntry( + entries: ProviderEntry[], + seenProviderIds: Set, + entry: ProviderEntry +) { + if (seenProviderIds.has(entry.providerId)) return; + + seenProviderIds.add(entry.providerId); + entries.push(entry); +} + +export function buildCompactProviderEntries( + groups: ProviderEntry[][], + options: { deferNoAuth?: boolean } = {} +): ProviderEntry[] { + const seenProviderIds = new Set(); + const visibleEntries: ProviderEntry[] = []; + const deferredNoAuthEntries: ProviderEntry[] = []; + const seenDeferredNoAuthProviderIds = new Set(); + + for (const group of groups) { + for (const entry of group) { + if (options.deferNoAuth && entry.displayAuthType === "no-auth") { + pushUniqueProviderEntry(deferredNoAuthEntries, seenDeferredNoAuthProviderIds, entry); + continue; + } + + pushUniqueProviderEntry(visibleEntries, seenProviderIds, entry); + } + } + + for (const entry of deferredNoAuthEntries) { + pushUniqueProviderEntry(visibleEntries, seenProviderIds, entry); + } + + return visibleEntries; +} + export function resolveDashboardProviderInfo( providerId: string, options?: { diff --git a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx index 7e8c1537fd..6b254addcf 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ComboDefaultsTab.tsx @@ -91,6 +91,7 @@ export default function ComboDefaultsTab() { retryDelayMs: 2000, maxComboDepth: 3, trackMetrics: true, + reasoningTokenBufferEnabled: true, handoffThreshold: 0.85, handoffModel: "", maxMessagesForSummary: 30, @@ -556,6 +557,29 @@ export default function ComboDefaultsTab() { } />
+
+
+

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

+

+ {translateOrFallback( + t, + "reasoningTokenBufferDesc", + "Allow combo routing to add max_tokens headroom only for known reasoning models when the full buffer fits inside a known output cap." + )} +

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

diff --git a/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx new file mode 100644 index 0000000000..f686776fc3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/ModelLockoutCard.tsx @@ -0,0 +1,511 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Card, Toggle } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; +import { useTranslations } from "next-intl"; + +type ModelLockoutSettings = { + enabled: boolean; + errorCodes: number[]; + baseCooldownMs: number; + maxCooldownMs: number; + maxBackoffSteps: number; + useExponentialBackoff: boolean; +}; + +const DEFAULTS: ModelLockoutSettings = { + enabled: false, + errorCodes: [403, 404, 429, 502, 503, 504], + baseCooldownMs: 120_000, + maxCooldownMs: 1_800_000, + maxBackoffSteps: 10, + useExponentialBackoff: true, +}; + + + +function NumberField({ + label, + value, + suffix, + min = 0, + max, + hint, + onChange, +}: { + label: string; + value: number; + suffix?: string; + min?: number; + max?: number; + hint?: string; + onChange: (value: number) => void; +}) { + return ( +

+ { + if (event.target.value === "") return; + const nextValue = Number(event.target.value); + if (Number.isFinite(nextValue)) { + onChange(nextValue); + } + }} + className="w-full rounded-lg border border-border bg-bg-subtle px-3 py-2 text-sm" + /> + {suffix ? {suffix} : null} +
+ + ); +} + +export default function ModelLockoutCard() { + const t = useTranslations("settings"); + const tc = useTranslations("common"); + const notify = useNotificationStore(); + + const [data, setData] = useState(DEFAULTS); + const [draft, setDraft] = useState(DEFAULTS); + const [errorCodesInput, setErrorCodesInput] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let mounted = true; + + const load = async () => { + try { + const res = await fetch("/api/settings", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + if (!mounted) return; + + const raw = (json as Record).modelLockout as + | Record + | undefined; + + const parsed: ModelLockoutSettings = { + enabled: + typeof raw?.enabled === "boolean" ? raw.enabled : DEFAULTS.enabled, + errorCodes: Array.isArray(raw?.errorCodes) + ? [...(raw.errorCodes as number[])].sort((a, b) => a - b) + : [...DEFAULTS.errorCodes].sort((a, b) => a - b), + baseCooldownMs: + typeof raw?.baseCooldownMs === "number" + ? raw.baseCooldownMs + : DEFAULTS.baseCooldownMs, + maxCooldownMs: + typeof raw?.maxCooldownMs === "number" + ? raw.maxCooldownMs + : DEFAULTS.maxCooldownMs, + maxBackoffSteps: + typeof raw?.maxBackoffSteps === "number" + ? raw.maxBackoffSteps + : DEFAULTS.maxBackoffSteps, + useExponentialBackoff: + typeof raw?.useExponentialBackoff === "boolean" + ? raw.useExponentialBackoff + : DEFAULTS.useExponentialBackoff, + }; + + setData(parsed); + setDraft(parsed); + setErrorCodesInput(""); + } catch (error) { + notify.error( + error instanceof Error + ? error.message + : "Failed to load model lockout settings" + ); + } finally { + if (mounted) setLoading(false); + } + }; + + void load(); + return () => { + mounted = false; + }; + }, []); + + const hasChanges = + draft.enabled !== data.enabled || + JSON.stringify([...draft.errorCodes].sort((a, b) => a - b)) !== + JSON.stringify([...data.errorCodes].sort((a, b) => a - b)) || + draft.baseCooldownMs !== data.baseCooldownMs || + draft.maxCooldownMs !== data.maxCooldownMs || + draft.useExponentialBackoff !== data.useExponentialBackoff || + draft.maxBackoffSteps !== data.maxBackoffSteps; + + function validateDraft(d: ModelLockoutSettings): string | null { + if (d.baseCooldownMs < 5000 || d.baseCooldownMs > 600000) + return `Base Cooldown must be between 5,000ms and 600,000ms`; + if (d.maxCooldownMs < 5000 || d.maxCooldownMs > 3600000) + return `Max Cooldown must be between 5,000ms and 3,600,000ms`; + if (d.maxCooldownMs < d.baseCooldownMs) + return `Max Cooldown must be ≥ Base Cooldown`; + if (d.maxBackoffSteps < 0 || d.maxBackoffSteps > 20) + return `Max Backoff Steps must be between 0 and 20`; + return null; + } + + const handleSave = async () => { + const validationError = validateDraft(draft); + if (validationError) { + notify.error(validationError); + return; + } + setSaving(true); + const saveDraft = { ...draft, errorCodes: draft.errorCodes }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelLockout: saveDraft }), + }); + if (!res.ok) { + const err = await res.json().catch(() => null); + const issues = err?.error?.issues ?? err?.error?.details; + if (Array.isArray(issues) && issues.length > 0) { + const fieldLabels: Record = { + "modelLockout.baseCooldownMs": "Base Cooldown", + "modelLockout.maxCooldownMs": "Max Cooldown", + "modelLockout.maxBackoffSteps": "Max Backoff Steps", + "modelLockout.errorCodes": "Error Codes", + }; + const msg = issues + .map( + (d: { path?: (string | number)[]; message?: string }) => + `${fieldLabels[String(d.path?.[0])] || String(d.path?.[0] || "")}: ${d.message}` + ) + .filter(Boolean) + .join("\n"); + if (msg) throw new Error(msg); + } + throw new Error( + err?.error?.message || `HTTP ${res.status}` + ); + } + const json = await res.json(); + const raw = (json as Record).modelLockout as + | Record + | undefined; + if (raw) { + setData({ + enabled: + typeof raw.enabled === "boolean" ? raw.enabled : saveDraft.enabled, + errorCodes: Array.isArray(raw.errorCodes) + ? [...(raw.errorCodes as number[])].sort((a, b) => a - b) + : [...saveDraft.errorCodes].sort((a, b) => a - b), + baseCooldownMs: + typeof raw.baseCooldownMs === "number" + ? raw.baseCooldownMs + : saveDraft.baseCooldownMs, + maxCooldownMs: + typeof raw.maxCooldownMs === "number" + ? raw.maxCooldownMs + : saveDraft.maxCooldownMs, + maxBackoffSteps: + typeof raw.maxBackoffSteps === "number" + ? raw.maxBackoffSteps + : saveDraft.maxBackoffSteps, + useExponentialBackoff: + typeof raw.useExponentialBackoff === "boolean" + ? raw.useExponentialBackoff + : saveDraft.useExponentialBackoff, + }); + } else { + setData(saveDraft); + } + setErrorCodesInput(""); + notify.success(t("savedSuccessfully") || "Settings saved successfully"); + } catch (error) { + notify.error( + error instanceof Error + ? error.message + : "Failed to save model lockout settings" + ); + } finally { + setSaving(false); + } + }; + + const handleReset = () => { + setDraft(data); + setErrorCodesInput(""); + }; + + const commitErrorCodes = (inputOverride?: string) => { + const raw = inputOverride ?? errorCodesInput; + const code = Number(raw); + if (!Number.isFinite(code) || code < 100 || code > 599) return; + if (draft.errorCodes.includes(code)) { + setErrorCodesInput(""); + return; + } + setDraft((prev) => ({ ...prev, errorCodes: [...prev.errorCodes, code].sort((a, b) => a - b) })); + setErrorCodesInput(""); + }; + + const removeErrorCode = (code: number) => { + setDraft((prev) => ({ ...prev, errorCodes: prev.errorCodes.filter((c) => c !== code) })); + }; + + const handleResetDefaults = () => { + setDraft({ + ...DEFAULTS, + errorCodes: [...DEFAULTS.errorCodes].sort((a, b) => a - b), + }); + setErrorCodesInput(""); + }; + + const fmt = (ms: number) => { + if (ms >= 60_000) return `${ms / 1000 / 60}m`; + if (ms >= 1_000) return `${ms / 1000}s`; + return `${ms}ms`; + }; + + const notifyRef = useRef(null); + const playNotify = useCallback(() => { + try { + if (notifyRef.current) { + notifyRef.current.pause(); + notifyRef.current.currentTime = 0; + } else { + notifyRef.current = new Audio("/audio/ui-notify.mp3"); + notifyRef.current.volume = 0.3; + } + void notifyRef.current.play(); + } catch { /* audio not available */ } + }, []); + + if (loading) { + return ( + +
+ + progress_activity + + Loading model lockout settings... +
+
+ ); + } + + return ( + +
+
+
+ + gpp_maybe + +

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

+
+

+ {t("modelLockoutPageDescription")} +

+
+ {hasChanges ? ( +
+ + +
+ ) : ( + + )} +
+ +
+ {/* Master toggle */} +
+ { + setDraft((prev) => ({ ...prev, enabled: checked })); + playNotify(); + }} + label={t("modelLockoutEnabled")} + description={t("modelLockoutEnabledDescription")} + /> +
+ + {/* Error codes — tag input */} +
+ + + {/* Chips row */} + {draft.errorCodes.length > 0 && ( +
+ {draft.errorCodes.map((code) => ( + + {code} + + + ))} +
+ )} + + {/* Input row */} +
+ { + const raw = e.target.value.replace(/[^0-9]/g, ""); + if (raw.length <= 3) setErrorCodesInput(raw); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitErrorCodes(); + } + }} + placeholder="Add error code..." + className="w-32 rounded-lg border border-border bg-bg px-3 py-2 text-sm outline-none focus:border-primary transition-colors placeholder:text-text-muted/50" + /> + +
+ + {/* Suggested common codes — chips as clickable suggestions */} + {draft.errorCodes.length === 0 && errorCodesInput === "" && ( +
+ Suggestions: + {[403, 404, 429, 502, 503, 504].map((code) => ( + + ))} +
+ )} +
+ + {/* Cooldowns - grid */} +
+
+ + setDraft((prev) => ({ ...prev, baseCooldownMs })) + } + /> +

+ {t("modelLockoutBaseCooldownDescription")} +

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

+ {t("modelLockoutMaxCooldownDescription")} +

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

+ {t("modelLockoutMaxBackoffStepsDescription")} +

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx index 1aa9f0855f..3e9d28edac 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx @@ -6,6 +6,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; import AutoDisableCard from "./AutoDisableCard"; import ModelCooldownsCard from "./ModelCooldownsCard"; +import ModelLockoutCard from "./ModelLockoutCard"; type RequestQueueSettings = { autoEnableApiKeyProviders: boolean; @@ -975,6 +976,7 @@ export default function ResilienceTab() { saving={savingSection === "providerCooldown"} onSave={(providerCooldown) => savePatch("providerCooldown", { providerCooldown })} /> +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx b/src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx index 0478b495db..e2abc6a342 100644 --- a/src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx @@ -60,6 +60,7 @@ function SortableSection({ onItemReorder, getLabel, }: SortableSectionProps) { + const tSidebar = useTranslations("sidebar"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: section.id, }); @@ -99,8 +100,8 @@ function SortableSection({ {...listeners} {...attributes} className="text-text-muted/40 hover:text-text-muted/80 cursor-grab active:cursor-grabbing touch-none shrink-0" - title="Drag to reorder section" - aria-label="Drag to reorder section" + title={tSidebar("dragReorderSection")} + aria-label={tSidebar("dragReorderSection")} > drag_indicator @@ -168,6 +169,7 @@ function SortableSection({ // ─── Sortable child row wrapper ──────────────────────────────────────────────── function SortableChildRow({ id, children }: { id: string; children: React.ReactNode }) { + const tSidebar = useTranslations("sidebar"); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }); @@ -182,8 +184,8 @@ function SortableChildRow({ id, children }: { id: string; children: React.ReactN {...listeners} {...attributes} className="mt-3.5 ml-4 text-text-muted/30 hover:text-text-muted/70 cursor-grab active:cursor-grabbing touch-none shrink-0" - title="Drag to reorder" - aria-label="Drag to reorder" + title={tSidebar("dragReorderItem")} + aria-label={tSidebar("dragReorderItem")} > drag_indicator @@ -205,6 +207,7 @@ interface ItemRowProps { const PROTECTED_ITEM_IDS = new Set(["settings-sidebar"]); function ItemRow({ item, hiddenSet, onToggleItem, getLabel }: ItemRowProps) { + const tSidebar = useTranslations("sidebar"); const isProtected = PROTECTED_ITEM_IDS.has(item.id); return (
@@ -217,8 +220,8 @@ function ItemRow({ item, hiddenSet, onToggleItem, getLabel }: ItemRowProps) { {isProtected ? ( lock diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx index ea6ddde29f..d5a0861480 100644 --- a/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx @@ -41,7 +41,8 @@ export default function DocumentationTab() {

SOCKS5

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

diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.tsx index 51ff62bc01..15a50b580b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaProgressBar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { cn } from "@/shared/utils/cn"; import { formatResetTime } from "./utils"; @@ -73,12 +74,14 @@ export default function QuotaProgressBar({ resetTime = null, staleAfterReset = false, }) { + const t = useTranslations("usage"); const colors = getColorClasses(percentage); const countdown = formatResetTime(resetTime); const resetDisplay = formatResetTimeDisplay(resetTime); // percentage is already remaining percentage (from ProviderLimitCard) const remaining = percentage; + const usedPercentage = Math.max(0, Math.min(100, 100 - remaining)); return (
@@ -87,7 +90,9 @@ export default function QuotaProgressBar({ {label}
{colors.emoji} - {remaining}% + + {t("percentUsed", { pct: usedPercentage })} +
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx index 23bb0d3d46..f86b2462f9 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardBody.tsx @@ -26,6 +26,7 @@ interface Props { const MAX_VISIBLE_DEFAULT = 3; function QuotaRow({ q }: { q: any }) { + const t = useTranslations("usage"); if (q.isCredits) { const colors = getBarColor(q.remainingPercentage ?? 0); const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? ""; @@ -56,6 +57,7 @@ function QuotaRow({ q }: { q: any }) { ? 100 : (q.remainingPercentage ?? calculatePercentage(q.used, q.total)); const pct = Math.round(pctRaw); + const usedPct = Math.max(0, Math.min(100, 100 - pct)); const colors = getBarColor(pct); const label = q.displayName || formatQuotaLabel(q.name); @@ -67,7 +69,7 @@ function QuotaRow({ q }: { q: any }) { className="text-[11px] font-bold tabular-nums shrink-0" style={{ color: colors.text }} > - {q.unlimited ? "∞" : `${pct}%`} + {q.unlimited ? "∞" : t("percentUsed", { pct: usedPct })}
{!q.unlimited && } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index d8bef91377..717bf10b7e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -27,6 +27,7 @@ interface Props { } function QuotaDetailRow({ q }: { q: any }) { + const t = useTranslations("usage"); if (q.isCredits) { const colors = getBarColor(q.remainingPercentage ?? 0); const sym = CURRENCY_SYMBOLS[q.currency] ?? q.currency ?? ""; @@ -54,6 +55,7 @@ function QuotaDetailRow({ q }: { q: any }) { ? 100 : (q.remainingPercentage ?? calculatePercentage(q.used, q.total)); const pct = Math.round(pctRaw); + const usedPct = Math.max(0, Math.min(100, 100 - pct)); const colors = getBarColor(pct); const cd = formatCountdown(q.resetAt); const label = q.displayName || formatQuotaLabel(q.name); @@ -69,7 +71,7 @@ function QuotaDetailRow({ q }: { q: any }) { className="text-[12px] font-bold tabular-nums shrink-0" style={{ color: colors.text }} > - {q.unlimited ? "∞" : `${pct}%`} + {q.unlimited ? "∞" : t("percentUsed", { pct: usedPct })}
{!q.unlimited && } diff --git a/src/app/api/intelligence/sync/route.ts b/src/app/api/intelligence/sync/route.ts new file mode 100644 index 0000000000..bf97489a72 --- /dev/null +++ b/src/app/api/intelligence/sync/route.ts @@ -0,0 +1,82 @@ +/** + * API Route: /api/intelligence/sync + * + * POST — Trigger a manual Arena ELO intelligence sync. + * GET — Get current intelligence sync status. + * DELETE — Clear all synced arena_elo intelligence data. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { intelligenceSyncRequestSchema } from "@/shared/validation/schemas"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; + +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { + error: { + message: "Invalid request", + details: [{ field: "body", message: "Invalid JSON body" }], + }, + }, + { status: 400 } + ); + } + + try { + const validation = validateBody(intelligenceSyncRequestSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + const { dryRun = false } = validation.data; + + const { syncArenaElo } = await import("@/lib/arenaEloSync"); + const result = await syncArenaElo(dryRun); + + return NextResponse.json(result, { status: result.success ? 200 : 502 }); + } catch (err) { + return NextResponse.json( + { error: sanitizeErrorMessage(err) }, + { status: 500 } + ); + } +} + +export async function GET(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { getArenaEloSyncStatus } = await import("@/lib/arenaEloSync"); + return NextResponse.json(getArenaEloSyncStatus()); + } catch (err) { + return NextResponse.json( + { error: sanitizeErrorMessage(err) }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { clearSyncedIntelligence } = await import("@/lib/arenaEloSync"); + clearSyncedIntelligence(); + return NextResponse.json({ success: true, message: "Synced intelligence data cleared" }); + } catch (err) { + return NextResponse.json( + { error: sanitizeErrorMessage(err) }, + { status: 500 } + ); + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 78c9865f71..2e34784e42 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -77,6 +77,7 @@ import { isAutoFetchModelsEnabled, persistDiscoveredModels, } from "@/lib/providerModels/modelDiscovery"; +import { parseGeminiModelsList, type GeminiDiscoveryModel } from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels } from "@/lib/db/models"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; @@ -399,50 +400,7 @@ const PROVIDER_MODELS_CONFIG: Record = { method: "GET", headers: { "Content-Type": "application/json" }, authQuery: "key", // Use query param for API key - parseResponse: (data) => { - const METHOD_TO_ENDPOINT: Record = { - generateContent: "chat", - embedContent: "embeddings", - predict: "images", - predictLongRunning: "images", - bidiGenerateContent: "audio", - generateAnswer: "chat", - }; - const IGNORED_METHODS = new Set([ - "countTokens", - "countTextTokens", - "createCachedContent", - "batchGenerateContent", - "asyncBatchEmbedContent", - ]); - - return (data.models || []).map((m: Record) => { - const methods: string[] = Array.isArray(m.supportedGenerationMethods) - ? m.supportedGenerationMethods - : []; - const endpoints = [ - ...new Set( - methods - .filter((method) => !IGNORED_METHODS.has(method)) - .map((method) => METHOD_TO_ENDPOINT[method] || "chat") - ), - ]; - if (endpoints.length === 0) endpoints.push("chat"); - - return { - ...m, - id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""), - name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""), - supportedEndpoints: endpoints, - ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), - ...(typeof m.outputTokenLimit === "number" - ? { outputTokenLimit: m.outputTokenLimit } - : {}), - ...(typeof m.description === "string" ? { description: m.description } : {}), - ...(m.thinking === true ? { supportsThinking: true } : {}), - }; - }); - }, + parseResponse: (data) => parseGeminiModelsList(data), }, // gemini-cli handled via retrieveUserQuota (see GET handler) huggingface: { @@ -2084,6 +2042,130 @@ export async function GET( }); } + if (provider === "vertex" || provider === "vertex-partner") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + + // Vertex AI lists models from the Generative Language `v1beta/models` endpoint, which both + // Express-mode API keys (via ?key=) and Service Account JSON (via a minted OAuth Bearer + // token) can reach. This surfaces the full live catalog — including image models + // (imagen-*, gemini-*-image) absent from the static registry list. + const credential = (apiKey || "").trim(); + let queryKey: string | null = null; + let bearerToken: string | null = null; + try { + const { parseSAFromApiKey, getAccessToken } = await import( + "@omniroute/open-sse/executors/vertex.ts" + ); + if (accessToken) { + bearerToken = accessToken; + } else if (credential) { + // A Service Account credential is a JSON object; a Vertex AI Express-mode API key is an + // opaque (non-JSON) string. Detect locally so this branch has no dependency on optional + // executor helpers. + let isServiceAccountJson = false; + try { + const parsed = JSON.parse(credential); + isServiceAccountJson = + !!parsed && typeof parsed === "object" && !Array.isArray(parsed); + } catch { + isServiceAccountJson = false; + } + + if (isServiceAccountJson) { + bearerToken = await getAccessToken(parseSAFromApiKey(credential)); + } else { + queryKey = credential; + } + } + } catch (error) { + // Couldn't resolve a usable credential (e.g. malformed Service Account JSON). + const fallback = buildDiscoveryErrorFallbackResponse(error, { + cacheWarning: "Vertex credential unavailable — using cached catalog", + localWarning: "Vertex credential unavailable — using local catalog", + }); + if (fallback) return fallback; + } + + if (!queryKey && !bearerToken) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "No usable Vertex credential — using cached catalog", + localWarning: "No usable Vertex credential — using local catalog", + }); + if (fallback) return fallback; + return NextResponse.json( + { error: "No usable Vertex AI credential configured for model discovery." }, + { status: 400 } + ); + } + + const baseUrl = "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000"; + const headers: Record = { "Content-Type": "application/json" }; + if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`; + + const allModels: GeminiDiscoveryModel[] = []; + let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl; + let pageCount = 0; + const MAX_PAGES = 20; + const seenTokens = new Set(); + + try { + while (pageUrl && pageCount < MAX_PAGES) { + pageCount++; + const response = await safeOutboundFetch(pageUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers, + }); + + if (!response.ok) { + // Avoid logging the raw upstream body (may contain sensitive data); status is enough. + console.log("[models] Vertex model discovery failed", { + provider, + status: response.status, + }); + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; + return NextResponse.json( + { error: `Failed to fetch Vertex models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + allModels.push(...parseGeminiModelsList(data)); + + const nextPageToken = data.nextPageToken; + if (!nextPageToken || seenTokens.has(nextPageToken)) break; + seenTokens.add(nextPageToken); + pageUrl = `${baseUrl}&pageToken=${encodeURIComponent(nextPageToken)}`; + if (queryKey) pageUrl += `&key=${encodeURIComponent(queryKey)}`; + } + } catch (error) { + const fallback = buildDiscoveryErrorFallbackResponse(error); + if (fallback) return fallback; + throw error; + } + + if (allModels.length > 0) { + return buildApiDiscoveryResponse(allModels); + } + + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: [], + source: "api", + }); + } + if (isAnthropicCompatibleProvider(provider)) { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/app/api/settings/combo-defaults/route.ts b/src/app/api/settings/combo-defaults/route.ts index 79a2812fe5..9e6ca358e4 100644 --- a/src/app/api/settings/combo-defaults/route.ts +++ b/src/app/api/settings/combo-defaults/route.ts @@ -55,6 +55,7 @@ export async function GET(request: Request) { maxMessagesForSummary: 30, maxComboDepth: 3, trackMetrics: true, + reasoningTokenBufferEnabled: true, zeroLatencyOptimizationsEnabled: false, }, providerOverrides, diff --git a/src/app/api/settings/proxies/egress/route.ts b/src/app/api/settings/proxies/egress/route.ts new file mode 100644 index 0000000000..b43fcd6e6f --- /dev/null +++ b/src/app/api/settings/proxies/egress/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { diagnoseAllEgressIps, validateProxyPool } from "@/lib/proxyEgress"; + +/** + * GET /api/settings/proxies/egress — diagnose the egress IP of every OAuth + * connection: by which IP each account is entering (clientIp) and leaving + * (egressIp), plus warnings for same-rotation-group accounts sharing one + * egress IP (the codex anomaly-revocation trigger). + * + * POST /api/settings/proxies/egress — validate the whole proxy pool by probing + * each proxy's real egress IP and persisting status=active/error, so dead + * proxies are taken out of rotation automatically. + */ +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const diagnostic = await diagnoseAllEgressIps(); + return NextResponse.json(diagnostic); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to diagnose egress IPs"); + } +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const report = await validateProxyPool(); + const dead = report.filter((r) => !r.alive); + return NextResponse.json({ + validated: report.length, + alive: report.length - dead.length, + dead: dead.length, + report, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to validate proxy pool"); + } +} diff --git a/src/app/api/settings/proxies/route.ts b/src/app/api/settings/proxies/route.ts index 9d923cf3c2..4bb156f9b6 100644 --- a/src/app/api/settings/proxies/route.ts +++ b/src/app/api/settings/proxies/route.ts @@ -42,7 +42,10 @@ export async function GET(request: Request) { return Response.json({ items: proxies, total: proxies.length, - socks5Enabled: process.env.ENABLE_SOCKS5_PROXY === "true", + // Default ON (opt-out): only an explicit falsey value disables SOCKS5. + socks5Enabled: !["false", "0", "no", "off"].includes( + (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase() + ), }); } catch (error) { return createErrorResponseFromUnknown(error, "Failed to load proxies"); diff --git a/src/app/api/settings/proxy/route.ts b/src/app/api/settings/proxy/route.ts index 1c59ee6353..2a878316b8 100755 --- a/src/app/api/settings/proxy/route.ts +++ b/src/app/api/settings/proxy/route.ts @@ -31,7 +31,9 @@ const PROXY_LEVEL_TO_REGISTRY_SCOPE = { } as const; function isSocks5Enabled() { - return process.env.ENABLE_SOCKS5_PROXY === "true"; + // Default ON (opt-out): only an explicit falsey value disables SOCKS5. + const raw = (process.env.ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase(); + return !["false", "0", "no", "off"].includes(raw); } function getSupportedProxyTypes() { @@ -106,7 +108,7 @@ function normalizeAndValidateProxy( const type = String(proxy.type || "http").toLowerCase() as NonNullable; if (type === "socks5" && !isSocks5Enabled()) { throw createInvalidProxyError( - "SOCKS5 proxy is disabled (set ENABLE_SOCKS5_PROXY=true to enable)" + "SOCKS5 proxy is disabled (remove ENABLE_SOCKS5_PROXY=false to enable — it is ON by default)" ); } if (type.startsWith("socks") && type !== "socks5") { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index df38c1a8ae..811da78b65 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -4,6 +4,7 @@ import { getRuntimePorts } from "@/lib/runtime/ports"; import { updateSettingsSchema } from "@/shared/validation/settingsSchemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; import { validateProxyUrl, upsertUpstreamProxyConfig, @@ -220,6 +221,14 @@ export async function PATCH(request: Request) { } const body: typeof validation.data & { password?: string } = { ...validation.data }; + // Sanitize model lockout settings: clamp values to valid bounds so that + // stale DB values or hand-crafted requests don't bypass range validation. + if (body.modelLockout) { + body.modelLockout = resolveModelLockoutSettings({ + modelLockout: body.modelLockout as Record, + }) as typeof body.modelLockout; + } + // Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the // VALIDATED body so we never trip on stray unknown keys. If any security // key is present, require currentPassword + verify against the stored diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 1846478e41..bc532d20e3 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -3,8 +3,10 @@ import { v1CountTokensSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { estimateTokens } from "@/shared/utils/costEstimator"; import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; +import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { getModelInfo } from "@/sse/services/model"; import { extractApiKey, getProviderCredentials, isValidApiKey } from "@/sse/services/auth"; +import { safeResolveProxy } from "@/sse/handlers/chatHelpers"; import * as log from "@/sse/utils/logger"; /** @@ -61,12 +63,17 @@ export async function POST(request) { } const executor = getExecutor(modelInfo.provider); - const counted = await executor?.countTokens?.({ - model: modelInfo.model, - body, - credentials, - log, - }); + // The provider-side count is a real upstream call — it must honor the + // connection's proxy assignment exactly like chat execution does. + const proxyInfo = await safeResolveProxy(credentials.connectionId); + const counted = await runWithProxyContext(proxyInfo?.proxy || null, () => + executor?.countTokens?.({ + model: modelInfo.model, + body, + credentials, + log, + }) + ); if (!counted || !Number.isFinite(counted.input_tokens)) { return estimated; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 565c97cc34..2de9742fc4 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -983,6 +983,7 @@ "settingsAuthz": "Authz", "settingsRouting": "Routing", "settingsResilience": "Resilience", + "modelLockout": "Model Lockout", "settingsAdvanced": "Advanced", "omniProxySection": "OmniProxy", "quotaTracker": "Provider Quota", @@ -1084,7 +1085,15 @@ "cliAgents": "CLI Agents", "cliAgentsSubtitle": "Autonomous CLI agents", "acpAgents": "ACP Agents", - "acpAgentsSubtitle": "CLIs spawned by OmniRoute" + "acpAgentsSubtitle": "CLIs spawned by OmniRoute", + "skipToContent": "Skip to content", + "unpinSection": "Unpin section", + "pinSectionOpen": "Pin section open", + "reloadPage": "Reload Page", + "dragReorderSection": "Drag to reorder section", + "dragReorderItem": "Drag to reorder", + "cannotHide": "This item cannot be hidden", + "alwaysVisible": "Always visible" }, "webhooks": { "title": "Webhooks", @@ -1726,6 +1735,7 @@ "keyOnlyAvailableAtCreation": "Full key available only at creation time — copy it when you first create the key", "modelsCount": "{count, plural, one {# model} other {# models}}", "lastUsedOn": "Last: {date}", + "viewCostsFor": "View costs for {name}", "editPermissions": "Edit permissions", "deleteKey": "Delete key", "regenerateKey": "Regenerate key", @@ -4006,7 +4016,6 @@ "noConnectionsToTest": "No matching connections to test", "batchDeleteConfirmTitle": "Delete connections", "batchDeleteConfirmButton": "Delete", - "filterAll": "All", "filterActive": "Active", "filterError": "Error", "filterBanned": "Banned", @@ -5871,7 +5880,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", @@ -6614,6 +6637,7 @@ "budgetColDailyLim": "Daily lim", "budgetColMonthlyLim": "Monthly lim", "budgetColUsedPct": "Used %", + "percentUsed": "{pct}% used", "budgetLoading": "Loading…", "budgetNoKeysMatch": "No keys match filters", "budgetLinearExtrapolation": "linear extrapolation", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 95e18c959b..b94093e176 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -100,7 +100,7 @@ "resolve": "解析", "force": "强制", "base64url": "Base64 URL", - "hex": "Hex", + "hex": "十六进制", "range": "范围", "component": "组件", "redirect_uri": "重定向 URI", @@ -125,7 +125,7 @@ "keytar": "keytar", "better-sqlite3": "better-sqlite3", "undici": "undici", - "builder-id": "Builder ID", + "builder-id": "构建器 ID", "musicDesc": "音乐描述", "musicGeneration": "音乐生成", "idc": "IDC", @@ -143,7 +143,7 @@ "goToDashboard": "前往仪表板", "checkSystemStatus": "查看系统状态", "selectModel": "选择模型", - "combos": "Combos", + "combos": "组合", "noModelsFound": "未找到 Model", "clear": "清除", "done": "完成", @@ -164,7 +164,7 @@ "minutesAgo": "分钟前", "a": "A", "liveAutoRefreshing": "实时自动刷新中", - "webSearch": "Web Search", + "webSearch": "网页搜索", "anthropicPrefixPlaceholder": "Anthropic 前缀", "addModelToCombo": "添加模型到组合", "failedToLoad": "加载失败", @@ -172,7 +172,7 @@ "enableCloud": "启用云端", "expirationBannerExpiringSoon": "过期横幅即将过期即将", "retry": "重试", - "embeddings": "Embeddings", + "embeddings": "嵌入", "errorCount": "错误数量", "backupReasonManual": "备份原因手动", "safeSearchModerate": "安全搜索中等", @@ -331,12 +331,12 @@ "enableCombo": "启用组合", "removeProviderOverrideAria": "移除提供商覆盖", "nodeVersion": "Node 版本", - "openai": "Openai", + "openai": "OpenAI", "exportFailedWithError": "导出失败:{error}", "proxyConfigured": "代理已配置", "concurrencyPerModel": "每个模型并发数", "protocolTasksLabel": "协议任务标签", - "oauthProviders": "Oauth Providers", + "oauthProviders": "OAuth 提供商", "lockedCount": "已锁定数量", "deleteChainConfirm": "删除此后备链?", "recentTranslations": "最近翻译", @@ -430,7 +430,7 @@ "globalProxyDesc": "全局代理说明", "latencyP99": "延迟P99", "connectingToCloud": "正在连接云端", - "responses": "Responses", + "responses": "响应", "errorDeleting": "错误删除", "openaiPrefixPlaceholder": "OpenAI 前缀", "enterPassword": "输入密码", @@ -456,13 +456,13 @@ "latencyP95": "延迟P95", "textToSpeech": "文本转语音", "searchType": "搜索类型", - "messages": "Messages", + "messages": "消息", "aggregatorsGateways": "聚合器与网关", "comboStrategyAria": "Combo 策略", "input": "输入", "testingConnection": "正在测试连接", "excludeDomains": "排除域名", - "webCookieProviders": "Web Cookie Providers", + "webCookieProviders": "Web Cookie 提供商", "allTestsPassed": "全部测试通过", "openaiCompatibleName": "OpenAI 兼容名称", "warningCostOptimizedPartialPricing": "部分模型缺少定价,成本优化结果可能不完整。", @@ -489,7 +489,7 @@ "monitoredProviders": "监控中的 Provider", "errors": "错误", "heap": "堆内存", - "chatCompletions": "Chat Completions", + "chatCompletions": "聊天补全", "exampleTemplatesHint": "示例模板提示", "retryDelayLabel": "重试延迟标签", "audioTranscription": "音频转写", @@ -641,11 +641,11 @@ "safeSearchOff": "Safe Search 关闭", "nameHint": "名称提示", "debugToggle": "调试开关", - "embedding": "Embedding", + "embedding": "嵌入", "issuesLabel": "问题标签", "optionAny": "任意选项", "maxNestingDepth": "最大嵌套深度", - "rerank": "Rerank", + "rerank": "重新排序", "checking": "正在检查", "getStarted": "开始使用", "restoreSuccess": "恢复成功", @@ -710,137 +710,137 @@ "batchPageLoadingMore": "加载更多...", "recommended": "推荐", "understand": "我明白", - "batchConceptTitle": "Batch processing", - "batchConceptSubtitle": "Process thousands of requests asynchronously at ~50% of the cost (24h window). Ideal for evaluations, classification, and embeddings.", - "batchConceptHowItWorks": "How it works", - "batchConceptBenefit50pct": "50% discount on input + output tokens", - "batchConceptAsync24h": "Async with a 24h completion window", - "batchConceptUseCases": "Best for bulk classification, evaluations, and embeddings", - "filesConceptTitle": "Batch files", - "filesConceptSubtitle": "JSONL files used by batches: input requests, results, and errors.", - "filesConceptInput": "Input — your JSONL with one request per line", - "filesConceptOutput": "Output — results from completed requests", - "filesConceptError": "Errors — requests that failed", - "filesConceptRetention": "Retention: 30 days by default (29 days on Anthropic)", - "wizardTitle": "New batch", - "wizardClose": "Close", - "wizardNext": "Next", - "wizardBack": "Back", - "wizardCancel": "Cancel", - "wizardCreate": "Create batch", - "wizardCreating": "Creating…", - "wizardStep1Destination": "Destination", - "wizardStep2Input": "Input", - "wizardStep3Validate": "Validate", - "wizardStep4Cost": "Cost & create", - "wizardProviderLabel": "Provider", - "wizardEndpointLabel": "Endpoint", - "wizardModelLabel": "Model", + "batchConceptTitle": "批处理", + "batchConceptSubtitle": "以约 50% 的成本异步处理数千个请求(24 小时间隔)。非常适合评估、批量分类和嵌入。", + "batchConceptHowItWorks": "工作原理", + "batchConceptBenefit50pct": "输入 + 输出令牌享受 50% 折扣", + "batchConceptAsync24h": "异步处理,24 小时完成窗口", + "batchConceptUseCases": "最适合批量分类、评估和嵌入", + "filesConceptTitle": "批处理文件", + "filesConceptSubtitle": "批处理使用的 JSONL 文件:输入请求、结果和错误。", + "filesConceptInput": "输入 — 每行一个请求的 JSONL", + "filesConceptOutput": "输出 — 已完成请求的结果", + "filesConceptError": "错误 — 失败的请求", + "filesConceptRetention": "保留期:默认为 30 天(Anthropic 为 29 天)", + "wizardTitle": "新建批处理", + "wizardClose": "关闭", + "wizardNext": "下一步", + "wizardBack": "上一步", + "wizardCancel": "取消", + "wizardCreate": "创建批处理", + "wizardCreating": "创建中…", + "wizardStep1Destination": "目标", + "wizardStep2Input": "输入", + "wizardStep3Validate": "验证", + "wizardStep4Cost": "成本与创建", + "wizardProviderLabel": "提供商", + "wizardEndpointLabel": "端点", + "wizardModelLabel": "模型", "wizardInputKindJsonl": "JSONL", - "wizardInputKindCsv": "CSV (we'll convert)", - "wizardDropOrPick": "Drop a file or click to pick", - "wizardCsvMappingTitle": "Map CSV columns → request fields", - "wizardCsvMappingAddField": "Add field", - "wizardValidationOk": "All lines valid", - "wizardValidationErrors": "Validation errors", - "wizardValidationPreview": "Preview (first 5 requests)", - "wizardValidationSamplingNote": "File is large — validated by sampling (first 1000 + last 100 lines). Full validation runs server-side.", - "wizardCostSync": "Sync cost", - "wizardCostBatch": "Batch cost (-50%)", - "wizardCostSavings": "Savings", - "wizardCostEstimatedNotice": "Estimated cost — real billing may vary.", - "wizardErrorUpload": "Failed to upload file. Try again.", - "wizardErrorCreate": "Failed to create batch. Try again.", - "wizardEmptyProviders": "Connect a provider with batch support (OpenAI, Anthropic, or Gemini) to create a batch.", - "uploadModalTitle": "Upload batch file", - "uploadModalDropOrPick": "Drop a .jsonl file or click to pick", - "uploadModalUpload": "Upload", - "uploadModalCancel": "Cancel", - "uploadModalError": "Upload failed. Try again.", - "uploadModalSuccess": "Uploaded", - "uploadModalSizeLimit": "Max 512 MB", - "batchListNewButton": "New batch", - "batchListAutoRefresh": "Auto-refresh 30s", - "batchListCostColumn": "Cost", - "batchActionCancel": "Cancel", - "batchActionDownloadOutput": "Download output", - "batchActionDownloadErrors": "Download errors", - "batchActionRetry": "Retry failed", - "batchActionRetryConfirm": "Retry {n} failed request(s)? Final cost depends on actual tokens consumed.", - "expirationBadgeCritical": "Critical", - "expirationBadgeWarning": "Soon", - "expirationBadgeNormal": "Pending", - "expirationBadgeExpired": "Expired", - "filesListUploadButton": "Upload", - "filesListUsedByColumn": "Used by", - "filesListUsedByNone": "None", - "filesListUsedByRoleInput": "input", - "filesListUsedByRoleOutput": "output", - "filesListUsedByRoleError": "error", - "filesListSizeColumn": "Size", - "batchListProgressPartial": "(partial)", - "batchListProviderColumn": "Provider", + "wizardInputKindCsv": "CSV(我们将转换)", + "wizardDropOrPick": "拖放文件或点击选择", + "wizardCsvMappingTitle": "将 CSV 列映射到请求字段", + "wizardCsvMappingAddField": "添加字段", + "wizardValidationOk": "所有行有效", + "wizardValidationErrors": "验证错误", + "wizardValidationPreview": "预览(前 5 个请求)", + "wizardValidationSamplingNote": "文件较大 — 通过抽样验证(前 1000 行 + 后 100 行)。完整验证在服务器端运行。", + "wizardCostSync": "同步成本", + "wizardCostBatch": "批处理成本(-50%)", + "wizardCostSavings": "节省", + "wizardCostEstimatedNotice": "估算成本 — 实际计费可能有所不同。", + "wizardErrorUpload": "上传文件失败。请重试。", + "wizardErrorCreate": "创建批处理失败。请重试。", + "wizardEmptyProviders": "先连接支持批处理的提供商(OpenAI、Anthropic 或 Gemini)以创建批处理。", + "uploadModalTitle": "上传批处理文件", + "uploadModalDropOrPick": "拖放 .jsonl 文件或点击选择", + "uploadModalUpload": "上传", + "uploadModalCancel": "取消", + "uploadModalError": "上传失败。请重试。", + "uploadModalSuccess": "已上传", + "uploadModalSizeLimit": "最大 512 MB", + "batchListNewButton": "新建批处理", + "batchListAutoRefresh": "自动刷新 30 秒", + "batchListCostColumn": "成本", + "batchActionCancel": "取消", + "batchActionDownloadOutput": "下载输出", + "batchActionDownloadErrors": "下载错误", + "batchActionRetry": "重试失败的", + "batchActionRetryConfirm": "重试 {n} 个失败请求?最终成本取决于实际消耗的令牌。", + "expirationBadgeCritical": "紧急", + "expirationBadgeWarning": "即将", + "expirationBadgeNormal": "待处理", + "expirationBadgeExpired": "已过期", + "filesListUploadButton": "上传", + "filesListUsedByColumn": "被使用", + "filesListUsedByNone": "无", + "filesListUsedByRoleInput": "输入", + "filesListUsedByRoleOutput": "输出", + "filesListUsedByRoleError": "错误", + "filesListSizeColumn": "大小", + "batchListProgressPartial": "(部分)", + "batchListProviderColumn": "提供商", "batchListProviderUnknown": "—", - "batchListProviderOther": "Other", - "batchListTitle": "Batches", - "batchListCount": "{count} batches", - "batchListRemovingCompleted": "Removing…", - "batchListRemoveCompleted": "Remove completed", - "batchListTableStatus": "Status", + "batchListProviderOther": "其他", + "batchListTitle": "批处理", + "batchListCount": "{count} 个批处理", + "batchListRemovingCompleted": "移除中…", + "batchListRemoveCompleted": "移除已完成", + "batchListTableStatus": "状态", "batchListTableId": "ID", - "batchListTableEndpoint": "Endpoint", - "batchListTableModel": "Model", - "batchListTableProgress": "Progress", - "batchListTableCreated": "Created", - "batchListTableExpires": "Expires", - "batchListLoading": "Loading…", - "batchListEmpty": "No batches found", - "batchListValidating": "Validating…", - "batchStatusAll": "All statuses", - "batchStatusInProgress": "In progress", - "batchStatusValidating": "Validating", - "batchStatusFinalizing": "Finalizing", - "batchStatusCompleted": "Completed", - "batchStatusFailed": "Failed", - "batchStatusCancelled": "Cancelled", - "batchStatusCancelling": "Cancelling", - "batchStatusExpired": "Expired", - "batchStatusCompletedWithFailures": "completed with failures", - "batchStatusInProgressWithFailures": "in progress (with failures)", - "batchStatusFinalizingWithFailures": "finalizing (with failures)", - "batchStatusCancelledWithFailures": "cancelled with failures", - "batchStatusExpiredWithFailures": "expired (partial)", - "wizardCostEstimating": "Estimating cost…", - "wizardCostRequests": "Requests", - "wizardCostInputTok": "input tok", - "wizardCostOutputTok": "output tok", - "wizardCostWindow": "Window", - "wizardDestinationSelectProvider": "Select a provider…", - "wizardDestinationSelectModel": "Select a model…", - "wizardDestinationConnectProvider": "Connect a provider", - "batchListBatchCreated": "Batch {id} created — refreshing list…", - "batchListBatchCreatedDismiss": "Dismiss", - "batchListRefreshing": "Refreshing…", - "batchListRefresh": "Refresh", - "uploadFileModalRemove": "Remove", - "uploadFileModalUploading": "Uploading…", - "wizardInputReading": "Reading file…", - "wizardInputReady": "Ready", - "wizardInputLargeFileLabel": "Large file — sampling validation", - "wizardInputCsvJsonlReady": "JSONL generated — ready to validate.", - "wizardInputLargeFileWarning": "Large file detected — validation runs by sampling (first 5 MB + last 100 KB). Full validation happens server-side.", - "wizardCostWindow24h": "24h completion window", - "wizardValidationFieldsOk": "required fields valid", - "filesListDelete": "Delete", - "filesListDownload": "Download", - "batchDetailActionCancel": "Cancel batch", - "batchDetailActionRetry": "Retry failed requests", - "batchDetailCancelling": "Cancelling…", - "batchDetailRetrying": "Preparing retry…", - "batchDetailCancelConfirm": "Cancel this batch? In-progress requests will stop.", - "batchActionCancelError": "Failed to cancel batch. Try again.", - "batchActionRetryError": "Failed to retry failed requests. Try again.", - "batchConceptRetentionNote": "Results and error files are retained for 30 days (Anthropic: 29 days)" + "batchListTableEndpoint": "端点", + "batchListTableModel": "模型", + "batchListTableProgress": "进度", + "batchListTableCreated": "创建时间", + "batchListTableExpires": "过期时间", + "batchListLoading": "加载中…", + "batchListEmpty": "未找到批处理", + "batchListValidating": "验证中…", + "batchStatusAll": "所有状态", + "batchStatusInProgress": "进行中", + "batchStatusValidating": "验证中", + "batchStatusFinalizing": "最终处理中", + "batchStatusCompleted": "已完成", + "batchStatusFailed": "已失败", + "batchStatusCancelled": "已取消", + "batchStatusCancelling": "取消中", + "batchStatusExpired": "已过期", + "batchStatusCompletedWithFailures": "已完成(有失败)", + "batchStatusInProgressWithFailures": "进行中(有失败)", + "batchStatusFinalizingWithFailures": "最终处理中(有失败)", + "batchStatusCancelledWithFailures": "已取消(有失败)", + "batchStatusExpiredWithFailures": "已过期(部分)", + "wizardCostEstimating": "估算成本…", + "wizardCostRequests": "请求数", + "wizardCostInputTok": "输入 tok", + "wizardCostOutputTok": "输出 tok", + "wizardCostWindow": "窗口", + "wizardDestinationSelectProvider": "选择提供商…", + "wizardDestinationSelectModel": "选择模型…", + "wizardDestinationConnectProvider": "连接提供商", + "batchListBatchCreated": "批处理 {id} 已创建 — 刷新列表中…", + "batchListBatchCreatedDismiss": "关闭", + "batchListRefreshing": "刷新中…", + "batchListRefresh": "刷新", + "uploadFileModalRemove": "移除", + "uploadFileModalUploading": "上传中…", + "wizardInputReading": "读取文件…", + "wizardInputReady": "就绪", + "wizardInputLargeFileLabel": "大文件 — 抽样验证", + "wizardInputCsvJsonlReady": "JSONL 已生成 — 可以验证。", + "wizardInputLargeFileWarning": "检测到大文件 — 通过抽样验证(前 5 MB + 后 100 KB)。完整验证在服务器端进行。", + "wizardCostWindow24h": "24 小时完成窗口", + "wizardValidationFieldsOk": "必填字段有效", + "filesListDelete": "删除", + "filesListDownload": "下载", + "batchDetailActionCancel": "取消批处理", + "batchDetailActionRetry": "重试失败请求", + "batchDetailCancelling": "取消中…", + "batchDetailRetrying": "准备重试…", + "batchDetailCancelConfirm": "取消此批处理?进行中的请求将停止。", + "batchActionCancelError": "取消批处理失败。请重试。", + "batchActionRetryError": "重试失败请求失败。请重试。", + "batchConceptRetentionNote": "结果和错误文件保留 30 天(Anthropic:29 天)" }, "sidebar": { "home": "首页", @@ -946,123 +946,123 @@ "contextCaveman": "Caveman", "contextRtk": "RTK", "contextCombos": "引擎组合", - "routingSection": "Routing", - "protocolsSection": "Protocols", - "agentsAiSection": "Agents & AI", - "cacheContextSection": "Cache & Context", - "analyticsSection": "Analytics", - "costsSection": "Costs", - "monitoringSection": "Monitoring", - "auditSecuritySection": "Audit & Security", - "devtoolsSection": "Dev Tools", - "configurationSection": "Configuration", - "aiFeaturesSection": "AI Features", + "routingSection": "路由", + "protocolsSection": "协议", + "agentsAiSection": "代理与 AI", + "cacheContextSection": "缓存与上下文", + "analyticsSection": "分析", + "costsSection": "成本", + "monitoringSection": "监控", + "auditSecuritySection": "审计与安全", + "devtoolsSection": "开发工具", + "configurationSection": "配置", + "aiFeaturesSection": "AI 功能", "mcp": "MCP", "a2a": "A2A", - "apiEndpoints": "API Endpoints", - "batchFiles": "Files", - "analyticsEvals": "Evals", - "analyticsSearch": "Search", - "analyticsUtilization": "Utilization", - "analyticsComboHealth": "Combo Health", - "analyticsCompression": "Compression", - "costsBudget": "Budget", - "costsQuotaShare": "Quota Sharing", - "costsPricing": "Pricing", - "logsProxy": "Proxy Logs", - "logsConsole": "Console", - "logsActivity": "Activity", - "auditMcp": "MCP Audit", + "apiEndpoints": "API 端点", + "batchFiles": "文件", + "analyticsEvals": "评估", + "analyticsSearch": "搜索", + "analyticsUtilization": "利用率", + "analyticsComboHealth": "组合健康", + "analyticsCompression": "压缩", + "costsBudget": "预算", + "costsQuotaShare": "配额共享", + "costsPricing": "定价", + "logsProxy": "代理日志", + "logsConsole": "控制台", + "logsActivity": "活动", + "auditMcp": "MCP 审计", "auditA2a": "A2A审核", - "settingsGeneral": "General", - "settingsAppearance": "Appearance", - "settingsAi": "AI Settings", - "settingsSecurity": "Security", + "settingsGeneral": "通用", + "settingsAppearance": "外观", + "settingsAi": "AI 设置", + "settingsSecurity": "安全", "settingsFeatureFlags": "功能标志", "settingsAuthz": "授权", - "settingsRouting": "Routing", - "settingsResilience": "Resilience", - "settingsAdvanced": "Advanced", + "settingsRouting": "路由", + "settingsResilience": "弹性", + "settingsAdvanced": "高级", "omniProxySection": "OmniProxy", - "quotaTracker": "Provider Quota", - "providerQuota": "Provider Quota", - "runtime": "Runtime", - "consoleLogs": "Console Logs", - "globalRouting": "Global Routing", - "mitmProxy": "MITM Proxy", + "quotaTracker": "提供商配额", + "providerQuota": "提供商配额", + "runtime": "运行时", + "consoleLogs": "控制台日志", + "globalRouting": "全局路由", + "mitmProxy": "MITM 代理", "oneProxy": "1Proxy", - "agenticFeaturesSection": "Agentic Features", - "otherFeaturesSection": "Other Features", - "compressionContextGroup": "Compression Context", - "toolsGroup": "Tools", - "integrationsGroup": "Integrations", - "proxyGroup": "Proxy", - "costsParametersGroup": "Costs Parameters", - "auditGroup": "Audit", - "batchGroup": "Batch", - "homeSubtitle": "Dashboard overview", - "providersSubtitle": "Manage AI providers", - "quotaTrackerSubtitle": "Track usage limits", - "providerQuotaSubtitle": "Track provider usage limits", - "runtimeSubtitle": "Realtime resilience & sessions", - "contextCombosSubtitle": "Combine compression engines", - "cliToolsSubtitle": "Configure CLI runtimes", - "agentsSubtitle": "Manage local agents", - "cloudAgentsSubtitle": "Manage cloud-based agents", - "apiEndpointsSubtitle": "Expose custom endpoints", - "proxySubtitle": "HTTP proxy settings", - "mitmProxySubtitle": "MITM interception", - "oneProxySubtitle": "Public proxy gateway", - "leaderboard": "Leaderboard", - "profile": "Profile", - "tokens": "Tokens", - "leaderboardSubtitle": "Rankings and achievements", - "profileSubtitle": "Account and preferences", - "tokensSubtitle": "Token usage and budgets", - "usageSubtitle": "Traffic and usage stats", - "analyticsComboHealthSubtitle": "Combo target reliability", - "analyticsUtilizationSubtitle": "Provider utilization", - "costsSubtitle": "Spend breakdown", - "cacheSubtitle": "Cache hit rates", - "analyticsCompressionSubtitle": "Token savings stats", - "analyticsSearchSubtitle": "Search tool analytics", - "analyticsEvalsSubtitle": "Eval suite results", - "logsSubtitle": "Application logs", - "logsProxySubtitle": "Proxy traffic logs", - "consoleLogsSubtitle": "Console output", - "logsActivitySubtitle": "User activity log", - "healthSubtitle": "System health check", - "costsPricingSubtitle": "Per-model pricing rules", - "costsBudgetSubtitle": "Budget limits", - "costsQuotaShareSubtitle": "Share provider quotas across keys", - "auditLogSubtitle": "Authorization audit", - "auditMcpSubtitle": "MCP server audit", - "auditA2aSubtitle": "A2A protocol audit", - "translatorSubtitle": "Format conversion", - "playgroundSubtitle": "Test prompts live", - "searchToolsSubtitle": "Search tool registry", - "memorySubtitle": "Persistent agent memory", - "omniSkillsSubtitle": "Sandbox skill registry", - "agentSkillsSubtitle": "A2A skill registry", - "mcpSubtitle": "MCP server controls", - "a2aSubtitle": "A2A protocol server", - "mediaSubtitle": "Cached media files", - "batchFilesSubtitle": "Batch input/output files", - "settingsSubtitle": "All settings", - "settingsGeneralSubtitle": "App basics", - "settingsAppearanceSubtitle": "Theme and layout", - "settingsAiSubtitle": "AI behavior defaults", - "globalRoutingSubtitle": "Global routing rules", - "settingsResilienceSubtitle": "Retries and breakers", - "settingsAdvancedSubtitle": "Power user options", - "settingsSecuritySubtitle": "Auth and encryption", + "agenticFeaturesSection": "代理功能", + "otherFeaturesSection": "其他功能", + "compressionContextGroup": "压缩上下文", + "toolsGroup": "工具", + "integrationsGroup": "集成", + "proxyGroup": "代理", + "costsParametersGroup": "成本参数", + "auditGroup": "审计", + "batchGroup": "批处理", + "homeSubtitle": "仪表盘概览", + "providersSubtitle": "管理 AI 提供商", + "quotaTrackerSubtitle": "跟踪使用限制", + "providerQuotaSubtitle": "跟踪提供商使用限制", + "runtimeSubtitle": "实时弹性与会话", + "contextCombosSubtitle": "组合压缩引擎", + "cliToolsSubtitle": "配置 CLI 运行时", + "agentsSubtitle": "管理本地代理", + "cloudAgentsSubtitle": "管理基于云的代理", + "apiEndpointsSubtitle": "暴露自定义端点", + "proxySubtitle": "HTTP 代理设置", + "mitmProxySubtitle": "MITM 拦截", + "oneProxySubtitle": "公共代理网关", + "leaderboard": "排行榜", + "profile": "个人资料", + "tokens": "令牌", + "leaderboardSubtitle": "排名与成就", + "profileSubtitle": "账户与偏好", + "tokensSubtitle": "令牌使用和预算", + "usageSubtitle": "流量和使用统计", + "analyticsComboHealthSubtitle": "组合目标可靠性", + "analyticsUtilizationSubtitle": "提供商利用率", + "costsSubtitle": "支出明细", + "cacheSubtitle": "缓存命中率", + "analyticsCompressionSubtitle": "令牌节省统计", + "analyticsSearchSubtitle": "搜索工具分析", + "analyticsEvalsSubtitle": "评估套件结果", + "logsSubtitle": "应用日志", + "logsProxySubtitle": "代理流量日志", + "consoleLogsSubtitle": "控制台输出", + "logsActivitySubtitle": "用户活动日志", + "healthSubtitle": "系统健康检查", + "costsPricingSubtitle": "按模型定价规则", + "costsBudgetSubtitle": "预算限制", + "costsQuotaShareSubtitle": "跨密钥共享提供商配额", + "auditLogSubtitle": "授权审计", + "auditMcpSubtitle": "MCP 服务器审计", + "auditA2aSubtitle": "A2A 协议审计", + "translatorSubtitle": "格式转换", + "playgroundSubtitle": "实时测试提示", + "searchToolsSubtitle": "搜索工具注册表", + "memorySubtitle": "持久的代理记忆", + "omniSkillsSubtitle": "沙箱技能注册表", + "agentSkillsSubtitle": "A2A 技能注册表", + "mcpSubtitle": "MCP 服务器控制", + "a2aSubtitle": "A2A 协议服务器", + "mediaSubtitle": "缓存的媒体文件", + "batchFilesSubtitle": "批处理输入/输出文件", + "settingsSubtitle": "所有设置", + "settingsGeneralSubtitle": "应用基础", + "settingsAppearanceSubtitle": "主题与布局", + "settingsAiSubtitle": "AI 行为默认值", + "globalRoutingSubtitle": "全局路由规则", + "settingsResilienceSubtitle": "重试与断路器", + "settingsAdvancedSubtitle": "高级用户选项", + "settingsSecuritySubtitle": "认证与加密", "settingsFeatureFlagsSubtitle": "切换系统功能", "settingsSidebar": "侧边栏", "settingsSidebarSubtitle": "自定义侧边栏布局", "settingsAuthzSubtitle": "路由清单与绕过策略", - "docsSubtitle": "Documentation", - "issuesSubtitle": "Report a bug", - "changelogSubtitle": "Release notes", + "docsSubtitle": "文档", + "issuesSubtitle": "报告错误", + "changelogSubtitle": "发布说明", "costsQuotaPlans": "计划与配额", "costsQuotaPlansSubtitle": "按提供商配置计划", "activity": "活动", @@ -1080,7 +1080,19 @@ "cliAgents": "CLI 代理", "cliAgentsSubtitle": "自主 CLI 代理", "acpAgents": "ACP 代理", - "acpAgentsSubtitle": "由 OmniRoute 生成的 CLI" + "acpAgentsSubtitle": "由 OmniRoute 生成的 CLI", + "costsFreeTiers": "免费层预算", + "providerStats": "提供商统计", + "providerStatsSubtitle": "延迟和性能指标", + "costsFreeTiersSubtitle": "每月免费令牌配额", + "skipToContent": "跳到内容", + "unpinSection": "取消固定分区", + "pinSectionOpen": "固定分区打开", + "reloadPage": "重新加载页面", + "dragReorderSection": "拖拽以重新排序分区", + "dragReorderItem": "拖拽以重新排序", + "cannotHide": "此项无法隐藏", + "alwaysVisible": "始终可见" }, "webhooks": { "title": "Webhook", @@ -1250,7 +1262,7 @@ "viewDetails": "查看详情", "closeDetails": "关闭详情", "notAvailable": "—", - "system": "system", + "system": "系统", "previous": "上一页", "next": "下一页", "mcpAudit": "MCP 审计", @@ -1361,48 +1373,48 @@ "mediaDescription": "生成图像、视频和音乐", "themes": "主题", "themesDescription": "为整个仪表板选择颜色主题", - "costsDescription": "Track spending, analyze trends, and manage your AI budget across all providers", - "cacheDescription": "Monitor provider prompt cache efficiency and local semantic response reuse.", - "limitsDescription": "Configure rate limits and quotas per API key and provider", - "runtimeDescription": "Live runtime observability — circuit breakers, cooldowns, model lockouts, sessions and quota alerts", - "apiManagerDescription": "Manage API keys and access control for your OmniRoute instance", - "batchDescription": "Process large volumes of requests asynchronously with batched API calls", - "contextCavemanDescription": "Rule-based message compression, language packs, analytics and output mode controls.", - "contextRtkDescription": "Command-aware compression for tool output, terminal logs and build results.", - "contextCombosDescription": "Define how engines are combined for different routing scenarios.", - "changelogDescription": "Stay up to date with the latest platform features and announcements.", - "agentsDescription": "Manage and configure AI agent tools: Codex, Devin, Jules, and custom agents", - "cloudAgentsDescription": "Orchestrate cloud-based AI agents with live task tracking and plan approval", - "memoryDescription": "Persistent conversational memory with semantic search and FTS5 full-text indexing", - "skillsDescription": "Install and manage sandbox skills for automated prompt and tool execution", - "agentSkillsDescription": "Agent-ready skills catalog with one-click URL copy for AI client integration", - "translatorDescription": "Translate and test prompts across API formats: OpenAI ↔ Claude ↔ Gemini", - "playgroundDescription": "Test prompts interactively with live provider responses and format inspection", - "searchToolsDescription": "Search analytics, provider breakdown, cache hit rates, and cost tracking", - "logsDescription": "Real-time request logs, error traces, and streaming event inspector", - "auditDescription": "Compliance audit trail of API key usage, MCP tool calls, and policy events", - "webhooksDescription": "Configure webhook endpoints to receive real-time event notifications", - "healthDescription": "System health overview: providers, circuit breakers, rate limits, and database", - "proxyDescription": "Configure upstream proxy settings for outbound provider connections", - "apiEndpointsDescription": "Manage custom API endpoint configurations and routing overrides", - "batchFilesDescription": "Browse and manage batch job output files and results", - "analyticsEvalsDescription": "Model evaluation results and performance benchmarks", - "analyticsSearchDescription": "Search query analytics, cache hit rates, and cost tracking", - "analyticsUtilizationDescription": "Provider utilization metrics and capacity planning", - "analyticsComboHealthDescription": "Real-time health and performance of combo routing configurations", - "analyticsCompressionDescription": "Context compression analytics and token savings", - "costsBudgetDescription": "Budget limits and spending alerts per API key and provider", - "costsPricingDescription": "Custom pricing configuration for token cost calculations", - "logsProxyDescription": "Upstream proxy request logs and traffic inspection", - "logsConsoleDescription": "Application console output and debug logs", - "logsActivityDescription": "Audit trail of user actions and system events", - "auditMcpDescription": "MCP tool invocation audit trail and compliance records", + "costsDescription": "跟踪支出,分析趋势,管理所有 AI 提供商的预算", + "cacheDescription": "监控提供商提示缓存效率和本地语义响应复用。", + "limitsDescription": "为每个 API 密钥和提供商配置速率限制和配额", + "runtimeDescription": "实时运行时可观测性 — 断路器、冷却、模型锁定、会话和配额告警", + "apiManagerDescription": "管理 OmniRoute 实例的 API 密钥和访问控制", + "batchDescription": "通过批量 API 调用异步处理大量请求", + "contextCavemanDescription": "基于规则的消息压缩、语言包、分析和输出模式控制。", + "contextRtkDescription": "针对工具输出、终端日志和构建结果的命令感知压缩。", + "contextCombosDescription": "定义如何为不同路由场景组合引擎。", + "changelogDescription": "了解最新平台功能和公告。", + "agentsDescription": "管理和配置 AI 代理工具:Codex、Devin、Jules 和自定义代理", + "cloudAgentsDescription": "编排基于云的 AI 代理,支持实时任务跟踪和计划审批", + "memoryDescription": "持久的对话记忆,支持语义搜索和 FTS5 全文索引", + "skillsDescription": "安装和管理沙箱技能,实现自动提示和执行", + "agentSkillsDescription": "代理就绪技能目录,一键复制 URL 以集成 AI 客户端", + "translatorDescription": "跨 API 格式翻译和测试提示:OpenAI ↔ Claude ↔ Gemini", + "playgroundDescription": "交互式测试提示,实时查看提供商响应和格式检查", + "searchToolsDescription": "搜索分析、提供商细分、缓存命中率和成本跟踪", + "logsDescription": "实时请求日志、错误追踪和流式事件检查器", + "auditDescription": "API 密钥使用、MCP 工具调用和策略事件的合规审计追踪", + "webhooksDescription": "配置 Webhook 端点以接收实时事件通知", + "healthDescription": "系统健康概览:提供商、断路器、速率限制和数据库", + "proxyDescription": "为出站提供商连接配置上游代理设置", + "apiEndpointsDescription": "管理自定义 API 端点配置和路由覆盖", + "batchFilesDescription": "浏览和管理批处理作业输出文件和结果", + "analyticsEvalsDescription": "模型评估结果和性能基准", + "analyticsSearchDescription": "搜索查询分析、缓存命中率和成本跟踪", + "analyticsUtilizationDescription": "提供商利用率指标和容量规划", + "analyticsComboHealthDescription": "组合路由配置的实时健康与性能", + "analyticsCompressionDescription": "上下文压缩分析和令牌节省", + "costsBudgetDescription": "每个 API 密钥和提供商的预算限制和支出告警", + "costsPricingDescription": "用于令牌成本计算的自定义定价配置", + "logsProxyDescription": "上游代理请求日志和流量检查", + "logsConsoleDescription": "应用控制台输出和调试日志", + "logsActivityDescription": "用户操作和系统事件的审计追踪", + "auditMcpDescription": "MCP 工具调用审计追踪和合规记录", "auditA2a": "A2A审核", "auditA2aDescription": "A2A任务执行审计追踪、状态转换、技能调用记录", - "settingsGeneralDescription": "Storage, database, and general instance configuration", - "settingsAppearanceDescription": "Theme, branding, and visual customization", - "settingsAiDescription": "AI behaviors, thinking budgets, vision, and memory settings", - "settingsSecurityDescription": "Authentication, authorization, and access control settings", + "settingsGeneralDescription": "存储、数据库和通用实例配置", + "settingsAppearanceDescription": "主题、品牌和视觉自定义", + "settingsAiDescription": "AI 行为、思维预算、视觉和记忆设置", + "settingsSecurityDescription": "认证、授权和访问控制设置", "featureFlags": "功能标志", "featureFlagsDescription": "控制系统能力和实验特点", "featureFlagsActive": "{count} 活跃", @@ -1418,7 +1430,7 @@ "featureFlagsCategoryHealth": "健康", "featureFlagsSourceDb": "数据库", "featureFlagsSourceEnv": "环境电压", - "featureFlagsSourceDefault": "DEF", + "featureFlagsSourceDefault": "默认", "featureFlagsReset": "重置", "featureFlagsResetAll": "重置所有覆盖", "featureFlagsResetAllConfirm": "您确定要重置所有功能标志覆盖吗?这会将所有标志恢复为其 ENV 或默认值。", @@ -1426,11 +1438,11 @@ "featureFlagsNoResults": "没有符合您搜索条件的标志", "featureFlagsSaved": "标志已更新", "featureFlagsError": "更新标志失败", - "settingsRoutingDescription": "Routing rules, model aliases, combo defaults, and degradation settings", - "settingsResilienceDescription": "Circuit breaker, retry, and fallback configuration", - "settingsAdvancedDescription": "Advanced payload rules, request limits, and proxy API settings", - "mitmProxyDescription": "Configure MITM proxy settings for traffic inspection and debugging", - "oneProxyDescription": "Configure 1Proxy settings for advanced proxy chaining", + "settingsRoutingDescription": "路由规则、模型别名、组合默认值和降级设置", + "settingsResilienceDescription": "断路器、重试和回退配置", + "settingsAdvancedDescription": "高级负载规则、请求限制和代理 API 设置", + "mitmProxyDescription": "配置 MITM 代理设置以进行流量检查和调试", + "oneProxyDescription": "配置 1Proxy 设置以实现高级代理链", "omniSkillsDescription": "安装和管理用于自动提示和工具执行的沙箱技能" }, "home": { @@ -1722,6 +1734,7 @@ "keyOnlyAvailableAtCreation": "完整密钥仅会在创建时显示一次,请在首次创建时立即复制保存", "modelsCount": "{count, plural, one {# 个模型} other {# 个模型}}", "lastUsedOn": "最近使用:{date}", + "viewCostsFor": "查看 {name} 的费用", "editPermissions": "编辑权限", "deleteKey": "删除密钥", "regenerateKey": "重新生成密钥", @@ -1768,7 +1781,7 @@ "normalKeysSection": "普通键", "quotaKeysSection": "配额密钥", "quotaPill": "配额", - "quotaModeOnly": "qtSd-only" + "quotaModeOnly": "仅配额" }, "auditLog": { "title": "审核日志", @@ -1876,56 +1889,56 @@ "emptyState": "发送搜索请求后即可查看结果", "copy": "复制", "resetToDefault": "重置为默认值", - "tabSearch": "Search", - "tabScrape": "Scrape", - "tabCompare": "Compare", - "modalitiesGuide": "Modalities guide", - "searchConceptTitle": "Search", - "searchConceptDesc": "Search = fetches a list of web results (title, URL, snippet, relevance score)", - "scrapeConceptTitle": "Scrape", - "scrapeConceptDesc": "Scrape = extracts the full content of a URL (markdown, text or HTML)", - "compareConceptTitle": "Compare", - "compareConceptDesc": "Compare = runs the same query across N providers side-by-side to compare latency, cost and result overlap", - "rerankConceptTitle": "Rerank", - "rerankConceptDesc": "Rerank = reorders results via LLM to improve relevance based on the query", - "autoConceptTitle": "Auto (cheapest)", - "autoConceptDesc": "Auto (cheapest) = automatically picks the cheapest available provider with configured credentials", - "providerCatalogTitle": "Provider Catalog", - "kindSearch": "Search", - "kindFetch": "Fetch/Scrape", - "statusConfigured": "Configured", - "statusMissing": "No credential", - "statusRateLimited": "Rate limited", - "configureProvider": "Configure in Providers", - "loadingProviders": "Loading providers…", - "failedToLoadProviders": "Failed to load providers", - "costPerQuery": "Cost/query", - "freeQuota": "Free quota/mo", - "scrapeUrl": "URL to scrape", + "tabSearch": "搜索", + "tabScrape": "抓取", + "tabCompare": "比较", + "modalitiesGuide": "模态指南", + "searchConceptTitle": "搜索", + "searchConceptDesc": "搜索 = 获取网页结果列表(标题、URL、摘要、相关性分数)", + "scrapeConceptTitle": "抓取", + "scrapeConceptDesc": "抓取 = 提取 URL 的完整内容(Markdown、文本或 HTML)", + "compareConceptTitle": "比较", + "compareConceptDesc": "比较 = 跨 N 个提供商并排运行相同查询,比较延迟、成本和结果重叠", + "rerankConceptTitle": "重新排序", + "rerankConceptDesc": "重新排序 = 通过 LLM 重新排序结果以基于查询提高相关性", + "autoConceptTitle": "自动(最便宜)", + "autoConceptDesc": "自动(最便宜)= 自动选择具有已配置凭据的最便宜可用提供商", + "providerCatalogTitle": "提供商目录", + "kindSearch": "搜索", + "kindFetch": "获取/抓取", + "statusConfigured": "已配置", + "statusMissing": "无凭据", + "statusRateLimited": "速率受限", + "configureProvider": "在提供商中配置", + "loadingProviders": "加载提供商…", + "failedToLoadProviders": "加载提供商失败", + "costPerQuery": "成本/查询", + "freeQuota": "免费配额/月", + "scrapeUrl": "要抓取的 URL", "scrapeUrlPlaceholder": "https://example.com", - "scrapeExtract": "Extract", - "scrapeExtracting": "Extracting…", - "scrapeFullPage": "Full page", - "scrapeFormat": "Format", + "scrapeExtract": "提取", + "scrapeExtracting": "提取中…", + "scrapeFullPage": "整页", + "scrapeFormat": "格式", "formatMarkdown": "Markdown", "formatHtml": "HTML", - "formatText": "Text", - "scrapePreview": "Preview", - "scrapeRaw": "Raw", - "scrapeContentTruncated": "(truncated, view raw)", - "scrapeMetadata": "Metadata", - "scrapeProvider": "Provider", - "scrapeSize": "Size", - "compareRun": "Compare", - "compareRunning": "Comparing…", - "autoProvider": "Auto (cheapest)", - "configuredStatus": "Configured", - "rateLimitedStatus": "Rate limited", - "noCredential": "No credential", - "searchTypeLabel": "Type", - "rerankModelLabel": "Rerank model", - "noneOption": "None", - "size": "Size" + "formatText": "文本", + "scrapePreview": "预览", + "scrapeRaw": "原始", + "scrapeContentTruncated": "(已截断,查看原始)", + "scrapeMetadata": "元数据", + "scrapeProvider": "提供商", + "scrapeSize": "大小", + "compareRun": "比较", + "compareRunning": "比较中…", + "autoProvider": "自动(最便宜)", + "configuredStatus": "已配置", + "rateLimitedStatus": "速率受限", + "noCredential": "无凭据", + "searchTypeLabel": "类型", + "rerankModelLabel": "重新排序模型", + "noneOption": "无", + "size": "大小" }, "cliTools": { "title": "CLI 工具", @@ -2027,7 +2040,7 @@ "hide": "隐藏", "howToInstall": "如何安装", "installationGuide": "安装指南", - "platforms": "macOS / Linux / Windows:", + "platforms": "macOS / Linux / Windows:", "afterInstallationRun": "安装后,运行", "toVerify": "来验证。", "current": "当前", @@ -2868,7 +2881,7 @@ "modelsAcrossEndpoints": "{endpoints} 个端点共提供 {models} 个模型", "loadingModels": "正在加载可用模型...", "chatDesc": "支持所有提供商的流式与非流式聊天", - "embeddings": "Embeddings", + "embeddings": "嵌入", "embeddingsDesc": "用于搜索和 RAG 流程的文本向量", "imageGeneration": "图像生成", "imageDesc": "根据文本提示生成图像", @@ -3015,7 +3028,7 @@ "tailscaleUrlNotice": "使用你的 Tailscale .ts.net 地址。首次使用时可能需要登录并批准 Funnel。", "tailscaleTitle": "Tailscale Funnel", "tailscaleNeedsLoginHint": "先使用 Tailscale 认证此机器,然后启用 Funnel。", - "tailscaleBinaryPath": "Binary: {path}", + "tailscaleBinaryPath": "二进制文件:{path}", "tailscaleLastError": "最近错误:{error}", "tailscaleInstallTitle": "安装 Tailscale", "tailscaleInstallIntro": "在此机器上安装 Tailscale,并准备让 OmniRoute 启用 Funnel。", @@ -3060,7 +3073,16 @@ "tierAlwaysProtected": "始终受保护", "tierPublic": "公开", "hideInternal": "隐藏内部端点", - "showInternal": "显示内部端点" + "showInternal": "显示内部端点", + "vscodeAliasTitle": "VS Code 令牌别名", + "vscodeAliasDescriptionReady": "使用 /api/v1/vscode/TOKEN/... 接口的可粘贴兼容性 URL。", + "vscodeAliasDescriptionError": "由于当前会话无法加载 CLI 密钥,正在显示占位符 URL。", + "vscodeAliasDescriptionLoading": "正在加载 CLI 密钥。在密钥可用之前将显示占位符 URL。", + "vscodeAliasDescriptionPlaceholder": "正在显示占位符 URL。请在 CLI 工具中创建或激活 API 密钥以替换 TOKEN。", + "vscodeAliasManage": "CLI 工具", + "vscodeAliasBaseLabel": "VS Code 基础", + "vscodeAliasModelsLabel": "VS Code 模型", + "vscodeAliasChatLabel": "VS Code 聊天" }, "endpoints": { "tabProxy": "端点代理", @@ -3217,7 +3239,7 @@ "tablePhase": "阶段", "offset": "偏移量", "limit": "限制", - "skill": "Skill", + "skill": "技能", "rpcEndpoint": "发布 /a2a", "rpcMethodSend": "留言/发送", "rpcMethodStream": "消息/流", @@ -3445,7 +3467,7 @@ "networkAccess": "网络访问", "networkAccessDesc": "允许发起出站网络请求", "mode": "模式", - "q": "Q", + "q": "问", "filterSkillsPlaceholder": "按名称、描述或标签过滤技能", "allModes": "所有模式", "totalSkills": "技能总数", @@ -3455,9 +3477,9 @@ "marketplaceTab": "市场", "applyFilters": "应用筛选", "popularDefaultsLabel": "所选提供商的默认热门技能:", - "onMode": "ON", - "offMode": "OFF", - "autoMode": "AUTO", + "onMode": "开", + "offMode": "关", + "autoMode": "自动", "installSkillButton": "安装技能", "installSkillModalTitle": "安装技能", "installJsonPlaceholder": "在此粘贴技能清单 JSON...", @@ -3515,7 +3537,7 @@ "latencyP50": "p50", "latencyP95": "p95", "latencyP99": "p99", - "millisecondsShort": "{value}ms", + "millisecondsShort": "{value} 毫秒", "notAvailable": "—", "totalRequests": "请求总数", "noDataYet": "还没有数据", @@ -3725,7 +3747,7 @@ "confirmClearActiveRequests": "清除所有活跃请求?", "model": "模型", "provider": "提供商", - "account": "Account", + "account": "账户", "elapsed": "已耗时", "activeStage": "阶段", "activeStageUnknown": "尚未发送到上游", @@ -3933,7 +3955,7 @@ "passedCount": "{count} 通过", "failedCount": "{count} 失败", "testedCount": "{count} 已测试", - "millisecondsAbbr": "{value}ms", + "millisecondsAbbr": "{value} 毫秒", "okShort": "好的", "errorShort": "错误", "noActiveConnectionsInGroup": "未找到该组的活动连接。", @@ -3958,18 +3980,14 @@ "testKeyPlaceholder": "sk-...(仅用于验证)", "providerNotFound": "未找到提供商", "deleteConnectionConfirm": "删除这个连接吗?", - "batchDeleteSelected": "Delete Selected ({count})", - "batchDeleteConfirm": "Delete {count} connection(s)? This action cannot be undone.", - "batchDeleteSuccess": "Deleted {count} connection(s)", - "batchActivateSelected": "__MISSING__:Activate", - "batchDeactivateSelected": "__MISSING__:Deactivate", - "batchRetestSelected": "__MISSING__:Retest", - "batchActivateSuccess": "__MISSING__:Activated {count} connection(s)", - "batchDeactivateSuccess": "__MISSING__:Deactivated {count} connection(s)", - "batchUpdatePartial": "__MISSING__:{count} updated, {skipped} skipped (no longer exist)", - "batchUpdateNone": "__MISSING__:No connections were updated (they may no longer exist)", - "batchRetestLimit": "__MISSING__:Retest up to {max} connections at a time", - "noConnectionsToTest": "__MISSING__:No matching connections to test", + "batchDeleteSelected": "删除选中({count})", + "batchDeleteConfirm": "删除 {count} 个连接?此操作无法撤销。", + "batchDeleteSuccess": "已删除 {count} 个连接", + "batchActivateSelected": "批量启用", + "batchDeactivateSelected": "批量停用", + "batchRetestSelected": "批量测试", + "batchActivateSuccess": "已启用 {count} 个连接", + "batchDeactivateSuccess": "已停用 {count} 个连接", "batchDeleteConfirmTitle": "删除连接", "batchDeleteConfirmButton": "删除", "filterAll": "全部", @@ -3987,7 +4005,7 @@ "openaiCompatibleDetails": "OpenAI 兼容详情", "messagesApi": "Messages API", "responsesApi": "Responses API", - "embeddings": "Embeddings", + "embeddings": "嵌入", "audioTranscriptions": "音频转写", "audioSpeech": "语音合成", "imagesGenerations": "图像生成", @@ -4352,7 +4370,7 @@ "audioShortLabel": "音频", "azureOpenAiBaseUrlHint": "Azure OpenAI 资源的 Base URL。", "bailianBaseUrlHint": "阿里云百炼服务的 Base URL。", - "claudeWebCookieHint": "Open claude.ai → DevTools → Application → Cookies → claude.ai, copy the 'sessionKey' value. Also need cf_clearance, __cf_bm, _cfuvid for Cloudflare.", + "claudeWebCookieHint": "打开 claude.ai → 开发者工具 → 应用 → Cookie → claude.ai,复制 sessionKey 值。还需要从同一页面复制 cf_clearance 值。", "claudeWebCookiePlaceholder": "sessionKey=sk-ant-...", "blackboxWebCookieHint": "从 Blackbox Web 会话复制 Cookie。", "blackboxWebCookiePlaceholder": "Blackbox Web Cookie", @@ -4404,7 +4422,7 @@ "expirationBannerExpiredDesc": "此 Provider 的凭据已过期,请更新后继续使用。", "expirationBannerExpiringSoon": "凭据即将过期", "expirationBannerExpiringSoonDesc": "此 Provider 的凭据即将过期,请提前更新。", - "extraApiKeyMasked": "Key {index}: {prefix}••••{suffix}", + "extraApiKeyMasked": "密钥 {index}:{prefix}••••{suffix}", "extraApiKeysHint": "为同一提供商添加额外 API 密钥,以便轮换和回退使用。", "extraApiKeysLabel": "额外 API 密钥", "apiKeyHealthLabel": "API 密钥健康状态", @@ -4477,7 +4495,7 @@ "snowflakeBaseUrlHint": "Snowflake Cortex 服务的 Base URL。", "supportedEndpointAudio": "音频", "supportedEndpointChat": "聊天", - "supportedEndpointEmbeddings": "Embeddings", + "supportedEndpointEmbeddings": "嵌入", "supportedEndpointImages": "图像", "supportedEndpointsLabel": "支持的端点", "tagGroupHint": "用于筛选和组织 Provider 的标签分组。", @@ -4648,7 +4666,59 @@ "kimiWebLabel": "Kimi Web", "kimiWebDesc": "通过 kimi.moonshot.cn 访问中国市场的 AI 聊天", "doubaoWebLabel": "豆包网", - "doubaoWebDesc": "字节跳动 AI 聊天通过 doubao.com" + "doubaoWebDesc": "字节跳动 AI 聊天通过 doubao.com", + "Account Deactivated": "账户已停用", + "distributeProxies": "分配代理", + "distributing": "分配中...", + "selectedCount": "已选 {count} 个", + "accountsCount": "{count} 个账户", + "testAllModels": "测试所有模型", + "testAllModelsConfirm": "测试所有可见模型?如果启用了自动隐藏,失败的模型将被隐藏。", + "testingAllModels": "测试中 {done}/{total}...", + "testAllResults": "{total} 个模型中 {ok} 个可用", + "noModelsToTest": "没有模型匹配当前筛选条件", + "showAllModels": "全部", + "showVisibleOnly": "仅可见", + "showHiddenOnly": "仅隐藏", + "filterByVisibility": "按可见性筛选", + "autoHideFailed": "自动隐藏失败模型", + "testAllCompleted": "已测试 {total} 个模型:{ok} 个通过,{failed} 个失败", + "modelAutoHidden": "模型 {model} 已自动隐藏(测试失败)", + "filterVisible": "仅可见", + "filterHidden": "仅隐藏", + "modelsHiddenCount": "已隐藏 {count} 个", + "setAliasSuccess": "别名 {alias} 已设置", + "deleteAliasSuccess": "别名 {alias} 已删除", + "fetchModelsSuccess": "找到 {count} 个新模型", + "fetchModelsFailed": "无法自动获取模型(可从设置中重试)", + "testAllRateLimited": "达到速率限制,已提前停止", + "hideFailedAuto": "自动隐藏失败模型", + "hideAllModels": "全部隐藏", + "testAllProgress": "已测试 {done}/{total}", + "testAllFailedHidden": "已隐藏 {count} 个失败模型", + "testAllDone": "所有模型已测试", + "errorTypeCreditsExhausted": "额度已用完", + "errorTypeBanned": "403 禁止访问", + "proxyOn": "代理开启", + "proxyOff": "代理关闭", + "proxyEnabledTitle": "此连接已启用代理", + "proxyDisabledTitle": "此连接已禁用代理", + "perKeyProxyOn": "按密钥", + "perKeyProxyOff": "连接", + "perKeyProxyEnabledTitle": "已为此提供商启用按密钥代理分配", + "perKeyProxyDisabledTitle": "已为此提供商禁用按密钥代理分配", + "healthcheckFailed": "运行健康检查失败", + "rateLimitOverridesSection": "速率限制覆盖", + "rateLimitOverridesMaxConcurrentHint": "此连接的最大并发请求覆盖。覆盖账户级别的上限。", + "rateLimitOverridesMaxConcurrentLabel": "最大并发(速率限制)", + "rateLimitOverridesMinTimeHint": "请求之间的最小时间(毫秒)。覆盖默认速率限制器延迟。", + "rateLimitOverridesMinTimeLabel": "最小间隔(毫秒)", + "rateLimitOverridesRpmHint": "此连接的每分钟最大请求数。覆盖提供商默认值。", + "rateLimitOverridesRpmLabel": "RPM(请求/分钟)", + "rateLimitOverridesTpdHint": "此连接的每日最大令牌数。覆盖提供商默认值。", + "rateLimitOverridesTpdLabel": "TPD(令牌/天)", + "rateLimitOverridesTpmHint": "此连接的每分钟最大令牌数。覆盖提供商默认值。", + "rateLimitOverridesTpmLabel": "TPM(令牌/分钟)" }, "settings": { "title": "设置", @@ -4792,13 +4862,13 @@ "modelsDevInfoOrder": "用户覆盖 → models.dev → LiteLLM → 硬编码默认值", "systemTheme": "系统主题", "debugToggle": "启用调试模式", - "homePinProviderQuotaToHome": "固定到首页的信息", + "homePinProviderQuotaToHome": "将信息固定到首页", "homeProviderQuotaLimits": "提供商配额限制", "homeProviderQuotaLimitsDesc": "将提供商配额状态容器(含全部刷新按钮)固定到首页顶部。", - "homeQuickStart": "快速开始", - "homeQuickStartDesc": "在首页显示快速开始面板。", + "homeQuickStart": "快速入门", + "homeQuickStartDesc": "在首页上显示快速入门面板。", "homeProviderTopology": "提供商拓扑", - "homeProviderTopologyDesc": "在首页显示提供商拓扑。", + "homeProviderTopologyDesc": "在首页上显示提供商拓扑。", "endpointTokenSaver": "Token Saver", "endpointTokenSaverDesc": "在 Endpoint 页面显示 Token Saver 面板。", "sidebarVisibilityToggle": "显示侧边栏项目", @@ -4834,7 +4904,7 @@ "autoDisableDescription": "若提供商连接返回特定的永久封禁信号(如 HTTP 403\"请验证您的账户\"),则将其永久标记为停用。这会将其从组合轮换中移除。", "autoDisableThreshold": "封禁阈值", "autoDisableThresholdDesc": "触发永久停用所需的连续封禁信号次数。", - "resilienceStructureTitle": "Resilience Structure", + "resilienceStructureTitle": "弹性结构", "resilienceStructureDesc": "此页面仅配置行为。实时断路器状态显示在运行状况页面上。组合特定的重试和循环槽控制保留在组合设置上。", "enableThinking": "激发思考", "maxThinkingTokens": "最大思考令牌", @@ -4878,7 +4948,7 @@ "showCloudflareTunnelDesc": "在端点页面显示 Cloudflare Quick Tunnel 控制项。", "showTailscaleFunnel": "Tailscale Funnel", "showTailscaleFunnelDesc": "在端点页面显示 Tailscale Funnel 控制项。", - "showNgrokTunnel": "ngrok Tunnel", + "showNgrokTunnel": "ngrok 隧道", "showNgrokTunnelDesc": "在端点页面显示 ngrok Tunnel 控制项。", "sidebarVisibility": "隐藏侧边栏项目", "sidebarVisibilityDesc": "可以隐藏任意侧边栏导航项,以减少视觉负担,但不会禁用任何功能", @@ -5340,13 +5410,13 @@ "compressionMode": "压缩模式", "compressionModeOff": "关闭", "compressionModeOffDesc": "不应用压缩", - "compressionModeLite": "Lite", + "compressionModeLite": "精简", "compressionModeLiteDesc": "减少空白字符和空行", "compressionModeStandard": "标准(Caveman)", "compressionModeStandardDesc": "基于规则的压缩,包含 30+ 个模式,并保留代码块和 URL", - "compressionModeAggressive": "Aggressive", + "compressionModeAggressive": "激进", "compressionModeAggressiveDesc": "摘要 + 工具结果压缩 + 渐进老化,以实现最大节省", - "compressionModeUltra": "Ultra", + "compressionModeUltra": "极速", "compressionModeUltraDesc": "使用包括语义去重在内的全部技术进行最大压缩", "compressionAggressiveConfig": "Aggressive 引擎配置", "compressionAggressiveConfigDesc": "微调摘要、工具压缩和老化阈值", @@ -5432,17 +5502,17 @@ "resilienceScope": "范围:", "resilienceTrigger": "触发:", "resilienceEffect": "效果:", - "resilienceRequestQueueTitle": "Request Queue & Rate", - "resilienceAutoEnableApiKeyProviders": "Auto-enable for API-key providers", - "resilienceRequestsPerMinute": "Requests per minute", - "resilienceMinTimeBetweenRequests": "Minimum time between requests", - "resilienceConcurrentRequests": "Concurrent requests", + "resilienceRequestQueueTitle": "请求队列与速率", + "resilienceAutoEnableApiKeyProviders": "为 API 密钥提供商自动启用", + "resilienceRequestsPerMinute": "每分钟请求数", + "resilienceMinTimeBetweenRequests": "请求之间的最小时间", + "resilienceConcurrentRequests": "并发请求数", "resilienceMaxQueueWaitTime": "最大队列等待时间", - "resilienceBaseCooldown": "Base cooldown", - "resilienceUseUpstreamRetryHints": "Use upstream retry hints", - "resilienceDefaultPerProvider": "Default (per provider)", - "resilienceAlwaysOn": "Always on", - "resilienceAlwaysOff": "Always off", + "resilienceBaseCooldown": "基础冷却时间", + "resilienceUseUpstreamRetryHints": "使用上游重试提示", + "resilienceDefaultPerProvider": "默认(按提供商)", + "resilienceAlwaysOn": "始终开启", + "resilienceAlwaysOff": "始终关闭", "routingRemoveEntry": "删除条目", "routingNeedlesSubstrings": "Needles(要匹配的子字符串)", "routingCaseSensitive": "区分大小写", @@ -5528,20 +5598,20 @@ "visionBridgePromptHint": "在将提取出的描述注入回原始请求前,先发送给视觉模型。", "visionBridgeTimeoutMs": "超时(毫秒)", "visionBridgeMaxImagesPerRequest": "每个请求的最大图像数", - "resilienceMaxBackoffSteps": "Max backoff steps", - "resilienceProviderBreakerTitle": "Circuit Breaker per Provider", - "resilienceFailureThreshold": "Failure threshold", + "resilienceMaxBackoffSteps": "最大退避步数", + "resilienceProviderBreakerTitle": "按提供商的断路器", + "resilienceFailureThreshold": "失败阈值", "resilienceDegradationThreshold": "Degradation threshold", "resilienceResetTimeout": "重置超时", "resilienceFailureThresholdLabel": "失败阈值", "resilienceResetTimeoutLabel": "重置超时", - "resilienceConnectionCooldownTitle": "Connection Cooldown", + "resilienceConnectionCooldownTitle": "连接冷却", "resilienceUseUpstreamRetryHintsLabel": "使用上游重试提示", "resilienceUseUpstream429BreakerLabel": "使用上游 429 提示(断路器)", "resilienceMaxBackoffStepsLabel": "最大退避步数", "resilienceYes": "是的", "resilienceNo": "否", - "resilienceDefault": "Default", + "resilienceDefault": "默认", "storageDatabaseBackupRetention": "数据库备份保留", "storagePurgeData": "清除数据", "retentionQuotaSnapshots": "配额快照(天)", @@ -5619,8 +5689,8 @@ "codexFastTierHint": "启用后,OmniRoute 会将 service_tier=priority 添加到尚未指定层的连接的出站 Codex 请求中。优先级需要 OpenAI Enterprise API 密钥或 ChatGPT-auth Codex 路径;其他密钥类型将从 OpenAI 收到与层相关的错误。 Codex 提供程序页面上的每个连接设置优先。", "codexFastTierSaveError": "无法更新 Codex Fast Tier 设置", "codexFastTierTierLabel": "服务层级", - "codexFastTierTierPriority": "Priority", - "codexFastTierTierFlex": "Flex", + "codexFastTierTierPriority": "优先级", + "codexFastTierTierFlex": "弹性", "codexFastTierTierDefault": "默认", "codexFastTierModelsLabel": "快速层模型", "codexFastTierModelsHint": "启用快速层后,只有勾选的模型会附带 service_tier。", @@ -5744,7 +5814,60 @@ "proxyGlobalConfigTab": "全局配置", "proxyPoolTab": "代理池", "freePoolTab": "免费泳池", - "proxyDocumentationTab": "文档" + "proxyDocumentationTab": "文档", + "perKeyProxyEnabled": "启用按密钥代理分配", + "perKeyProxyEnabledDesc": "启用后,每个提供商连接可以使用自己的代理分配", + "proxyPool": "代理池", + "freePool": "免费池", + "proxyDocumentation": "代理文档", + "proxyDocumentationScopeTitle": "代理作用域解析", + "proxyDocumentationScopeDescBefore": "OmniRoute 按优先级顺序解析出站代理:", + "proxyDocumentationScopeOrder": "组合 → 账户 → 提供商 → 全局", + "proxyDocumentationScopeDescAfter": "。最具体的作用域优先。", + "proxyDocumentationAddTitle": "添加自定义代理", + "proxyDocumentationAddDescBefore": "转到 ", + "proxyDocumentationAddDescMiddle": " 选项卡 → 点击 ", + "proxyDocumentationAddCta": "+ 添加代理", + "proxyDocumentationAddDescAfter": "。填写类型(http/https/socks5)、主机和端口。可选择将其分配给作用域。", + "proxyDocumentationBulkTitle": "批量导入格式", + "proxyDocumentationBulkDesc": "管道符分隔的字段:", + "proxyDocumentationSocks5DescBefore": "SOCKS5 代理默认禁用。设置", + "proxyDocumentationFreePoolDesc": "免费池选项卡聚合来自 1proxy、Proxifly 和 IPLocate 的代理。使用 ⊕ 测试并将代理提升到您的注册表中。只有通过连通性测试的代理才会被添加。", + "proxyDocumentationVercelRelayDescBefore": "Vercel Relay 是一个出站边缘中继,而不是入站隧道。部署后,LLM API 调用将通过 Vercel 的动态 IP 发送,绕过数据中心地理封锁和速率限制。中继由生成的密钥头保护", + "proxyDocumentationVercelRelayDescAfter": "您的 Vercel 令牌仅在部署期间使用,不会存储。", + "vercelRelayTokenRequired": "需要令牌", + "vercelRelayDeployFailed": "部署失败", + "vercelRelayTokenHint": "令牌仅在部署期间使用——从不存储。", + "proxyFreePoolFilterProtocol": "按协议筛选", + "proxyFreePoolProtocol": "协议", + "proxyFreePoolCountryPlaceholder": "国家(例如 US)", + "proxyFreePoolFilterCountry": "按国家筛选", + "proxyFreePoolMinQualityPlaceholder": "最低质量", + "proxyFreePoolMinQualityLabel": "最低质量分数", + "proxyFreePoolSyncAll": "同步全部", + "proxyFreePoolTotal": "总计", + "proxyFreePoolInPool": "池中", + "proxyFreePoolAvgQuality": "平均质量", + "proxyFreePoolSelected": "已选 {count} 个", + "proxyFreePoolAddSelected": "将选中项添加到池", + "proxyFreePoolAddVisible": "将所有可见项添加到池", + "proxyFreePoolSource": "来源", + "proxyFreePoolHostPort": "主机:端口", + "proxyFreePoolType": "类型", + "proxyFreePoolCountry": "国家", + "proxyFreePoolQuality": "质量", + "proxyFreePoolLatency": "延迟", + "proxyFreePoolEmpty": "未找到代理。点击同步全部从来源获取。", + "close": "关闭", + "cancel": "取消", + "responsesStateTitle": "响应状态", + "responsesStateDesc": "控制 OmniRoute 如何处理 previous_response_id。", + "responsesStateModeLabel": "previous_response_id 处理", + "responsesStateModeAuto": "自动", + "responsesStateModeStrip": "剥离", + "responsesStateModePreserve": "保留", + "responsesStateHint": "自动模式会剥离 previous_response_id,除非连接明确启用 OpenAI 响应存储。对于无状态客户端(如 VS Code 自定义端点),剥离模式最安全;上下文取决于客户端发送完整历史记录。", + "responsesStateSaveError": "更新响应状态设置失败" }, "contextRtk": { "title": "RTK 引擎", @@ -5894,7 +6017,7 @@ "successful": "成功", "errors": "错误", "avgLatency": "平均延迟", - "millisecondsShort": "{value}ms", + "millisecondsShort": "{value} 毫秒", "notAvailableSymbol": "—", "liveAutoRefreshing": "直播 — 自动刷新", "paused": "已暂停", @@ -6094,7 +6217,7 @@ "conceptDiagramHubLabel": "OpenAI (中心)", "conceptDiagramTargetLabel": "目标提供者", "conceptDiagramExampleApp": "例如 Anthropic SDK", - "conceptDiagramExampleSource": "claude", + "conceptDiagramExampleSource": "Claude", "conceptDiagramExampleTarget": "双子座", "conceptHowItWorksToggle": "它是如何工作的", "conceptHowItWorksBody": "您的应用以其自己的格式发送请求。翻译器检测该格式,通过 OpenAI 作为中介中心进行转换(或在可用的情况下直接进行转换),将其发送到所选提供商,并将响应转换回您应用的格式。", @@ -6285,10 +6408,10 @@ "age": "年龄", "requests": "请求", "connection": "连接方式", - "durationMillisecondsShort": "{value}ms", - "durationSecondsShort": "{value}s", - "durationMinutesShort": "{value}m", - "durationHoursShort": "{value}h", + "durationMillisecondsShort": "{value} 毫秒", + "durationSecondsShort": "{value} 秒", + "durationMinutesShort": "{value} 分", + "durationHoursShort": "{value} 时", "reasonSeparator": " - ", "notAvailableSymbol": "-", "providerLimits": "提供商限制", @@ -6358,7 +6481,7 @@ "suiteImportInvalid": "无效的 eval 套件 JSON", "suiteBuilderCloneSuffix": "副本", "suiteBuilderImportedSuite": "已导入套件", - "scorecardTitle": "Scorecard", + "scorecardTitle": "记分卡", "evalApiKey": "API key", "scorecardPassRate": "通过率", "targetTypeModel": "模型", @@ -6393,7 +6516,7 @@ "targetSuiteDefaults": "Suite 默认值", "suiteBuilderDeleteConfirm": "确定要删除此套件吗?此操作无法撤销。", "suiteBuilderNameLabel": "Suite 名称", - "scorecardSuites": "Suites", + "scorecardSuites": "套件", "evalCompareTarget": "对比目标", "suiteBuilderCaseModelPlaceholder": "例如 gpt-4o-mini", "evalControlsHint": "配置评估目标和 API 密钥,然后运行套件以验证模型质量。", @@ -6434,7 +6557,7 @@ "suiteBuilderCaseExpectedLabel": "期望值", "historyLatency": "延迟", "suiteBuilderCaseTagsPlaceholder": "例如 coding, python", - "targetTypeCombo": "Combo", + "targetTypeCombo": "组合", "scorecardPassed": "已通过", "suiteBuilderCaseTagsLabel": "标签", "suiteBuilderNewSuite": "新建 Suite", @@ -6480,6 +6603,7 @@ "budgetColDailyLim": "每日限制", "budgetColMonthlyLim": "每月限制", "budgetColUsedPct": "使用百分比", + "percentUsed": "已使用 {pct}%", "budgetLoading": "加载中...", "budgetNoKeysMatch": "没有键匹配过滤器", "budgetLinearExtrapolation": "线性外推法", @@ -6496,7 +6620,9 @@ "updatedShort": "更新于", "lastRefreshed": "上次刷新", "providerQuota": "提供商配额", - "providerQuotaHomeHint": "已连接账户的实时状态" + "providerQuotaHomeHint": "已连接账户的实时状态", + "tokenExpiresIn": "令牌在 {time} 后过期", + "tokenExpired": "令牌已过期" }, "modals": { "waitingAuth": "等待授权", @@ -6686,7 +6812,7 @@ "configureProvidersNote": "📝 在仪表板中配置提供商或使用环境变量", "dataLocation": "数据位置:", "dataLocationMacLinux": " macOS/Linux:", - "dataLocationWindows": " Windows:", + "dataLocationWindows": " Windows:", "product": "产品", "dashboardLink": "仪表板", "changelog": "变更日志", @@ -6763,7 +6889,7 @@ "deployFlyText": "使用一个 Fly.toml 和一个部署命令部署到 Fly.io 边缘运行时。", "deployPwaTitle": "渐进式网页应用", "deployPwaText": "在 Android、iOS 和桌面浏览器上将 OmniRoute 作为渐进式 Web 应用程序安装。", - "deployTermuxTitle": "Termux (Android)", + "deployTermuxTitle": "Termux(Android)", "deployTermuxText": "通过 Termux 在 Android 上以无界面模式运行 OmniRoute,并从移动浏览器访问仪表盘。", "featureRoutingTitle": "多提供商路由", "featureRoutingText": "通过单一 OpenAI 兼容端点将请求路由到 30+ AI 提供商,支持聊天、Responses、音频和图像 API。", @@ -7038,7 +7164,7 @@ "flowDiagramOmniRouteDesc": "接收请求并选择目标", "flowDiagramSpawn": "启动进程", "flowDiagramSpawnDesc": "通过 stdio 启动 CLI 二进制文件", - "flowDiagramCli": "CLI Agent", + "flowDiagramCli": "CLI 代理", "flowDiagramCliDesc": "使用自身认证/模型处理", "fingerprintSettingsHint": "CLI 指纹匹配(伪装成特定 CLI 工具的请求)可在以下位置配置:", "settingsRoutingLink": "设置/路由", @@ -7326,7 +7452,7 @@ "password": "密码", "passwordPlaceholder": "密码", "connected": "已连接", - "ip": "IP:", + "ip": "IP:", "connectionFailed": "连接失败", "testConnection": "测试连接", "clear": "清除", @@ -7533,42 +7659,42 @@ "conversationalChat": "对话式聊天", "clearChat": "清除聊天内容", "typeMessagePlaceholder": "输入消息...(Shift+Enter 换行)", - "tabChat": "Chat", - "tabCompare": "Compare", + "tabChat": "聊天", + "tabCompare": "比较", "tabApi": "API", - "tabBuild": "Build", - "configPane": "Config", - "systemPrompt": "System prompt", - "systemPromptPlaceholder": "You are a helpful assistant.", - "modelPlaceholder": "e.g. openai/gpt-4o", - "endpointLabel": "Endpoint", - "parametersLabel": "Parameters", - "collapseConfig": "Collapse config pane", - "expandConfig": "Expand config pane", - "temperature": "Temperature", - "maxTokens": "Max tokens", + "tabBuild": "构建", + "configPane": "配置", + "systemPrompt": "系统提示", + "systemPromptPlaceholder": "你是一个乐于助人的助手。", + "modelPlaceholder": "例如 openai/gpt-4o", + "endpointLabel": "端点", + "parametersLabel": "参数", + "collapseConfig": "折叠配置面板", + "expandConfig": "展开配置面板", + "temperature": "温度", + "maxTokens": "最大令牌数", "topP": "Top-p", - "presencePenalty": "Presence penalty", - "frequencyPenalty": "Frequency penalty", - "seedPlaceholder": "Random (leave empty)", - "presetsLabel": "Presets", - "loadPreset": "Load preset", - "savePreset": "Save preset", - "noPresets": "No presets", - "presetNamePlaceholder": "Preset name", - "savingPreset": "Saving…", - "nameRequired": "Name is required", - "failedToSavePreset": "Failed to save preset", - "improvePrompt": "Improve prompt", - "improvingPrompt": "Improving…", - "improvePromptTitle": "Improve your prompt with AI", - "setModelFirst": "Set a model first", - "improveQuotaWarning": "This will consume quota from the model configured in Config.", - "improveConfirm": "Improve", - "exportCode": "Export code", - "exportCodeTitle": "Export Code", + "presencePenalty": "存在惩罚", + "frequencyPenalty": "频率惩罚", + "seedPlaceholder": "随机(留空)", + "presetsLabel": "预设", + "loadPreset": "加载预设", + "savePreset": "保存预设", + "noPresets": "无预设", + "presetNamePlaceholder": "预设名称", + "savingPreset": "保存中…", + "nameRequired": "名称是必填项", + "failedToSavePreset": "保存预设失败", + "improvePrompt": "优化提示", + "improvingPrompt": "优化中…", + "improvePromptTitle": "使用 AI 优化提示", + "setModelFirst": "请先设置模型", + "improveQuotaWarning": "这将消耗配置中模型配额的用量。", + "improveConfirm": "优化", + "exportCode": "导出代码", + "exportCodeTitle": "导出代码", "exportShort": "导出", - "noStateToExport": "No playground state available to export.", + "noStateToExport": "没有可导出的 Playground 状态。", "closeExportModal": "关闭导出模态框", "close": "关闭", "exportRealKeyWarning": "安全警告:导出被阻止 — 输出中检测到真实的 API 密钥。请重置您的 API 密钥并重试。", @@ -7577,31 +7703,31 @@ "copyLangCode": "复制 {language} 代码", "loadingPresets": "加载中…", "loadPresetPlaceholder": "加载预设…", - "copyCode": "Copy", - "copiedCode": "Copied!", - "addModel": "+ Add model", - "runAll": "Run all", - "cancelAll": "Cancel all", - "maxColumnsReached": "Maximum {max} columns", - "modelPlaceholderCompare": "Model (Cmd+K)…", + "copyCode": "复制", + "copiedCode": "已复制!", + "addModel": "+ 添加模型", + "runAll": "运行全部", + "cancelAll": "取消全部", + "maxColumnsReached": "最大 {max} 列", + "modelPlaceholderCompare": "模型(Cmd+K)…", "ttft": "TTFT", "tps": "TPS", - "metricsDisclaimer": "client-side estimate", - "tokensLabel": "Tokens", - "costLabel": "Cost", - "costEstimated": "(estimated)", - "toolsLabel": "Tools", - "addTool": "Add tool", - "toolNamePlaceholder": "Function name", - "toolDescPlaceholder": "Description (optional)", - "toolParamsInvalid": "Parameters must be valid JSON", - "structuredOutputLabel": "Structured Output", - "enableJsonMode": "Enable JSON mode", - "disableJsonMode": "Disable JSON mode", - "invalidJson": "Invalid JSON", - "running": "Running…", - "runLabel": "Run", - "enterToolResult": "Enter tool result…", + "metricsDisclaimer": "客户端估算", + "tokensLabel": "令牌", + "costLabel": "成本", + "costEstimated": "(估算)", + "toolsLabel": "工具", + "addTool": "添加工具", + "toolNamePlaceholder": "函数名称", + "toolDescPlaceholder": "描述(可选)", + "toolParamsInvalid": "参数必须为有效 JSON", + "structuredOutputLabel": "结构化输出", + "enableJsonMode": "启用 JSON 模式", + "disableJsonMode": "禁用 JSON 模式", + "invalidJson": "无效 JSON", + "running": "运行中…", + "runLabel": "运行", + "enterToolResult": "输入工具结果…", "build": { "step1Label": "测试什么?", "step2Label": "配置", @@ -7657,7 +7783,7 @@ "audioFile": "音频文件", "noKeysFound": "未找到 API 密钥。请到 Keys 区域添加一个。", "exampleLabel": "示例", - "latency": "{ms}ms", + "latency": "{ms} 毫秒", "statsLine": "{ms}ms · {tokensIn} 输入 / {tokensOut} 输出 tokens" }, "requestLogger": { @@ -7692,8 +7818,8 @@ "sortLogs": "日志排序", "refresh": "刷新", "columnsLabel": "列", - "cacheSem": "SEM", - "cacheUp": "UP", + "cacheSem": "语义", + "cacheUp": "在线", "semantic": "Semantic Cache", "upstream": "上游", "semanticCacheHit": "语义缓存命中(由 OmniRoute 提供)", @@ -7749,10 +7875,10 @@ "allLevels": "全部级别", "allProviders": "全部 Provider", "total": "总计", - "ok": "OK", - "err": "ERR", - "timeoutShort": "TMO", - "direct": "direct", + "ok": "正常", + "err": "错误", + "timeoutShort": "超时", + "direct": "直连", "newest": "最新", "oldest": "最旧", "latencyDesc": "延迟 ↓", @@ -7771,10 +7897,10 @@ "images": "图像", "chat": "聊天", "music": "音乐", - "responses": "Responses", - "rerank": "Rerank", + "responses": "响应", + "rerank": "重新排序", "video": "视频", - "embeddings": "Embeddings", + "embeddings": "嵌入", "transcription": "转写" }, "toolDescriptions": { @@ -7786,118 +7912,118 @@ "kilo": "Kilo" }, "runtime": { - "title": "Runtime", - "description": "Realtime observability — 3-layer resilience + sessions + quota alerts", - "pause": "Pause", - "resume": "Resume", - "refreshNow": "Refresh now", - "kpiSessions": "Sessions", - "kpiCircuits": "Circuits", - "kpiCooldowns": "Cooldowns", - "kpiLockouts": "Lockouts", - "hintStickyBound": "{count} sticky-bound", - "hintRecovering": "{count} recovering", - "hintAllHealthy": "all healthy", - "hintOpen": "open", - "hintConnsCooling": "connections cooling", - "hintModelsBlocked": "models blocked", - "resilienceTitle": "3-Layer Resilience", - "resilienceSubtitle": "Mirrors the documented resilience model", - "providersHealthy": "{percent}% providers healthy", - "layer": "Layer {n}", - "layer1Title": "Provider Circuit Breakers", - "layer1Desc": "Stop traffic to providers failing at the upstream level", - "layer2Title": "Connection Cooldowns", - "layer2Desc": "Skip one bad account/key; other connections keep serving", - "layer3Title": "Model Lockouts", - "layer3Desc": "Per-model rate-limit locks; same connection still serves other models", - "badgeAffectedOf": "{affected} of {total} affected", - "badgeCooling": "{count} cooling", - "badgeLocked": "{count} locked", - "emptyCircuits": "No circuit breakers active yet", - "emptyCooldowns": "No connection cooldowns active", - "emptyLockouts": "No model lockouts", - "moreCooldowns": "+{count} more cooldowns", - "moreLockouts": "+{count} more lockouts", - "feedTitle": "Live Feed", - "feedSubtitle": "Last {count} events", - "feedFilterAll": "All", - "feedFilterCircuits": "Circuits", - "feedFilterCooldowns": "Cooldowns", - "feedFilterLockouts": "Lockouts", - "feedFilterSessions": "Sessions", - "feedFilterQuotas": "Quotas", - "feedClear": "Clear", - "feedEmptyWaiting": "Waiting for events... (polled every 5s)", - "feedEmptyFiltered": "No events match this filter", - "sessionsTitle": "Active Sessions", - "sessionsSubtitle": "Sticky-bound request fingerprints (live)", - "sessionsActive": "{count} active", - "sessionsEmptyTitle": "No active sessions", - "sessionsEmptyHint": "Sessions appear as requests flow through the proxy", - "tblSession": "Session", - "tblAge": "Age", - "tblIdle": "Idle", - "tblReqs": "Reqs", - "tblBoundTo": "Bound to", - "topApiKeys": "Top API keys", - "quotaMonitorsTitle": "Quota Monitors", - "quotaMonitorsSubtitle": "Live quota state per account window", - "openQuota": "Open Quota", - "allQuotasHealthy": "All quotas healthy", - "statusExhausted": "EXHAUSTED", - "statusAlerting": "ALERTING", - "statusError": "ERROR", - "moreSuffix": "+{count} more" + "title": "运行时", + "description": "实时可观测性 — 3 层弹性 + 会话 + 配额告警", + "pause": "暂停", + "resume": "恢复", + "refreshNow": "立即刷新", + "kpiSessions": "会话", + "kpiCircuits": "断路器", + "kpiCooldowns": "冷却", + "kpiLockouts": "锁定", + "hintStickyBound": "{count} 个粘性绑定", + "hintRecovering": "{count} 个恢复中", + "hintAllHealthy": "全部健康", + "hintOpen": "打开", + "hintConnsCooling": "连接冷却中", + "hintModelsBlocked": "模型已阻止", + "resilienceTitle": "3 层弹性", + "resilienceSubtitle": "反映记录的弹性模型", + "providersHealthy": "{percent}% 提供商健康", + "layer": "第 {n} 层", + "layer1Title": "提供商断路器", + "layer1Desc": "阻止到上游级失败的提供商的流量", + "layer2Title": "连接冷却", + "layer2Desc": "跳过一个坏账户/密钥;其他连接继续服务", + "layer3Title": "模型锁定", + "layer3Desc": "按模型速率限制锁定;同一连接仍可服务其他模型", + "badgeAffectedOf": "{affected}/{total} 受影响", + "badgeCooling": "{count} 冷却中", + "badgeLocked": "{count} 已锁定", + "emptyCircuits": "尚无活动断路器", + "emptyCooldowns": "尚无连接冷却", + "emptyLockouts": "无模型锁定", + "moreCooldowns": "+{count} 更多冷却", + "moreLockouts": "+{count} 更多锁定", + "feedTitle": "实时动态", + "feedSubtitle": "最近 {count} 个事件", + "feedFilterAll": "全部", + "feedFilterCircuits": "断路器", + "feedFilterCooldowns": "冷却", + "feedFilterLockouts": "锁定", + "feedFilterSessions": "会话", + "feedFilterQuotas": "配额", + "feedClear": "清除", + "feedEmptyWaiting": "等待事件…(每 5 秒轮询一次)", + "feedEmptyFiltered": "没有匹配此筛选条件的事件", + "sessionsTitle": "活动会话", + "sessionsSubtitle": "粘性绑定请求指纹(实时)", + "sessionsActive": "{count} 个活动", + "sessionsEmptyTitle": "无活动会话", + "sessionsEmptyHint": "请求流经代理时会出现会话", + "tblSession": "会话", + "tblAge": "时长", + "tblIdle": "空闲", + "tblReqs": "请求数", + "tblBoundTo": "绑定到", + "topApiKeys": "热门 API 密钥", + "quotaMonitorsTitle": "配额监视器", + "quotaMonitorsSubtitle": "每个账户窗口的实时配额状态", + "openQuota": "开放配额", + "allQuotasHealthy": "所有配额健康", + "statusExhausted": "已耗尽", + "statusAlerting": "告警中", + "statusError": "错误", + "moreSuffix": "+{count} 更多" }, "quotaShare": { - "title": "Quota Sharing", - "description": "Share provider quotas across API keys with % limits", - "newPool": "New pool", + "title": "配额共享", + "description": "通过百分比限制跨 API 密钥共享提供商配额", + "newPool": "新建池", "betaTitle": "Beta — UI preview.", - "betaDescription": "Configuration is saved in localStorage (not yet persisted on the server). Per-request cap enforcement is not yet wired into the proxy pipeline. This screen lets you design and visualize the quota split; real enforcement will land in a future iteration with DB persistence and upstream call interception.", - "kpiActivePools": "Active pools", - "kpiKeysAllocated": "Keys allocated", - "kpiAvgUnallocated": "Avg unallocated", - "kpiProvidersWithQuota": "Providers w/ quota", - "emptyTitle": "No pools configured", - "emptyDescription": "Create a pool to assign API keys to share a provider's quota window with % allocations.", - "loading": "Loading…", - "removePool": "Remove pool", - "removeConfirm": "Remove this pool?", - "pool": "Pool", - "used": "used", - "allocationsCount": "Allocations ({count})", - "allocatedFree": "{allocated}% allocated · {free}% free", - "noAllocations": "No keys allocated yet", - "capLabel": "cap {value}", - "notTrackedYet": "(not tracked yet)", - "policy": "Policy", - "policyHard": "hard", - "policySoft": "soft", - "policyBurst": "burst", - "policyHardHint": "Block when key exhausts allocation", - "policySoftHint": "Allow overflow, alert only", - "policyBurstHint": "Allow burst into free pool", - "editAllocations": "Edit allocations", - "newPoolTitle": "New quota pool", - "providerConnection": "Provider connection (account)", - "selectConnection": "Select a connection…", - "noEligibleConnections": "No connections with quota data. Refresh from /dashboard/quota first.", - "quotaWindow": "Quota window", - "selectWindow": "Select a window…", - "alreadyUsedSuffix": "(already used)", - "windowReset": "Reset", - "duplicatePoolError": "A pool for this connection + window already exists", - "cancel": "Cancel", - "createPool": "Create pool", - "editTitle": "Edit allocations", - "noKeysAdded": "No keys allocated. Add one below.", - "totalLabel": "Total: {percent}%", - "totalExceeded": "⚠ exceeds 100%", - "addKey": "+ Add key…", - "equalSplit": "Equal split", - "save": "Save allocations", + "betaDescription": "配置保存在 localStorage 中(尚未持久化到服务器)。每次请求的上限执行将在未来更新中实现。", + "kpiActivePools": "活跃池", + "kpiKeysAllocated": "已分配密钥", + "kpiAvgUnallocated": "平均未分配", + "kpiProvidersWithQuota": "有配额的提供商", + "emptyTitle": "未配置池", + "emptyDescription": "创建池以分配 API 密钥,通过百分比分配共享提供商的配额窗口。", + "loading": "加载中…", + "removePool": "移除池", + "removeConfirm": "移除此池?", + "pool": "池", + "used": "已使用", + "allocationsCount": "分配({count})", + "allocatedFree": "已分配 {allocated}% · 空闲 {free}%", + "noAllocations": "尚未分配密钥", + "capLabel": "上限 {value}", + "notTrackedYet": "(尚未跟踪)", + "policy": "策略", + "policyHard": "硬性", + "policySoft": "软性", + "policyBurst": "突发", + "policyHardHint": "密钥耗尽分配时阻止", + "policySoftHint": "允许溢出,仅告警", + "policyBurstHint": "允许突发进入空闲池", + "editAllocations": "编辑分配", + "newPoolTitle": "新建配额池", + "providerConnection": "提供商连接(账户)", + "selectConnection": "选择连接…", + "noEligibleConnections": "没有带配额数据的连接。首先从 /dashboard/quota 刷新。", + "quotaWindow": "配额窗口", + "selectWindow": "选择窗口…", + "alreadyUsedSuffix": "(已使用)", + "windowReset": "重置", + "duplicatePoolError": "此连接 + 窗口的池已存在", + "cancel": "取消", + "createPool": "创建池", + "editTitle": "编辑分配", + "noKeysAdded": "未分配密钥。请在下方添加。", + "totalLabel": "总计:{percent}%", + "totalExceeded": "⚠ 超过 100%", + "addKey": "+ 添加密钥…", + "equalSplit": "平均分配", + "save": "保存分配", "betaPreviewLabel": "Beta — UI 预览。", "betaConfigSavedPrefix": "配置保存在", "betaConfigSavedSuffix": "(尚未保留在服务器上)。每个请求上限的强制执行尚未连接到代理管道中。此屏幕可让您设计和可视化配额分配;真正的执行将在未来的迭代中通过数据库持久性和上游调用拦截来实现。", @@ -7973,7 +8099,7 @@ "endpointsBaseUrl": "基础 URL", "endpointsCollapse": "折叠", "endpointsExpand": "展开", - "endpointsAnthropicNote": "Anthropic-native", + "endpointsAnthropicNote": "Anthropic 原生", "endpointsResponsesNote": "OpenAI 响应 — codex/github", "endpointsWsNote": "WebSocket — 仅限 codex", "betaText": "配额共享功能正常,但预计会有错误。发现一个了吗?请报告。", @@ -7985,40 +8111,40 @@ "ungroupedHint": "这些池未分配给已知组。编辑一个池以将其移动到真实组中。" }, "plugins": { - "title": "Plugins", - "description": "Install and manage plugins for extending OmniRoute functionality", - "loading": "Loading plugins...", - "scanning": "Scanning...", - "scanForPlugins": "Scan for Plugins", - "noPlugins": "No plugins installed", - "noPluginsDescription": "Place plugin directories in ~/.omniroute/plugins/ and click Scan.", - "activate": "Activate", - "deactivate": "Deactivate", - "uninstall": "Uninstall", - "uninstallConfirm": "Uninstall plugin \"{name}\"?", - "pluginScanComplete": "Plugin scan complete", - "pluginScanFailed": "Plugin scan failed", - "activated": "{name} activated", - "deactivated": "{name} deactivated", - "activateFailed": "Failed to activate {name}", - "deactivateFailed": "Failed to deactivate {name}", - "uninstalled": "{name} uninstalled", - "uninstallFailed": "Failed to uninstall {name}", - "configure": "Configure: {name}", - "configurePlugin": "Configure", - "noConfigSettings": "This plugin has no configurable settings.", - "saving": "Saving...", - "saveConfiguration": "Save Configuration", - "configurationSaved": "Configuration saved", - "saveConfigurationFailed": "Failed to save configuration", - "pluginNotFound": "Plugin not found", - "version": "Version", - "author": "Author", - "description_label": "Description", - "status": "Status", - "enabled": "Enabled", - "disabled": "Disabled", - "hooks": "Hooks" + "title": "插件", + "description": "安装和管理插件以扩展 OmniRoute 功能", + "loading": "加载插件中…", + "scanning": "扫描中…", + "scanForPlugins": "扫描插件", + "noPlugins": "未安装插件", + "noPluginsDescription": "将插件目录放入 ~/.omniroute/plugins/ 并点击扫描。", + "activate": "激活", + "deactivate": "停用", + "uninstall": "卸载", + "uninstallConfirm": "卸载插件 \"{name}\"?", + "pluginScanComplete": "插件扫描完成", + "pluginScanFailed": "插件扫描失败", + "activated": "{name} 已激活", + "deactivated": "{name} 已停用", + "activateFailed": "激活 {name} 失败", + "deactivateFailed": "停用 {name} 失败", + "uninstalled": "{name} 已卸载", + "uninstallFailed": "卸载 {name} 失败", + "configure": "配置:{name}", + "configurePlugin": "配置", + "noConfigSettings": "此插件无可配置的设置。", + "saving": "保存中…", + "saveConfiguration": "保存配置", + "configurationSaved": "配置已保存", + "saveConfigurationFailed": "保存配置失败", + "pluginNotFound": "未找到插件", + "version": "版本", + "author": "作者", + "description_label": "描述", + "status": "状态", + "enabled": "已启用", + "disabled": "已禁用", + "hooks": "钩子" }, "quotaPlans": { "title": "计划与配额", @@ -8105,7 +8231,7 @@ "providerCredentialsUpdated": "{actor} 更新了 {target} 的凭据", "providerCredentialsRevoked": "{actor} 撤销了 {target} 的凭据", "providerCredentialsBatchRevoked": "{actor} 批量撤销凭证", - "providerCredentialsBatchUpdated": "__MISSING__:{actor} batch-updated credentials", + "providerCredentialsBatchUpdated": "{actor} 批量更新了凭据", "providerCredentialsBulkCreated": "{actor} 批量创建了凭据", "providerCredentialsBulkImported": "{actor} 批量导入了凭据", "providerCredentialsImported": "{actor} 导入的凭据", @@ -8251,7 +8377,7 @@ "resumeBtn": "简历", "clearBtn": "清除", "exportHar": "导出 .har", - "recordSession": "REC", + "recordSession": "记录", "stopSession": "停止", "liveBadge": "直播", "offlineBadge": "离线", @@ -8449,7 +8575,7 @@ "binaryName": "二进制", "binaryNamePlaceholder": "例如:myagent", "versionCommand": "版本命令", - "versionCommandPlaceholder": "e.g.: myagent --version", + "versionCommandPlaceholder": "例如:myagent --version", "spawnArgs": "生成参数", "spawnArgsPlaceholder": "例如:--quiet,--json", "cliCodeRedirectCta": "打开 CLI 代码的" @@ -8505,7 +8631,7 @@ }, "categoryApi": "API", "categoryCli": "命令行界面", - "categoryConfig": "Config", + "categoryConfig": "配置", "filterAll": "所有", "coverageLabel": "覆盖率", "mcpUrl": "MCP URL", diff --git a/src/lib/arenaEloSync.ts b/src/lib/arenaEloSync.ts new file mode 100644 index 0000000000..587370931c --- /dev/null +++ b/src/lib/arenaEloSync.ts @@ -0,0 +1,561 @@ +/** + * arenaEloSync.ts — Arena AI leaderboard ELO sync engine. + * + * Fetches model intelligence data from the Arena AI leaderboard API + * (https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard) and stores + * normalised task-fit scores in the `model_intelligence` DB table. + * + * Resolution order: user overrides > synced arena ELO > defaults + * + * Opt-in via ARENA_ELO_SYNC_ENABLED=true (default: false). + */ + +import { backupDbFile } from "./db/backup"; +import { + bulkUpsertModelIntelligence, + deleteExpiredIntelligence, + deleteModelIntelligenceBySource, + type ModelIntelligenceEntry, +} from "./db/modelIntelligence"; + +// ─── Types ─────────────────────────────────────────────── + +/** + * A single model entry from the Arena AI leaderboard. + */ +export interface ArenaModelEntry { + /** Leaderboard rank (1-based). */ + rank: number; + /** Model identifier (may include vendor prefix like "anthropic/claude-opus"). */ + model: string; + /** Vendor / provider name (e.g. "Anthropic", "OpenAI"). */ + vendor: string; + /** ELO score (higher = better). */ + score: number; + /** Confidence interval half-width. */ + ci: number; + /** Total number of human preference votes. */ + votes: number; + /** License type (e.g. "proprietary", "open"). */ + license: string; +} + +/** + * Metadata + models for a single leaderboard category. + */ +export interface ArenaLeaderboardData { + /** Leaderboard metadata. */ + meta: { + /** Leaderboard category name (e.g. "text", "code"). */ + leaderboard: string; + /** Total number of models in this leaderboard. */ + model_count: number; + }; + /** Ranked model entries. */ + models: ArenaModelEntry[]; +} + +/** + * Map of leaderboard category → leaderboard data. + */ +export interface ArenaLeaderboardMap { + [category: string]: ArenaLeaderboardData; +} + +/** + * Result of a sync operation. + */ +export interface SyncResult { + /** Whether the sync completed successfully. */ + success: boolean; + /** Number of model intelligence entries stored. */ + modelCount: number; + /** Source identifier (always "arena_elo"). */ + source: string; + /** Error message if sync failed. */ + error?: string; +} + +/** + * Current status of the Arena ELO sync subsystem. + */ +export interface SyncStatus { + /** Whether periodic sync is enabled via env var. */ + enabled: boolean; + /** ISO timestamp of last successful sync, or null. */ + lastSync: string | null; + /** Number of models stored in last successful sync. */ + lastSyncModelCount: number; + /** ISO timestamp of next scheduled sync, or null. */ + nextSync: string | null; + /** Configured sync interval in milliseconds. */ + intervalMs: number; + /** Active data sources. */ + sources: string[]; +} + +// ─── Configuration ─────────────────────────────────────── + +const ARENA_ELO_API_BASE = + "https://api.wulong.dev/arena-ai-leaderboards/v1/leaderboard"; + +/** Leaderboard categories to fetch from the Arena API. */ +const FETCH_CATEGORIES = ["text", "code"] as const; + +/** + * Maps Arena leaderboard categories to OmniRoute task-type categories. + * + * - "text" leaderboard → default, review, documentation, debugging + * - "code" leaderboard → coding + * - "vision" leaderboard is intentionally skipped (not relevant for text fitness) + */ +const CATEGORY_TASK_MAP: Record = { + text: ["default", "review", "documentation", "debugging"], + code: ["coding"], +}; + +/** + * Known vendor prefixes to strip from model names. + * E.g. "anthropic/claude-opus-4-6-thinking" → "claude-opus-4-6-thinking" + */ +const VENDOR_PREFIXES = [ + "anthropic/", + "openai/", + "google/", + "meta/", + "mistral/", + "deepseek/", + "xai/", + "cohere/", + "qwen/", + "alibaba/", + "nvidia/", + "01-ai/", + "phind/", + "zerox/", + "together/", + "fireworks/", + "perplexity/", + "ai21/", +] as const; + +/** + * OmniRoute model aliases: canonical name → known aliases. + * Creates additional DB entries for each alias so that models + * are findable under any name OmniRoute uses internally. + */ +const MODEL_ALIAS_MAP: Record = { + "claude-opus-4-6-thinking": ["claude-opus-4", "anthropic/claude-opus-4"], + "claude-sonnet-4-5": ["claude-sonnet-4.5", "anthropic/claude-sonnet-4.5"], + "gpt-5.5": ["openai/gpt-5.5", "gpt-5"], + "gemini-3-flash": ["google/gemini-3-flash", "gemini-flash"], + "deepseek-r1": ["deepseek/deepseek-r1", "if/deepseek-r1"], + "kimi-k2-thinking": ["moonshot/kimi-k2", "qw/kimi-k2"], + "qwen3-coder-plus": ["qw/qwen3-coder-plus", "alibaba/qwen3-coder"], + "llama-4": ["meta/llama-4", "llama4"], +}; + +/** Votes threshold for "high" confidence. */ +const HIGH_CONFIDENCE_VOTES = 5000; + +/** Votes threshold for "medium" confidence. */ +const MEDIUM_CONFIDENCE_VOTES = 1000; + +/** Intelligence entry expiration: 7 days after sync. */ +const EXPIRY_DAYS = 7; + +const parsedInterval = parseInt(process.env.ARENA_ELO_SYNC_INTERVAL || "86400", 10); +const SYNC_INTERVAL_MS = + Number.isFinite(parsedInterval) && parsedInterval > 0 + ? parsedInterval * 1000 + : 86400 * 1000; + +// ─── Periodic sync state ───────────────────────────────── + +let syncTimer: ReturnType | null = null; +let lastSyncTime: string | null = null; +let lastSyncModelCount = 0; +let activeSyncIntervalMs = SYNC_INTERVAL_MS; +let firstSyncDone = false; +let syncInProgress = false; + +// ─── Model name normalization ──────────────────────────── + +/** + * Normalize a model name from the Arena leaderboard. + * + * Lowercases the name and strips known vendor prefixes + * (e.g. "anthropic/claude-opus-4" → "claude-opus-4"). + * + * @param rawName - The raw model name from the API response. + * @returns The cleaned, lowercase model name. + */ +export function normalizeModelName(rawName: string): string { + let name = rawName.toLowerCase(); + for (const prefix of VENDOR_PREFIXES) { + if (name.startsWith(prefix)) { + name = name.slice(prefix.length); + break; + } + } + return name; +} + +// ─── Core: Fetch ───────────────────────────────────────── + +/** + * Fetch leaderboards from the Arena AI API for all configured categories. + * + * Fetches "text" and "code" leaderboards concurrently and returns + * a map of category → leaderboard data. + * + * @returns Map of leaderboard category to its data. + * @throws If all category fetches fail (individual failures are logged and skipped). + */ +export async function fetchArenaLeaderboards(): Promise { + const result: ArenaLeaderboardMap = {}; + const errors: string[] = []; + + const fetches = FETCH_CATEGORIES.map(async (category) => { + const url = `${ARENA_ELO_API_BASE}?name=${category}`; + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(30000), + }); + if (!response.ok) { + throw new Error( + `Arena API fetch failed for "${category}" [${response.status}]: ${response.statusText}` + ); + } + const text = await response.text(); + try { + result[category] = JSON.parse(text) as ArenaLeaderboardData; + } catch { + throw new Error( + `Arena API returned invalid JSON for "${category}" (${text.slice(0, 100)}...)` + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn(`[ARENA_ELO_SYNC] Failed to fetch "${category}" leaderboard: ${message}`); + errors.push(message); + } + }); + + await Promise.all(fetches); + + if (Object.keys(result).length === 0) { + throw new Error( + `All Arena leaderboard fetches failed: ${errors.join("; ")}` + ); + } + + return result; +} + +// ─── Core: Transform ───────────────────────────────────── + +/** + * Compute confidence level based on vote count. + * + * @param votes - Number of human preference votes. + * @returns "high" (≥5000), "medium" (≥1000), or "low" (<1000). + */ +function computeConfidence(votes: number): "high" | "medium" | "low" { + if (votes >= HIGH_CONFIDENCE_VOTES) return "high"; + if (votes >= MEDIUM_CONFIDENCE_VOTES) return "medium"; + return "low"; +} + +/** + * Transform raw Arena leaderboard data into model intelligence entries. + * + * For each leaderboard category, normalizes ELO scores into task-fit values + * in the range [0.4, 0.98] using the formula: + * + * taskFit = 0.4 + 0.58 * ((elo - minElo) / (maxElo - minElo || 1)) + * + * This ensures scores never reach 0 or 1, leaving room for user overrides. + * Models with fewer than 100 votes are marked as confidence="low". + * + * Leaderboard categories are mapped to OmniRoute task types: + * - "text" → default, review, documentation, debugging + * - "code" → coding + * + * Known OmniRoute model aliases are also expanded into additional entries. + * + * @param data - Map of leaderboard category → Arena leaderboard data. + * @returns Array of model intelligence entries ready for DB upsert. + */ +export function transformToModelIntelligence( + data: ArenaLeaderboardMap +): Array> { + const entries: Array> = []; + const expiresAt = new Date( + Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000 + ).toISOString(); + + for (const [category, leaderboard] of Object.entries(data)) { + const taskCategories = CATEGORY_TASK_MAP[category]; + if (!taskCategories) continue; + + const models = Array.isArray(leaderboard.models) ? leaderboard.models : []; + if (models.length === 0) continue; + + // Compute ELO range for normalization + const eloScores = models.map((m) => m.score); + const minElo = Math.min(...eloScores); + const maxElo = Math.max(...eloScores); + const eloRange = maxElo - minElo || 1; + + for (const model of models) { + const normalizedModel = normalizeModelName(model.model); + const confidence = computeConfidence(model.votes); + const taskFit = 0.4 + 0.58 * ((model.score - minElo) / eloRange); + + for (const taskCategory of taskCategories) { + const entry: Omit = { + model: normalizedModel, + category: taskCategory, + source: "arena_elo", + score: Math.round(taskFit * 10000) / 10000, + eloRaw: model.score, + confidence, + expiresAt, + }; + entries.push(entry); + + // Expand known aliases + const aliases = MODEL_ALIAS_MAP[normalizedModel]; + if (aliases) { + for (const alias of aliases) { + entries.push({ + ...entry, + model: alias, + }); + } + } + } + } + } + + return entries; +} + +// ─── Main sync function ────────────────────────────────── + +/** + * Fetch, transform, and store Arena ELO intelligence data. + * + * Pipeline: delete expired → fetch leaderboards → transform → bulk upsert. + * All errors are caught and logged — sync is never fatal. + * + * @param dryRun - If true, fetches and transforms but does not write to DB. + * @returns Sync result with model count and success status. + */ +export async function syncArenaElo(dryRun = false): Promise { + if (syncInProgress) { + return { + success: false, + modelCount: 0, + source: "arena_elo", + error: "Sync already in progress", + }; + } + syncInProgress = true; + try { + // Backup DB before first sync (same pattern as pricingSync) + if (!firstSyncDone && !dryRun) { + backupDbFile("pre-arena-elo-sync"); + firstSyncDone = true; + } + + // Clean up stale entries before writing new ones + if (!dryRun) { + try { + deleteExpiredIntelligence(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[ARENA_ELO_SYNC] Failed to delete expired intelligence: ${message}` + ); + } + } + + const leaderboards = await fetchArenaLeaderboards(); + const entries = transformToModelIntelligence(leaderboards); + + if (!dryRun && entries.length > 0) { + try { + bulkUpsertModelIntelligence(entries); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn( + `[ARENA_ELO_SYNC] Failed to bulk upsert intelligence: ${message}` + ); + return { + success: false, + modelCount: 0, + source: "arena_elo", + error: message, + }; + } + } + + if (!dryRun) { + lastSyncTime = new Date().toISOString(); + lastSyncModelCount = entries.length; + } + + const countLabel = dryRun ? "would sync" : "synced"; + console.log( + `[ARENA_ELO_SYNC] ${countLabel} ${entries.length} model intelligence entries from Arena leaderboards` + ); + + return { + success: true, + modelCount: entries.length, + source: "arena_elo", + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn("[ARENA_ELO_SYNC] Sync failed:", message); + return { + success: false, + modelCount: 0, + source: "arena_elo", + error: message, + }; + } finally { + syncInProgress = false; + } +} + +// ─── Clear synced data ─────────────────────────────────── + +/** + * Clear all synced intelligence data (arena_elo source). + * + * Iterates through all arena_elo entries and deletes them one by one, + * since the DB module provides per-key deletion. This is used by the + * DELETE /api/intelligence/sync endpoint. + */ +export function clearSyncedIntelligence(): void { + const deleted = deleteModelIntelligenceBySource("arena_elo"); + console.log(`[ARENA_ELO_SYNC] Cleared ${deleted} arena_elo intelligence entries`); +} + +// ─── Periodic sync ─────────────────────────────────────── + +/** + * Start periodic Arena ELO sync (non-blocking). + * + * Performs an initial sync immediately, then schedules periodic syncs + * at the configured interval. The timer is unref'd so it won't prevent + * the Node.js process from exiting. + * + * @param intervalMs - Override interval in milliseconds (defaults to env or 86400s). + */ +function startPeriodicSync(intervalMs?: number): void { + if (syncTimer) return; // Already running + + const interval = intervalMs ?? SYNC_INTERVAL_MS; + activeSyncIntervalMs = interval; + console.log( + `[ARENA_ELO_SYNC] Starting periodic sync every ${interval / 1000}s` + ); + + // Initial sync (non-blocking) + syncArenaElo() + .then((result) => { + if (result.success) { + console.log( + `[ARENA_ELO_SYNC] Initial sync complete: ${result.modelCount} model intelligence entries` + ); + } + }) + .catch((err) => { + console.warn( + "[ARENA_ELO_SYNC] Initial sync error:", + err instanceof Error ? err.message : err + ); + }); + + syncTimer = setInterval(() => { + syncArenaElo() + .then((result) => { + if (result.success) { + console.log( + `[ARENA_ELO_SYNC] Periodic sync complete: ${result.modelCount} entries` + ); + } + }) + .catch((err) => { + console.warn( + "[ARENA_ELO_SYNC] Periodic sync error:", + err instanceof Error ? err.message : err + ); + }); + }, interval); + + // Prevent the timer from keeping the process alive + if (syncTimer && typeof syncTimer === "object" && "unref" in syncTimer) { + (syncTimer as { unref?: () => void }).unref?.(); + } +} + +/** + * Stop periodic Arena ELO sync and clean up the timer. + */ +export function stopArenaEloSync(): void { + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + console.log("[ARENA_ELO_SYNC] Periodic sync stopped"); + } +} + +/** + * Get the current Arena ELO sync status. + * + * @returns Sync status including enabled flag, last sync time, model count, + * next scheduled sync time, interval, and active sources. + */ +export function getArenaEloSyncStatus(): SyncStatus { + const enabled = process.env.ARENA_ELO_SYNC_ENABLED === "true"; + return { + enabled, + lastSync: lastSyncTime, + lastSyncModelCount, + nextSync: + syncTimer && lastSyncTime + ? new Date( + new Date(lastSyncTime).getTime() + activeSyncIntervalMs + ).toISOString() + : null, + intervalMs: activeSyncIntervalMs, + sources: ["arena_elo"], + }; +} + +// ─── Init (called from server-init.ts) ─────────────────── + +/** + * Initialize Arena ELO sync if enabled via environment variable. + * + * Reads `ARENA_ELO_SYNC_ENABLED` (default: false). When enabled, + * starts periodic sync with the interval from `ARENA_ELO_SYNC_INTERVAL` + * (default: 86400 seconds / daily). + * + * All errors during initialization or the initial sync are caught and logged + * — initialization is never fatal. + */ +export async function initArenaEloSync(): Promise { + if (process.env.ARENA_ELO_SYNC_ENABLED !== "true") { + console.log( + "[ARENA_ELO_SYNC] Disabled (set ARENA_ELO_SYNC_ENABLED=true to enable)" + ); + return; + } + startPeriodicSync(); +} diff --git a/src/lib/db/migrations/097_model_intelligence.sql b/src/lib/db/migrations/097_model_intelligence.sql new file mode 100644 index 0000000000..686e2cb453 --- /dev/null +++ b/src/lib/db/migrations/097_model_intelligence.sql @@ -0,0 +1,25 @@ +-- 097_model_intelligence.sql +-- Model intelligence scores: per-model task-fitness from arena ELO, models.dev +-- tier rankings, and user overrides. Supports resolution chain (user_override +-- > arena_elo > models_dev_tier) and auto-expiry for stale synced data. + +CREATE TABLE IF NOT EXISTS model_intelligence ( + model TEXT NOT NULL, + source TEXT NOT NULL, -- 'arena_elo' | 'models_dev_tier' | 'user_override' + category TEXT NOT NULL, -- 'coding' | 'review' | 'planning' | 'analysis' | 'debugging' | 'documentation' | 'default' + score REAL NOT NULL, -- [0..1] normalized fitness score + elo_raw INTEGER, -- original ELO if source='arena_elo' + confidence TEXT, -- 'high' | 'medium' | 'low' or CI string like '+10/-8' + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, -- TTL for auto-invalidated scores (NULL = never expires) + PRIMARY KEY (model, source, category) +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS idx_mi_model_category + ON model_intelligence (model, category); + +CREATE INDEX IF NOT EXISTS idx_mi_source + ON model_intelligence (source); + +CREATE INDEX IF NOT EXISTS idx_mi_expires + ON model_intelligence (expires_at) WHERE expires_at IS NOT NULL; diff --git a/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql b/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql new file mode 100644 index 0000000000..92e0570305 --- /dev/null +++ b/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql @@ -0,0 +1,4 @@ +-- Migration 098: Clear semantic_cache after key-isolation fix (#3740) +-- Old signatures were computed without the API key ID dimension, so existing +-- entries would be shared across users. Truncating forces new scoped entries. +DELETE FROM semantic_cache; diff --git a/src/lib/db/modelIntelligence.ts b/src/lib/db/modelIntelligence.ts new file mode 100644 index 0000000000..f5ed208e69 --- /dev/null +++ b/src/lib/db/modelIntelligence.ts @@ -0,0 +1,232 @@ +/** + * modelIntelligence.ts — DB domain module for model task-fitness scores. + * + * Persists per-model intelligence from arena ELO, models.dev tier rankings, + * and user overrides. Resolution chain: user_override → arena_elo → models_dev_tier. + * + * @see Migration 097_model_intelligence.sql + */ + +import { getDbInstance, rowToCamel } from "./core"; + +// ──────────────── Types ──────────────── + +export interface ModelIntelligenceEntry { + model: string; + source: string; + category: string; + score: number; + eloRaw: number | null; + confidence: string | null; + syncedAt: string; + expiresAt: string | null; + votes?: number; + rank?: number; +} + +// ──────────────── Helpers ──────────────── + +function rowToEntry(row: Record): ModelIntelligenceEntry { + const camel = rowToCamel(row) ?? {}; + return { + model: String(camel.model ?? ""), + source: String(camel.source ?? ""), + category: String(camel.category ?? ""), + score: typeof camel.score === "number" ? camel.score : 0, + eloRaw: typeof camel.eloRaw === "number" ? camel.eloRaw : null, + confidence: typeof camel.confidence === "string" ? camel.confidence : null, + syncedAt: String(camel.syncedAt ?? ""), + expiresAt: typeof camel.expiresAt === "string" ? camel.expiresAt : null, + }; +} + +// ──────────────── CRUD ──────────────── + +export function getModelIntelligence(model: string, category: string): ModelIntelligenceEntry | null { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT * FROM model_intelligence + WHERE model = ? AND category = ? + AND source IN ('user_override', 'arena_elo', 'models_dev_tier') + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) + ORDER BY CASE source + WHEN 'user_override' THEN 1 + WHEN 'arena_elo' THEN 2 + WHEN 'models_dev_tier' THEN 3 + END + LIMIT 1` + ) + .get(model, category) as Record | undefined; + + return row ? rowToEntry(row) : null; +} + +export function getModelIntelligenceBySource( + model: string, + source: string, + category: string +): ModelIntelligenceEntry | null { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT * FROM model_intelligence + WHERE model = ? AND source = ? AND category = ? + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))` + ) + .get(model, source, category) as Record | undefined; + + return row ? rowToEntry(row) : null; +} + +export function upsertModelIntelligence(entry: Omit): void { + const db = getDbInstance(); + + db.prepare( + `INSERT OR REPLACE INTO model_intelligence + (model, source, category, score, elo_raw, confidence, synced_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)` + ).run( + entry.model, + entry.source, + entry.category, + entry.score, + entry.eloRaw ?? null, + entry.confidence ?? null, + entry.expiresAt ?? null + ); +} + +export function deleteModelIntelligence(model: string, source: string, category: string): boolean { + const db = getDbInstance(); + const result = db + .prepare( + `DELETE FROM model_intelligence + WHERE model = ? AND source = ? AND category = ?` + ) + .run(model, source, category); + return (result.changes ?? 0) > 0; +} + +export function deleteExpiredIntelligence(source?: string): number { + const db = getDbInstance(); + const conditions = ["expires_at IS NOT NULL", "datetime(expires_at) < datetime('now')"]; + const params: unknown[] = []; + + if (source) { + conditions.push("source = ?"); + params.push(source); + } + + const where = conditions.join(" AND "); + const result = db + .prepare(`DELETE FROM model_intelligence WHERE ${where}`) + .run(...params); + return result.changes ?? 0; +} + +export function deleteModelIntelligenceBySource(source: string): number { + const db = getDbInstance(); + const result = db + .prepare(`DELETE FROM model_intelligence WHERE source = ?`) + .run(source); + return result.changes ?? 0; +} + +export function listModelIntelligence(filters?: { + source?: string; + category?: string; +}): ModelIntelligenceEntry[] { + const db = getDbInstance(); + + const conditions: string[] = []; + const params: unknown[] = []; + + if (filters?.source) { + conditions.push("source = ?"); + params.push(filters.source); + } + if (filters?.category) { + conditions.push("category = ?"); + params.push(filters.category); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = `SELECT * FROM model_intelligence ${where} ORDER BY model ASC, source ASC, category ASC`; + + const rows = db.prepare(sql).all(...params) as Record[]; + return rows.map(rowToEntry); +} + +export function bulkUpsertModelIntelligence(entries: Array>): number { + if (entries.length === 0) return 0; + + const db = getDbInstance(); + const stmt = db.prepare( + `INSERT OR REPLACE INTO model_intelligence + (model, source, category, score, elo_raw, confidence, synced_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)` + ); + + const upsertAll = db.transaction(() => { + let count = 0; + for (const entry of entries) { + stmt.run( + entry.model, + entry.source, + entry.category, + entry.score, + entry.eloRaw ?? null, + entry.confidence ?? null, + entry.expiresAt ?? null + ); + count++; + } + return count; + }); + + return upsertAll(); +} + +export function getResolvedTaskFitness(model: string, category: string): number | null { + const entry = getModelIntelligence(model, category); + return entry ? entry.score : null; +} + +/** + * Write a user_override entry for a model × category combination. + * Used by taskFitness.ts resolution chain as Layer 1 (highest priority). + * + * @param model - Model identifier + * @param category - Task category + * @param score - Fitness score [0..1] + */ +export function setUserFitnessOverrideEntry( + model: string, + category: string, + score: number, +): void { + upsertModelIntelligence({ + model: model.toLowerCase(), + source: "user_override", + category: category.toLowerCase(), + score: Math.max(0, Math.min(1, score)), + eloRaw: null, + confidence: null, + expiresAt: null, + }); +} + +/** + * Delete a user_override entry for a model × category combination. + * + * @param model - Model identifier + * @param category - Task category + * @returns true if an entry was deleted + */ +export function deleteUserFitnessOverrideEntry( + model: string, + category: string, +): boolean { + return deleteModelIntelligence(model.toLowerCase(), "user_override", category.toLowerCase()); +} diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 74d114119a..88d65ef78c 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -694,13 +694,21 @@ export async function deleteProxyById(id: string, options?: { force?: boolean }) return result.changes > 0; } +// A proxy is "alive" for resolution unless it has been explicitly marked dead +// (by an operator or a health check). Conservative: active/null/unknown stay +// usable so a working proxy is never stranded; only known-dead states are +// excluded so a dead proxy stops being handed out (every request would +// otherwise pay the timeout or leak out the host IP). +const PROXY_ALIVE_PREDICATE = + "(p.status IS NULL OR LOWER(p.status) NOT IN ('inactive','error','disabled','dead','down'))"; + export async function resolveProxyForConnectionFromRegistry(connectionId: string) { try { const db = getDbInstance(); const accountAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1" + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(connectionId); if (accountAssignment) { @@ -728,7 +736,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string if (connection?.provider) { const providerAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1" + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(connection.provider); if (providerAssignment) { @@ -752,7 +760,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string const globalAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(); if (globalAssignment) { @@ -789,7 +797,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: if (normalizedScope === "global") { const globalAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(); return globalAssignment ? toRegistryProxyResolution(globalAssignment, "global", null) : null; @@ -800,7 +808,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: const assignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? LIMIT 1" + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(normalizedScope, normalizedScopeId); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index ff428ae949..97079d4a2b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -636,6 +636,23 @@ export type { ApiKeyContextSource } from "./db/apiKeyContextSources"; export { sumUsageTokensThisMonth } from "./db/usageSummary"; +export { + // Model Intelligence (task-fitness scores) + getModelIntelligence, + getModelIntelligenceBySource, + upsertModelIntelligence, + deleteModelIntelligence, + deleteExpiredIntelligence, + deleteModelIntelligenceBySource, + listModelIntelligence, + bulkUpsertModelIntelligence, + getResolvedTaskFitness, + setUserFitnessOverrideEntry, + deleteUserFitnessOverrideEntry, +} from "./db/modelIntelligence"; + +export type { ModelIntelligenceEntry } from "./db/modelIntelligence"; + export { getProviderMetrics, getSearchProviderStats, diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 3a2bd1d1b0..379f5a535d 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -176,6 +176,40 @@ function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSp return undefined; } +function getStaticSpecCanonicalModelId(modelId: string | null, rawModel: string | null) { + const candidates = [modelId, rawModel].filter( + (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0 + ); + for (const candidate of candidates) { + const lower = candidate.toLowerCase(); + for (const [canonical, spec] of Object.entries(MODEL_SPECS)) { + if (canonical === "__default__") continue; + if (canonical.toLowerCase() === lower) return canonical; + if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return canonical; + } + } + return null; +} + +function getSyncedCapabilityForResolved( + provider: string | null, + model: string | null, + rawModel: string | null +): SyncedCapabilities { + if (!provider || !model) return null; + + const direct = getSyncedCapability(provider, model); + if (direct) return direct; + + if (rawModel && rawModel !== model) { + const raw = getSyncedCapability(provider, rawModel); + if (raw) return raw; + } + + const canonical = getStaticSpecCanonicalModelId(model, rawModel); + return canonical && canonical !== model ? getSyncedCapability(provider, canonical) : null; +} + function resolveVisionCapability( spec: ModelSpec | undefined, registryModel: { supportsVision?: boolean } | null, @@ -209,10 +243,11 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo const resolved = resolveCapabilityInput(input); const spec = getStaticSpec(resolved.model, resolved.rawModel); const registryModel = getRegistryModel(resolved.provider, resolved.model); - const synced = - resolved.provider && resolved.model - ? getSyncedCapability(resolved.provider, resolved.model) - : null; + const synced = getSyncedCapabilityForResolved( + resolved.provider, + resolved.model, + resolved.rawModel + ); const modalitiesInput = parseModalities(synced?.modalities_input); const modalitiesOutput = parseModalities(synced?.modalities_output); @@ -283,9 +318,7 @@ export function getResolvedModelCapabilities(input: CapabilityInput): ResolvedMo modalitiesOutput, interleavedField: synced?.interleaved_field ?? - (typeof registryModel?.interleavedField === "string" - ? registryModel.interleavedField - : null), + (typeof registryModel?.interleavedField === "string" ? registryModel.interleavedField : null), }; } diff --git a/src/lib/providerModels/geminiModelsParser.ts b/src/lib/providerModels/geminiModelsParser.ts new file mode 100644 index 0000000000..cf40da47d2 --- /dev/null +++ b/src/lib/providerModels/geminiModelsParser.ts @@ -0,0 +1,68 @@ +/** + * Parses the Google Generative Language `v1beta/models` listing into discovery models. + * + * Each model's `supportedGenerationMethods` is mapped to OmniRoute endpoints: + * - generateContent / generateAnswer → "chat" + * - predict / predictLongRunning → "images" (Imagen models) + * - embedContent → "embeddings" + * - bidiGenerateContent → "audio" + * + * This is shared by the `gemini` discovery config and the `vertex` discovery branch: Vertex AI + * Express keys (and Service Account JSON via a minted OAuth token) list models from the same + * endpoint, so image models (imagen-*, gemini-*-image) surface dynamically instead of being + * limited to the small static registry list. + */ +const METHOD_TO_ENDPOINT: Record = { + generateContent: "chat", + embedContent: "embeddings", + predict: "images", + predictLongRunning: "images", + bidiGenerateContent: "audio", + generateAnswer: "chat", +}; + +const IGNORED_METHODS = new Set([ + "countTokens", + "countTextTokens", + "createCachedContent", + "batchGenerateContent", + "asyncBatchEmbedContent", +]); + +export interface GeminiDiscoveryModel { + id: string; + name: string; + supportedEndpoints: string[]; + inputTokenLimit?: number; + outputTokenLimit?: number; + description?: string; + supportsThinking?: boolean; + [key: string]: unknown; +} + +export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] { + return (data?.models || []).map((m: Record) => { + const methods: string[] = Array.isArray(m.supportedGenerationMethods) + ? (m.supportedGenerationMethods as string[]) + : []; + const endpoints = [ + ...new Set( + methods + .filter((method) => !IGNORED_METHODS.has(method)) + .map((method) => METHOD_TO_ENDPOINT[method] || "chat") + ), + ]; + if (endpoints.length === 0) endpoints.push("chat"); + + return { + ...m, + id: ((m.name as string) || (m.id as string) || "").replace(/^models\//, ""), + name: (m.displayName as string) || ((m.name as string) || "").replace(/^models\//, ""), + supportedEndpoints: endpoints, + ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), + ...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}), + ...(typeof m.description === "string" ? { description: m.description } : {}), + ...(m.thinking === true ? { supportsThinking: true } : {}), + } as GeminiDiscoveryModel; + }); +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 61c1aa1d40..c6387ec805 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -3954,8 +3954,13 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi }, vertex: async ({ apiKey }: any) => { try { - const { parseSAFromApiKey, getAccessToken } = + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); + // Express-mode API keys are opaque strings sent directly as the ?key= query param — there is + // no JWT to mint, so accept any non-empty Express key (the live chat/media call validates it). + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } const sa = parseSAFromApiKey(apiKey); // Validates credentials by successfully successfully exchanging them for a JWT from Google Identity await getAccessToken(sa); @@ -3966,8 +3971,11 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi }, "vertex-partner": async ({ apiKey }: any) => { try { - const { parseSAFromApiKey, getAccessToken } = + const { parseSAFromApiKey, getAccessToken, isExpressApiKey } = await import("@omniroute/open-sse/executors/vertex.ts"); + if (isExpressApiKey(apiKey)) { + return { valid: true, error: null }; + } const sa = parseSAFromApiKey(apiKey); await getAccessToken(sa); return { valid: true, error: null }; diff --git a/src/lib/providers/webCookieAuth.ts b/src/lib/providers/webCookieAuth.ts index 4e9263c5fc..a7e204efdf 100644 --- a/src/lib/providers/webCookieAuth.ts +++ b/src/lib/providers/webCookieAuth.ts @@ -63,6 +63,41 @@ export function buildGrokCookieHeader(rawValue: string): string { return parts.join("; "); } +/** + * Build the `Cookie` header value for chat.qwen.ai (Qwen Web / Tongyi). + * + * The Qwen v2 API sits behind Alibaba's "baxia" WAF, which requires the full + * browser cookie jar from a real logged-in session (`cna`, `ssxmod_itna`, + * `ssxmod_itna2`, `token`, `_bl_uid`, `x-ap`, ...). Unlike grok we cannot + * reconstruct a canonical subset, so we forward the whole pasted/captured blob + * verbatim (minus a leading `Cookie:`/`bearer ` prefix). + * + * A bare token (no cookie pairs, i.e. no `=`) yields "" — there is no jar to + * replay, only a bearer credential (handled by {@link extractQwenToken}). + */ +export function buildQwenCookieHeader(rawValue: string): string { + const trimmed = stripCookieInputPrefix(rawValue); + if (!trimmed || !trimmed.includes("=")) return ""; + return trimmed; +} + +/** + * Extract the Qwen bearer token from whatever the user pasted/captured. + * + * Qwen stores its auth JWT in localStorage as `token`, and chat.qwen.ai also + * mirrors it into a `token` cookie. So: + * - full cookie blob with `token=...` → that value + * - bare token (no cookie pairs) → the value itself + * - cookie blob without a `token` pair → "" (token must come from elsewhere) + */ +export function extractQwenToken(rawValue: string): string { + const trimmed = stripCookieInputPrefix(rawValue); + if (!trimmed) return ""; + if (!trimmed.includes("=")) return trimmed; + const match = trimmed.match(/(?:^|;\s*)token=([^;\s]+)/); + return match ? match[1] : ""; +} + export function normalizeSessionCookieHeaders( rawValues: Array, defaultCookieName: string diff --git a/src/lib/proxyEgress.ts b/src/lib/proxyEgress.ts new file mode 100644 index 0000000000..aa2f3a4ea1 --- /dev/null +++ b/src/lib/proxyEgress.ts @@ -0,0 +1,388 @@ +/** + * Proxy Egress IP visibility. + * + * The proxy logs already capture the INBOUND client IP (x-forwarded-for), but + * NOT the OUTBOUND/egress IP — the address the upstream actually sees. For + * rotating providers (codex/openai) this is critical: when several accounts + * egress through the SAME IP at high volume, the provider flags it as anomaly + * and revokes the tokens ("Your authentication token has been invalidated"). + * + * This module resolves the real egress IP (via an echo-IP service through the + * resolved proxy/dispatcher) and detects same-rotation-group accounts sharing + * an egress IP, so the operator can confirm exactly which IP each account is + * entering and leaving by. + */ +import { request as undiciRequest } from "undici"; +import { createProxyDispatcher, proxyConfigToUrl } from "@omniroute/open-sse/utils/proxyDispatcher.ts"; +import { rotationGroupFor } from "@omniroute/open-sse/services/refreshSerializer.ts"; + +const EGRESS_ECHO_URL = "https://api64.ipify.org?format=json"; +const EGRESS_PROBE_TIMEOUT_MS = 6000; +const EGRESS_CACHE_TTL_MS = 5 * 60 * 1000; + +export interface EgressProbeResult { + ip: string | null; + latencyMs: number; + error?: string; +} + +export type EgressProbe = (proxyUrl: string | null) => Promise; + +const egressCache = new Map(); + +async function defaultEgressProbe(proxyUrl: string | null): Promise { + const start = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), EGRESS_PROBE_TIMEOUT_MS); + try { + const dispatcher = proxyUrl ? createProxyDispatcher(proxyUrl) : undefined; + const res = await undiciRequest(EGRESS_ECHO_URL, { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: EGRESS_PROBE_TIMEOUT_MS, + bodyTimeout: EGRESS_PROBE_TIMEOUT_MS, + }); + const text = await res.body.text(); + let ip: string | null = null; + try { + ip = (JSON.parse(text) as { ip?: string }).ip ?? null; + } catch { + // non-JSON body — leave ip null + } + return { ip, latencyMs: Date.now() - start }; + } catch (error) { + return { + ip: null, + latencyMs: Date.now() - start, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +let probe: EgressProbe = defaultEgressProbe; + +/** Test seam: override the network probe. */ +export function _setEgressProbeForTests(fn: EgressProbe | null): void { + probe = fn ?? defaultEgressProbe; +} + +export function clearEgressCache(): void { + egressCache.clear(); +} + +/** + * Synchronous read of the cached egress IP for a proxy URL (null = direct). + * Non-blocking — used by the request hot path to log the egress IP without an + * echo-IP round-trip. Returns null if not yet probed. + */ +export function getCachedEgressIp(proxyUrl: string | null): string | null { + const cached = egressCache.get(proxyUrl ?? "__direct__"); + if (!cached) return null; + if (Date.now() - cached.at >= EGRESS_CACHE_TTL_MS) return null; + return cached.ip; +} + +const warmingInFlight = new Set(); + +/** + * Fire-and-forget: populate the egress cache for a proxy URL in the background + * so subsequent proxy log lines carry the real egress IP. Deduped per URL. + */ +export function warmEgressIp(proxyUrl: string | null): void { + const key = proxyUrl ?? "__direct__"; + if (warmingInFlight.has(key) || getCachedEgressIp(proxyUrl) !== null) return; + warmingInFlight.add(key); + void resolveEgressIp(proxyUrl) + .catch(() => undefined) + .finally(() => warmingInFlight.delete(key)); +} + +/** + * Resolve the egress IP for a given proxy URL (null = direct/host IP). + * Cached per proxyUrl to avoid an echo-IP round-trip on every call. + */ +export async function resolveEgressIp( + proxyUrl: string | null, + opts: { cacheTtlMs?: number; force?: boolean } = {} +): Promise { + const key = proxyUrl ?? "__direct__"; + const ttl = opts.cacheTtlMs ?? EGRESS_CACHE_TTL_MS; + const cached = egressCache.get(key); + if (!opts.force && cached && Date.now() - cached.at < ttl) { + return { ip: cached.ip, latencyMs: 0, cached: true }; + } + const result = await probe(proxyUrl); + egressCache.set(key, { ip: result.ip, at: Date.now() }); + return { ...result, cached: false }; +} + +export interface ConnectionEgress { + connectionId: string; + provider: string; + account: string | null; + proxyLevel: string; + proxyHost: string | null; + egressIp: string | null; + error?: string; +} + +export interface EgressSharingWarning { + egressIp: string; + rotationGroup: string; + connections: string[]; // connectionId/account labels sharing this IP within one rotation group +} + +export interface EgressDiagnostic { + connections: ConnectionEgress[]; + byEgressIp: Record; + sharedWithinRotationGroup: EgressSharingWarning[]; +} + +/** + * PURE: group egress results by IP and flag IPs shared by ≥2 accounts of the + * SAME rotation group (codex+openai share one Auth0 family — the exact + * condition that triggers anomaly revocation). Direct/unknown IPs are reported + * but only same-group sharing is a warning. + */ +export function analyzeEgressSharing(connections: ConnectionEgress[]): { + byEgressIp: Record; + sharedWithinRotationGroup: EgressSharingWarning[]; +} { + const byEgressIp: Record = {}; + // ip -> rotationGroup -> labels + const byIpGroup = new Map>(); + + for (const c of connections) { + if (!c.egressIp) continue; + const label = c.account || c.connectionId; + (byEgressIp[c.egressIp] ??= []).push(label); + + const group = rotationGroupFor(c.provider) || `provider:${c.provider}`; + let groups = byIpGroup.get(c.egressIp); + if (!groups) { + groups = new Map(); + byIpGroup.set(c.egressIp, groups); + } + const list = groups.get(group) ?? []; + list.push(label); + groups.set(group, list); + } + + const sharedWithinRotationGroup: EgressSharingWarning[] = []; + for (const [egressIp, groups] of byIpGroup) { + for (const [rotationGroup, labels] of groups) { + if (labels.length >= 2) { + sharedWithinRotationGroup.push({ egressIp, rotationGroup, connections: labels }); + } + } + } + + return { byEgressIp, sharedWithinRotationGroup }; +} + +/** + * Diagnose egress IPs for every OAuth connection: resolve each connection's + * proxy, probe the real egress IP, and flag same-rotation-group IP sharing. + */ +export async function diagnoseAllEgressIps(deps?: { + getConnections?: () => Promise< + Array<{ id: string; provider: string; name?: string; email?: string; authType?: string }> + >; + resolveProxy?: ( + connectionId: string + ) => Promise<{ proxy?: unknown; level?: string } | null>; +}): Promise { + const getConnections = + deps?.getConnections ?? + (async () => { + const { getProviderConnections } = await import("./localDb"); + return (await getProviderConnections({ authType: "oauth" })) as Array<{ + id: string; + provider: string; + name?: string; + email?: string; + }>; + }); + const resolveProxy = + deps?.resolveProxy ?? + (async (connectionId: string) => { + const { resolveProxyForConnection } = await import("./db/settings"); + return resolveProxyForConnection(connectionId); + }); + + const conns = await getConnections(); + const results: ConnectionEgress[] = []; + + for (const c of conns) { + const resolved = await resolveProxy(c.id); + const proxyObj = (resolved?.proxy ?? null) as { + type?: string; + host?: string; + port?: number | string; + } | null; + const proxyUrl = proxyObj ? proxyConfigToUrl(proxyObj) : null; + const egress = await resolveEgressIp(proxyUrl); + results.push({ + connectionId: c.id, + provider: c.provider, + account: c.email || c.name || c.id.slice(0, 8), + proxyLevel: resolved?.level || "direct", + proxyHost: proxyObj?.host ?? null, + egressIp: egress.ip, + ...(egress.error ? { error: egress.error } : {}), + }); + } + + const { byEgressIp, sharedWithinRotationGroup } = analyzeEgressSharing(results); + return { connections: results, byEgressIp, sharedWithinRotationGroup }; +} + +export interface ProxyValidationResult { + proxyId: string; + host: string; + port: number | string; + alive: boolean; + egressIp: string | null; + latencyMs: number; + previousStatus: string | null; + newStatus: "active" | "error"; +} + +/** + * Validate every proxy in the registry by probing its real egress IP, and + * persist the result to `proxy_registry.status` (active/error). Combined with + * PROXY_ALIVE_PREDICATE in resolution, a dead proxy is automatically taken out + * of rotation — fixing the "all proxies marked active but actually dead" state + * that left codex accounts falling back to the shared host /64 IP. + * + * Deps are injectable for tests. + */ +export async function validateProxyPool(deps?: { + listProxies?: () => Promise< + Array<{ id: string; type: string; host: string; port: number | string; username?: string | null; password?: string | null; status?: string | null }> + >; + markStatus?: (id: string, status: string, meta: { latencyMs: number; egressIp: string | null }) => Promise; +}): Promise { + const listProxies = + deps?.listProxies ?? + (async () => { + const { listProxies: real } = await import("./db/proxies"); + return (await real({ includeSecrets: true })) as Array<{ + id: string; + type: string; + host: string; + port: number | string; + username?: string | null; + password?: string | null; + status?: string | null; + }>; + }); + const markStatus = + deps?.markStatus ?? + (async (id: string, status: string) => { + const { updateProxy } = await import("./db/proxies"); + await updateProxy(id, { status }); + }); + + const proxies = await listProxies(); + const report: ProxyValidationResult[] = []; + + for (const p of proxies) { + const url = proxyConfigToUrl({ + type: p.type, + host: p.host, + port: p.port, + username: p.username ?? undefined, + password: p.password ?? undefined, + }); + const probe = await resolveEgressIp(url, { force: true }); + const alive = !!probe.ip && !probe.error; + const newStatus: "active" | "error" = alive ? "active" : "error"; + await markStatus(p.id, newStatus, { latencyMs: probe.latencyMs, egressIp: probe.ip }); + report.push({ + proxyId: p.id, + host: p.host, + port: p.port, + alive, + egressIp: probe.ip, + latencyMs: probe.latencyMs, + previousStatus: p.status ?? null, + newStatus, + }); + } + + return report; +} + +export interface DistributionPlan { + assignments: Array<{ connectionId: string; account: string; proxyId: string }>; + unassigned: Array<{ connectionId: string; account: string }>; + sharingRisk: boolean; + note: string; +} + +/** + * PURE: plan a 1-proxy-per-connection assignment so no two accounts of the same + * rotation group share an egress IP (the codex anomaly trigger). Default is + * strict 1:1 — extras are left UNASSIGNED (better unrouted than sharing an IP). + * allowSharing=true round-robins instead, flagging sharingRisk. + */ +export function planProxyDistribution( + connections: Array<{ id: string; account?: string }>, + liveProxyIds: string[], + opts: { allowSharing?: boolean } = {} +): DistributionPlan { + const assignments: DistributionPlan["assignments"] = []; + const unassigned: DistributionPlan["unassigned"] = []; + let sharingRisk = false; + + connections.forEach((c, i) => { + const account = c.account || c.id.slice(0, 8); + if (liveProxyIds.length === 0) { + unassigned.push({ connectionId: c.id, account }); + return; + } + if (opts.allowSharing) { + assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i % liveProxyIds.length] }); + } else if (i < liveProxyIds.length) { + assignments.push({ connectionId: c.id, account, proxyId: liveProxyIds[i] }); + } else { + unassigned.push({ connectionId: c.id, account }); + } + }); + + if (opts.allowSharing && liveProxyIds.length < connections.length) sharingRisk = true; + + const note = + liveProxyIds.length === 0 + ? "No live proxies available — add working proxies before distributing." + : liveProxyIds.length < connections.length && !opts.allowSharing + ? `Only ${liveProxyIds.length} live proxies for ${connections.length} accounts — ${unassigned.length} left unassigned (avoid shared-IP anomaly).` + : "1 distinct proxy per account."; + + return { assignments, unassigned, sharingRisk, note }; +} + +/** + * Apply a distribution plan: assign each proxy to its connection (account scope). + */ +export async function applyProxyDistribution( + plan: DistributionPlan, + deps?: { assign?: (connectionId: string, proxyId: string) => Promise } +): Promise<{ applied: number }> { + const assign = + deps?.assign ?? + (async (connectionId: string, proxyId: string) => { + const { assignProxyToScope } = await import("./db/proxies"); + await assignProxyToScope("account", connectionId, proxyId); + }); + let applied = 0; + for (const a of plan.assignments) { + await assign(a.connectionId, a.proxyId); + applied++; + } + return { applied }; +} diff --git a/src/lib/proxyLogger.ts b/src/lib/proxyLogger.ts index 2fd45e3abc..19cd565f14 100644 --- a/src/lib/proxyLogger.ts +++ b/src/lib/proxyLogger.ts @@ -29,6 +29,10 @@ interface ProxyLogEntry { provider: string | null; targetUrl: string | null; clientIp: string | null; + /** Outbound/egress IP the upstream actually saw (null until probed). The + * historical clientIp is the INBOUND IP (x-forwarded-for); egressIp answers + * "by which IP is this account leaving" — critical for rotating providers. */ + egressIp: string | null; latencyMs: number; error: string | null; connectionId: string | null; @@ -77,6 +81,7 @@ function loadFromDb() { provider: row.provider || null, targetUrl: row.target_url || null, clientIp: row.public_ip || null, + egressIp: row.egress_ip || null, latencyMs: row.latency_ms || 0, error: row.error || null, connectionId: row.connection_id || null, @@ -109,6 +114,7 @@ export function logProxyEvent(entry: ProxyLogInput) { provider: entry.provider || null, targetUrl: entry.targetUrl || null, clientIp: entry.clientIp ?? entry.publicIp ?? null, + egressIp: entry.egressIp ?? null, latencyMs: entry.latencyMs || 0, error: entry.error || null, connectionId: entry.connectionId || null, @@ -117,6 +123,16 @@ export function logProxyEvent(entry: ProxyLogInput) { tlsFingerprint: entry.tlsFingerprint || false, }; + // Structured egress line so the operator can confirm, in the proxy logs, which + // IP each account is entering (clientIp) and leaving (egressIp) by. + if (log.proxy || log.egressIp) { + console.log( + `[ProxyEgress] ${log.provider || "-"}/${log.account || "-"} ` + + `in=${log.clientIp || "?"} out=${log.egressIp || "?"} ` + + `proxy=${log.level}${log.proxy ? `:${log.proxy.host}` : ""} status=${log.status}` + ); + } + // 1. In-memory ring buffer (newest first) proxyLogs.unshift(log); if (proxyLogs.length > MAX_IN_MEMORY_ENTRIES) { diff --git a/src/lib/resilience/modelLockoutSettings.ts b/src/lib/resilience/modelLockoutSettings.ts new file mode 100644 index 0000000000..463881e28b --- /dev/null +++ b/src/lib/resilience/modelLockoutSettings.ts @@ -0,0 +1,95 @@ +export interface ModelLockoutSettings { + enabled: boolean; + errorCodes: number[]; + baseCooldownMs: number; + maxCooldownMs: number; + maxBackoffSteps: number; + useExponentialBackoff: boolean; +} + +export const DEFAULT_MODEL_LOCKOUT_SETTINGS: ModelLockoutSettings = { + enabled: false, + errorCodes: [403, 404, 429, 502, 503, 504], + baseCooldownMs: 120_000, + maxCooldownMs: 1_800_000, + maxBackoffSteps: 10, + useExponentialBackoff: true, +}; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toInteger( + value: unknown, + fallback: number, + options: { min?: number; max?: number } = {} +): number { + const min = options.min ?? 0; + const max = options.max ?? Number.MAX_SAFE_INTEGER; + const parsed = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value) + : fallback; + return Number.isFinite(parsed) ? Math.max(min, Math.min(max, Math.trunc(parsed))) : fallback; +} + +function toBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value === "true" || value === "1"; + return fallback; +} + +function toNumberArray(value: unknown, fallback: number[]): number[] { + if (Array.isArray(value)) { + const nums = value + .map((v) => (typeof v === "number" ? v : Number(v))) + .filter((n) => Number.isFinite(n) && n >= 100 && n <= 599); + return nums.length > 0 ? nums : fallback; + } + return fallback; +} + +export function resolveModelLockoutSettings( + settings: Record | null | undefined +): ModelLockoutSettings { + const record = asRecord(settings); + const raw = asRecord(record.modelLockout); + const isTest = + process.env.NODE_ENV === "test" || + process.execArgv.includes("--test") || + process.argv.some((arg) => typeof arg === "string" && arg.includes("test")); + + const baseCooldownMs = toInteger(raw.baseCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.baseCooldownMs, { + min: isTest ? 0 : 5_000, + max: 600_000, + }); + const maxCooldownMs = Math.max( + toInteger(raw.maxCooldownMs, DEFAULT_MODEL_LOCKOUT_SETTINGS.maxCooldownMs, { + min: isTest ? 0 : 5_000, + max: 3_600_000, + }), + baseCooldownMs // cap must be >= base or exponential backoff is meaningless + ); + + return { + enabled: toBoolean(raw.enabled, DEFAULT_MODEL_LOCKOUT_SETTINGS.enabled), + errorCodes: toNumberArray(raw.errorCodes, DEFAULT_MODEL_LOCKOUT_SETTINGS.errorCodes), + baseCooldownMs, + maxCooldownMs, + maxBackoffSteps: toInteger( + raw.maxBackoffSteps, + DEFAULT_MODEL_LOCKOUT_SETTINGS.maxBackoffSteps, + { min: 0, max: 20 } + ), + useExponentialBackoff: toBoolean( + raw.useExponentialBackoff, + DEFAULT_MODEL_LOCKOUT_SETTINGS.useExponentialBackoff + ), + }; +} diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index 1fbc6a6548..b8ddea8a07 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -113,14 +113,16 @@ function getMemoryCache() { * @param {Array} messages - Normalized messages array * @param {number} temperature * @param {number} topP + * @param {string} [apiKeyId] - API key ID for per-key isolation (prevents cross-user cache hits) * @returns {string} hex signature */ -export function generateSignature(model, conversation, temperature = 0, topP = 1) { +export function generateSignature(model, conversation, temperature = 0, topP = 1, apiKeyId?: string) { const payload = JSON.stringify({ model, messages: normalizeConversation(conversation), temperature, top_p: topP, + ...(apiKeyId ? { api_key_id: apiKeyId } : {}), }); return crypto.createHash("sha256").update(payload).digest("hex"); } diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index 1166238f04..305d9ec6e5 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -76,10 +76,38 @@ function getEffectiveTokenExpiryMs(conn: any): number { return Number.isFinite(expiryMs) ? expiryMs : 0; } +// ── Refresh circuit breaker ─────────────────────────────────────────────── +// A refresh that returns null (network blip, dead proxy, unclassified error) +// leaves the connection active, so the next 60s sweep retries immediately — +// the production refresh loop (claude/aa5dd5cf 1352×, kimi 270×). We track +// consecutive failures and back off exponentially so a stuck connection stops +// hammering the upstream (and stops flooding the logs) instead of looping. +const REFRESH_CIRCUIT_BASE_MIN = 5; +const REFRESH_CIRCUIT_MAX_MIN = 240; // cap at 4h + +export function getRefreshBackoffUntil(streak: number, now: string): string { + const steps = Math.max(0, streak - 1); + const backoffMin = Math.min(REFRESH_CIRCUIT_BASE_MIN * 2 ** steps, REFRESH_CIRCUIT_MAX_MIN); + return new Date(new Date(now).getTime() + backoffMin * 60 * 1000).toISOString(); +} + +export function isInRefreshBackoff(conn: any, nowMs: number): boolean { + const until = conn?.providerSpecificData?.refreshCircuit?.until; + if (typeof until !== "string") return false; + const untilMs = new Date(until).getTime(); + return Number.isFinite(untilMs) && untilMs > nowMs; +} + export function buildRefreshFailureUpdate(conn: any, now: string) { const wasExpired = conn.testStatus === "expired"; const retryCount = (conn.expiredRetryCount ?? 0) + (wasExpired ? 1 : 0); + // Circuit breaker: increment the consecutive-failure streak and set an + // exponential backoff window so the next sweep skips this connection instead + // of retrying every 60s. Cleared by a successful refresh (clearRefreshCircuit). + const prevStreak = conn.providerSpecificData?.refreshCircuit?.streak ?? 0; + const streak = prevStreak + 1; + return { lastHealthCheckAt: now, // A failed background refresh should not evict otherwise healthy accounts @@ -91,10 +119,28 @@ export function buildRefreshFailureUpdate(conn: any, now: string) { lastErrorType: "token_refresh_failed", lastErrorSource: "oauth", errorCode: "refresh_failed", + providerSpecificData: { + ...(conn.providerSpecificData || {}), + refreshCircuit: { streak, until: getRefreshBackoffUntil(streak, now), lastFailAt: now }, + }, ...(wasExpired ? { expiredRetryCount: retryCount, expiredRetryAt: now } : {}), }; } +/** + * Strip the refresh circuit breaker state from providerSpecificData after a + * successful refresh, so the streak/backoff resets cleanly. + */ +export function clearRefreshCircuit( + providerSpecificData: Record | null | undefined +): Record | undefined { + if (!providerSpecificData || typeof providerSpecificData !== "object") return undefined; + if (!("refreshCircuit" in providerSpecificData)) return undefined; + const next = { ...providerSpecificData }; + delete next.refreshCircuit; + return next; +} + function isEnvFlagEnabled(name: string): boolean { const value = process.env[name]; if (!value) return false; @@ -355,6 +401,14 @@ export async function checkConnection(conn) { if (!isAboutToExpire && !shouldRefreshByInterval) return; + // Circuit breaker: if recent refreshes for this connection failed, wait out + // the exponential backoff window instead of retrying every 60s tick. This is + // what stops the refresh loop when getAccessToken keeps returning null + // (dead proxy / network blip / unclassified upstream error). + if (isInRefreshBackoff(conn, Date.now())) { + return; + } + const reason = isAboutToExpire ? "token expiring soon" : `interval: ${intervalMin}min`; log(`${LOG_PREFIX} Refreshing ${conn.provider}/${getConnectionLogLabel(conn)} (${reason})`); @@ -427,11 +481,17 @@ export async function checkConnection(conn) { updateData.expiresAt = expiresAt; updateData.tokenExpiresAt = expiresAt; } - if (refreshResult.providerSpecificData) { - updateData.providerSpecificData = { - ...(conn.providerSpecificData || {}), - ...refreshResult.providerSpecificData, - }; + // Merge new providerSpecificData and ALWAYS clear the refresh circuit + // breaker streak on a successful refresh. + const mergedProviderData = { + ...(conn.providerSpecificData || {}), + ...(refreshResult.providerSpecificData || {}), + }; + const clearedProviderData = clearRefreshCircuit(mergedProviderData); + if (clearedProviderData !== undefined) { + updateData.providerSpecificData = clearedProviderData; + } else if (refreshResult.providerSpecificData) { + updateData.providerSpecificData = mergedProviderData; } await updateProviderConnection(conn.id, updateData); persistedResult = refreshResult; diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 2ae6da88d2..b14997ed04 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -70,6 +70,8 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ "nanogpt", "deepseek", "xiaomi-mimo", + "vertex", + "vertex-partner", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/lib/usage/usageStats.ts b/src/lib/usage/usageStats.ts index 514fe4b8a2..6c10405ec6 100644 --- a/src/lib/usage/usageStats.ts +++ b/src/lib/usage/usageStats.ts @@ -219,6 +219,65 @@ export function getMonthlyProviderTokensForConnection( return Math.max(0, Number(row?.total ?? 0)); } +/** + * Total USD spend OmniRoute has recorded for a single provider connection, across all time + * (i.e. "since the account was added" — usage_history rows only exist from first use onward). + * + * Sums per-model token usage from `usage_history` for the connection and prices each model via the + * backend pricing table (`calculateCost`). Scoped to the given `provider` and to **successful** + * requests (`success = 1`) so failed/errored calls and any cross-provider rows can't inflate the + * total. Only reflects traffic that went THROUGH OmniRoute, not the provider's own dashboard. Used + * to surface a "$X used since added" figure for providers that expose no native usage/quota API + * (e.g. Vertex AI). + */ +export async function getConnectionSpendUsdSinceAdded( + provider: string, + connectionId: string +): Promise<{ costUsd: number; requests: number }> { + if (!provider || !connectionId) return { costUsd: 0, requests: 0 }; + + const db = getDbInstance(); + const rows = db + .prepare( + `SELECT model, + COALESCE(SUM(tokens_input), 0) AS input, + COALESCE(SUM(tokens_output), 0) AS output, + COALESCE(SUM(tokens_cache_read), 0) AS cacheRead, + COALESCE(SUM(tokens_cache_creation), 0) AS cacheCreation, + COALESCE(SUM(tokens_reasoning), 0) AS reasoning, + COUNT(*) AS requests + FROM usage_history + WHERE connection_id = ? AND provider = ? AND success = 1 + GROUP BY model` + ) + .all(connectionId, provider) as Array<{ + model?: string; + input?: number; + output?: number; + cacheRead?: number; + cacheCreation?: number; + reasoning?: number; + requests?: number; + }>; + + let costUsd = 0; + let requests = 0; + for (const row of rows) { + requests += Math.max(0, Number(row.requests ?? 0)); + const model = typeof row.model === "string" ? row.model : ""; + const tokens = { + input: Number(row.input ?? 0), + output: Number(row.output ?? 0), + cacheRead: Number(row.cacheRead ?? 0), + cacheCreation: Number(row.cacheCreation ?? 0), + reasoning: Number(row.reasoning ?? 0), + }; + costUsd += await calculateCost(provider, model, tokens, { provider, model }); + } + + return { costUsd: Math.max(0, costUsd), requests }; +} + /** * Get aggregated usage stats. * Uses UNION of recent raw data and older aggregated data when aggregation is enabled. diff --git a/src/server-init.ts b/src/server-init.ts index 5083e7d64c..bf04fa3063 100644 --- a/src/server-init.ts +++ b/src/server-init.ts @@ -131,6 +131,16 @@ async function startServer() { startupLog.warn({ error: getErrorMessage(err) }, "Pricing sync could not initialize"); } } + + // Arena ELO sync: opt-in model intelligence from leaderboard data (non-blocking, never fatal) + if (process.env.ARENA_ELO_SYNC_ENABLED === "true") { + try { + const { initArenaEloSync } = await import("./lib/arenaEloSync"); + await initArenaEloSync(); + } catch (err) { + startupLog.warn({ error: getErrorMessage(err) }, "Arena ELO sync could not initialize"); + } + } } // Start the server initialization diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index 1ec9cb2922..ce5f0f3e0a 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -12,7 +12,10 @@ const ALL_PROXY_TYPES = [ ]; // Build-time fallback (static deploys). The live value comes from GET /api/settings/proxies // (server ENABLE_SOCKS5_PROXY) so a runtime Docker env is honoured — #3508. -const BUILD_TIME_SOCKS5 = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true"; +// Default ON (opt-out) to match the server: only an explicit falsey value hides SOCKS5. +const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes( + (process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase() +); export function buildProxyTypes(socks5Enabled: boolean) { return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5"); } diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 9576b0d1c3..5afc5bdf79 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -419,7 +419,7 @@ export default function Sidebar({ href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-3 focus:bg-primary focus:text-white focus:rounded-md focus:m-2" > - Skip to content + {t("skipToContent")} {(onToggleCollapse || !isMacElectron) && ( @@ -442,9 +442,9 @@ export default function Sidebar({ {onToggleCollapse && (
-

Server Disconnected

-

- The proxy server has been stopped or is restarting. -

+

{t("serverDisconnected")}

+

{t("serverDisconnectedMsg")}

diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 38e8d672c8..184ffbbef8 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -3127,6 +3127,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "nanogpt", "deepseek", "xiaomi-mimo", + "vertex", + "vertex-partner", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 4f9bfec84c..0914d94742 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -158,11 +158,11 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = { storageKeys: ["cookie", "session"], }, "qwen-web": { - kind: "token", - credentialName: "token", - placeholder: "Paste your Qwen token from chat.qwen.ai (Local Storage → token)", - acceptsFullCookieHeader: false, - storageKeys: ["token", "tongyi_sso_ticket"], + kind: "cookie", + credentialName: "full Cookie header (must include cna, ssxmod_itna, token)", + placeholder: "cna=...; token=...; ssxmod_itna=...; ssxmod_itna2=... (full Cookie header from chat.qwen.ai)", + acceptsFullCookieHeader: true, + storageKeys: ["cookie", "token", "ssxmod_itna", "ssxmod_itna2", "cna", "tongyi_sso_ticket"], }, "duckduckgo-web": { kind: "cookie", diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index b5b9ec81eb..e855296431 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -643,6 +643,7 @@ const comboRuntimeConfigSchema = z maxMessagesForSummary: z.coerce.number().int().min(5).max(100).optional(), maxComboDepth: z.coerce.number().int().min(1).max(10).optional(), trackMetrics: z.boolean().optional(), + reasoningTokenBufferEnabled: z.boolean().optional(), compressionMode: compressionModeSchema.optional(), failoverBeforeRetry: z.boolean().optional(), maxSetRetries: z.coerce.number().int().min(0).max(10).optional(), @@ -1232,6 +1233,12 @@ export const pricingSyncRequestSchema = z }) .strict(); +export const intelligenceSyncRequestSchema = z + .object({ + dryRun: z.boolean().optional(), + }) + .strict(); + const taskRoutingModelMapSchema = z .object({ coding: z.string().max(200).optional(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 40ae9f3268..81acbcd15d 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -304,6 +304,32 @@ export const updateSettingsSchema = z.object({ cliproxyapi_fallback_codes: z.string().max(200).optional(), // CLIProxyAPI model mapping (Record) cliproxyapi_model_mapping: z.record(z.string(), z.string()).optional(), + // Model lockout settings + modelLockout: z + .object({ + enabled: z.boolean().optional(), + errorCodes: z.array(z.number().int().min(100).max(599)).min(0).max(20).optional(), + baseCooldownMs: z + .number() + .int() + .min(5000, "Must be at least 5,000ms") + .max(600000, "Must be at most 600,000ms (10 min)") + .optional(), + maxCooldownMs: z + .number() + .int() + .min(5000, "Must be at least 5,000ms") + .max(3600000, "Must be at most 3,600,000ms (1 h)") + .optional(), + maxBackoffSteps: z + .number() + .int() + .min(0, "Must be at least 0") + .max(20, "Must be at most 20") + .optional(), + useExponentialBackoff: z.boolean().optional(), + }) + .optional(), }); export const databaseSettingsSchema = z.object( diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 2e5efb681a..e8bb5317a8 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -40,6 +40,7 @@ import { getCombosCacheVersion, getSessionAccountAffinity, } from "@/lib/localDb"; +import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; import { ensureOpenAIStoreSessionFallback, isOpenAIResponsesStoreEnabled, @@ -1057,8 +1058,8 @@ async function handleSingleModelChat( getTargetFormat(provider, credentials.providerSpecificData) || targetFormat; - // 5. Log proxy + translation events - safeLogEvents({ + // 5. Log proxy + translation events (fire-and-forget; never blocks the response) + void safeLogEvents({ result, proxyInfo, proxyLatency, @@ -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) { @@ -1214,7 +1217,10 @@ async function handleSingleModelChat( // Emergency fallback for budget exhaustion (402 / billing / quota keywords): // reroute to a free model (default provider/model: nvidia + openai/gpt-oss-120b) exactly once. - if (!runtimeOptions.emergencyFallbackTried) { + // Combo targets never emergency-hop: the combo is the operator's fallback policy + // (target-level orchestration plus the global fallback #689 after it), and a + // per-target hop burns extra upstream calls against exhausted providers (#1731). + if (!runtimeOptions.emergencyFallbackTried && !comboName) { const fallbackDecision = shouldUseFallback( Number(result.status || 0), String(result.error || ""), @@ -1290,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; } @@ -1349,6 +1359,7 @@ async function handleSingleModelChat( { persistUnavailableState: !(isCombo && result.status === 429 && (failureKind === "rate_limit" || failureKind === "transient")), + isCombo, } ); diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index bf7e7ac232..f5f3854774 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -384,7 +384,6 @@ export async function executeChatWithBreaker({ isCombo, comboStepId, comboExecutionKey, - disableEmergencyFallback: isCombo, cachedSettings, skipUpstreamRetry, trafficType: normalizedTrafficType, @@ -431,7 +430,8 @@ export async function executeChatWithBreaker({ String(failure?.message || failure?.code || "stream failure"), provider, model, - providerProfile + providerProfile, + { isCombo } ); }, }) @@ -586,12 +586,19 @@ export async function safeResolveProxy(connectionId: string, apiKeyId?: string) try { return await resolveProxyForConnection(connectionId, apiKeyId); } catch (proxyErr: any) { - log.debug("PROXY", `Failed to resolve proxy: ${proxyErr.message}`); + // Falling back to a DIRECT connection silently defeats proxy-based traffic + // isolation — keep the request alive, but make the bypass visible. + log.warn( + "PROXY", + `Proxy resolution failed for connection ${String(connectionId).slice(0, 8)} — falling back to DIRECT connection: ${proxyErr.message}` + ); return null; } } -export function safeLogEvents({ +// Async because the egress-IP lookup lazy-imports proxyEgress; callers treat +// this as fire-and-forget logging (the internal try/catch swallows everything). +export async function safeLogEvents({ result, proxyInfo, proxyLatency, @@ -613,6 +620,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" @@ -625,6 +646,7 @@ export function safeLogEvents({ provider, targetUrl: `${provider}/${model}`, clientIp, + egressIp, latencyMs: proxyLatency, error: result.success ? null : result.error || null, connectionId: credentials.connectionId, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index a5fce790cb..22234e1c9e 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -31,7 +31,7 @@ import { recordModelLockoutFailure, } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { COOLDOWN_MS } from "@omniroute/open-sse/config/constants.ts"; +import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts"; import { preflightQuota, isQuotaPreflightEnabled, @@ -42,7 +42,7 @@ import { classifyProviderError, PROVIDER_ERROR_TYPES, } from "@omniroute/open-sse/services/errorClassifier.ts"; -import { looksLikeQuotaExhausted } from "@/shared/utils/classify429"; + import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; import { getProviderById, @@ -1707,6 +1707,8 @@ export async function markAccountUnavailable( providerProfile = null, options: { persistUnavailableState?: boolean; + /** Caller is the combo engine — it records its own model-level lockouts. */ + isCombo?: boolean; } = {} ) { const currentMutex = markMutexes.get(connectionId) || Promise.resolve(); @@ -1799,7 +1801,7 @@ export async function markAccountUnavailable( const reason = status === 404 ? "not_found" - : status === 429 && looksLikeQuotaExhausted(errorText) + : status === 429 && fallbackResult.reason === RateLimitReason.QUOTA_EXHAUSTED ? "quota_exhausted" : status === 429 ? "rate_limited" @@ -1986,6 +1988,13 @@ export async function markAccountUnavailable( const persistUnavailableState = options.persistUnavailableState !== false; if (!persistUnavailableState) { + // Combo-managed transient failure (e.g. 429): keep the connection clean in + // the DB, but record an in-memory model lockout so credential selection + // skips this exact provider+connection+model while it cools down — other + // models on the same connection stay usable. + if (provider && model && cooldownMs > 0) { + lockModel(provider, connectionId, model, reason || "unknown", cooldownMs); + } await updateProviderConnection(connectionId, { ...baseUpdate, }); diff --git a/src/types/settings.ts b/src/types/settings.ts index b46b68cfc7..d1bd0a4bd2 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -56,6 +56,7 @@ export interface ComboDefaults { fallbackDelayMs?: number; maxComboDepth: number; trackMetrics: boolean; + reasoningTokenBufferEnabled?: boolean; concurrencyPerModel?: number; queueTimeoutMs?: number; handoffThreshold?: number; diff --git a/tests/integration/combo-provider-exhaustion.test.ts b/tests/integration/combo-provider-exhaustion.test.ts index 2d20c96dbb..f61060b7f2 100644 --- a/tests/integration/combo-provider-exhaustion.test.ts +++ b/tests/integration/combo-provider-exhaustion.test.ts @@ -13,6 +13,7 @@ const { seedConnection, settingsDb, } = harness; +const providersDb = await import("../../src/lib/db/providers.ts"); function toPlainHeaders(headers: any): Record { if (!headers) return {}; @@ -34,7 +35,7 @@ test.after(async () => { await harness.cleanup(); }); -test.skip("fast-skip on quota-exhausted 429: first same-provider target causes remaining same-provider targets to be skipped (#1731)", async () => { +test("fast-skip on quota-exhausted 429: first same-provider target causes remaining same-provider targets to be skipped (#1731)", async () => { await seedConnection("openai", { apiKey: "sk-openai-quota-exhausted", }); @@ -116,7 +117,7 @@ test.skip("fast-skip on quota-exhausted 429: first same-provider target causes r assert.equal(anthropicCalls, 1, "anthropic should be called once"); }); -test.skip("fast-skip on credits-exhausted 429: same-provider targets are skipped (#1731)", async () => { +test("fast-skip on credits-exhausted 429: same-provider targets are skipped (#1731)", async () => { await seedConnection("openai", { apiKey: "sk-openai-credits-exhausted", }); @@ -252,7 +253,7 @@ test("no skip on transient 429: plain rate-limit does not skip same-provider tar assert.equal(anthropicCalls, 1); }); -test.skip("cross-provider not affected: different providers both return 429, both are still attempted (#1731)", async () => { +test("cross-provider not affected: different providers both return 429, both are still attempted (#1731)", async () => { await seedConnection("openai", { apiKey: "sk-openai-cross-provider", }); @@ -337,10 +338,13 @@ test.skip("cross-provider not affected: different providers both return 429, bot assert.equal(claudeCalls, 1); }); -test.skip("exhaustion does not persist across requests: second request starts fresh (#1731)", async () => { - await seedConnection("openai", { +test("exhaustion does not persist across requests: second request starts fresh (#1731)", async () => { + // The fast-skip exhaustion sets are request-scoped. The connection-level + // cooldown written by the first 429 is a separate, legitimate layer — clear + // it between requests so this test only observes the request-scoped state. + const openaiConn = (await seedConnection("openai", { apiKey: "sk-openai-persistence-test", - }); + })) as any; await seedConnection("anthropic", { apiKey: "sk-anthropic-persistence-test", }); @@ -410,6 +414,17 @@ test.skip("exhaustion does not persist across requests: second request starts fr assert.equal(openaiCalls, 1, "first request: openai called once"); assert.equal(anthropicCalls, 1, "first request: anthropic called once"); + // Clear the connection-level cooldown left by the first 429 — we are testing + // the request-scoped exhaustion sets, not the (persistent-by-design) cooldown. + await providersDb.updateProviderConnection(openaiConn.id, { + rateLimitedUntil: null, + testStatus: "active", + lastError: null, + lastErrorType: null, + errorCode: null, + backoffLevel: 0, + }); + // Second request: exhaustedProviders should be reset, openai should be tried again requestCount = 1; const response2 = await handleChat( @@ -429,7 +444,7 @@ test.skip("exhaustion does not persist across requests: second request starts fr assert.equal(anthropicCalls, 1, "second request: anthropic should not be called"); }); -test.skip("round-robin path fast-skip: round-robin combo also skips exhausted provider targets (#1731)", async () => { +test("round-robin path fast-skip: round-robin combo also skips exhausted provider targets (#1731)", async () => { await seedConnection("openai", { apiKey: "sk-openai-rr-exhaustion", }); @@ -571,3 +586,69 @@ test("allow rate-limited connections after transient 429 on subsequent targets i assert.deepEqual(usedApiKeys, ["fresh", "rate-limited"]); }); +test("emergency fallback never sends the failing provider's credentials to another provider's endpoint", async () => { + // Single-model request (no combo). On a quota/billing 429 the emergency + // fallback redirects to nvidia/openai/gpt-oss-120b. The hop must go through + // credential selection for the EMERGENCY provider — it must never re-send + // the exhausted provider's API key to a different provider's URL (the old + // executor-level hop shipped the OpenAI key to integrate.api.nvidia.com). + await seedConnection("openai", { + apiKey: "sk-openai-emergency-leak-guard", + }); + await settingsDb.updateSettings({ + requestRetry: 0, + maxRetryIntervalSec: 0, + }); + + const originalRetryDelay = harness.BaseExecutor.RETRY_CONFIG.delayMs; + harness.BaseExecutor.RETRY_CONFIG.delayMs = 0; + + const upstreamCalls: Array<{ host: string; usedOpenAIKey: boolean }> = []; + + globalThis.fetch = async (url: any, init: any = {}) => { + const headers = toPlainHeaders(init.headers); + const authHeader = headers.authorization ?? headers.Authorization; + const host = new URL(typeof url === "string" ? url : String(url)).host; + const usedOpenAIKey = authHeader === "Bearer sk-openai-emergency-leak-guard"; + upstreamCalls.push({ host, usedOpenAIKey }); + + if (usedOpenAIKey) { + return new Response(JSON.stringify({ error: { message: "Subscription quota exceeded" } }), { + status: 429, + headers: { "Content-Type": "application/json" }, + }); + } + + throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`); + }; + + try { + const response = await handleChat( + buildRequest({ + body: { + model: "openai/gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "test emergency credential leak guard" }], + }, + }) + ); + + assert.notEqual(response.status, 200, "request should fail (no fallback available)"); + } finally { + harness.BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelay; + } + + const leaks = upstreamCalls.filter( + (call) => call.usedOpenAIKey && !call.host.includes("openai.com") + ); + assert.deepEqual( + leaks, + [], + `the OpenAI key must never be sent to a non-OpenAI host: ${JSON.stringify(upstreamCalls)}` + ); + assert.ok( + upstreamCalls.every((call) => call.usedOpenAIKey), + `no unauthenticated emergency call should reach upstream either: ${JSON.stringify(upstreamCalls)}` + ); +}); + diff --git a/tests/integration/gemini-live-429-classification.test.ts b/tests/integration/gemini-live-429-classification.test.ts new file mode 100644 index 0000000000..f8635c79ac --- /dev/null +++ b/tests/integration/gemini-live-429-classification.test.ts @@ -0,0 +1,152 @@ +/** + * Gemini 429 classification integration tests. + * + * Tests the end-to-end classification path for Gemini rate-limit errors + * through OmniRoute. Sends bursts of requests to try to trigger published + * RPM/RPD limits, then verifies the classification is correct. + * + * The tests are "best effort" — if rate limits aren't triggered (Gemini + * may be more generous in practice), the test logs a warning and passes + * rather than failing. The unit tests in account-fallback-service.test.ts + * provide the definitive coverage of classification logic. + * + * Env vars: + * OMNIROUTE_URL — base URL (default http://localhost:20128) + * OMNIROUTE_API_KEY — API key for auth (REQUIRED) + * TEST_GEMINI_RPM_MODEL — RPM model (default gemini/gemma-4-31b-it) + * TEST_GEMINI_RPD_MODEL — RPD model (default gemini/gemini-2.5-flash) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const API_KEY = process.env.OMNIROUTE_API_KEY; +const BASE_URL = process.env.OMNIROUTE_URL || "http://localhost:20128"; +const RPM_MODEL = process.env.TEST_GEMINI_RPM_MODEL || "gemini/gemma-4-31b-it"; +const RPD_MODEL = process.env.TEST_GEMINI_RPD_MODEL || "gemini/gemini-2.5-flash"; + +const skip = !API_KEY ? "OMNIROUTE_API_KEY not set — skipping live test" : undefined; + +async function chat(model: string, content: string) { + const res = await fetch(`${BASE_URL}/api/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` }, + body: JSON.stringify({ model, stream: false, messages: [{ role: "user", content }] }), + }); + return { status: res.status, body: await res.text() }; +} + +// ── Test 1: RPM burst ──────────────────────────────────────────────────────── + +test( + "Gemma 4 RPM burst: try to hit 15 RPM, verify 429 classification if triggered", + { skip }, + async () => { + const BURST = 30; + console.error(`\n[RPM] Sending ${BURST} concurrent requests to ${RPM_MODEL} (15 RPM)...`); + const fetches = Array.from({ length: BURST }, (_, i) => + chat(RPM_MODEL, `Count to 3. Only numbers. Request ${i}.`) + ); + const results = await Promise.all(fetches); + + const statuses = results.map((r) => r.status); + const successes = results.filter((r) => r.status === 200); + const rateLimited = results.filter((r) => r.status === 429); + + console.error( + `[RPM] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})` + ); + + assert.ok(successes.length > 0, "expected at least one successful request"); + + if (rateLimited.length > 0) { + for (const r of rateLimited) { + assert.equal( + r.body.includes("quota_exhausted"), + false, + `RPM 429 should NOT be quota_exhausted: ${r.body.slice(0, 300)}` + ); + assert.ok( + r.body.includes("cooling down") || r.body.includes("rate_limit"), + `RPM 429 should mention cooldown: ${r.body.slice(0, 300)}` + ); + } + } else { + console.error("[RPM] No 429s received (Gemini may have higher effective RPM for this key)"); + console.error( + "[RPM] Classification logic verified by unit tests in account-fallback-service.test.ts" + ); + } + } +); + +test("Gemma 4 RPM recovery: after 65s, requests should succeed again", { skip }, async () => { + // First send a burst to ensure any cooldown from the previous test has cleared + const warmup = await chat(RPM_MODEL, "ping"); + if (warmup.status === 429) { + console.error("[RPM recovery] Previous test left model in cooldown, waiting 65s..."); + await new Promise((r) => setTimeout(r, 65_000)); + } else { + console.error("[RPM recovery] Model is healthy, skipping wait"); + } + + const results: Array<{ status: number }> = []; + for (let i = 0; i < 3; i++) { + results.push(await chat(RPM_MODEL, `Hello ${i}.`)); + await new Promise((r) => setTimeout(r, 500)); + } + + const successes = results.filter((r) => r.status === 200); + console.error(`[RPM recovery] ${successes.length}/3 success`); + assert.ok( + successes.length >= 1, + `expected at least 1 recovery, got: ${results.map((r) => r.status).join(",")}` + ); +}); + +// ── Test 2: RPD burst ──────────────────────────────────────────────────────── + +test( + "Gemini 2.5 Flash RPD burst: try to hit 20 RPD, verify quota_exhausted if triggered", + { skip }, + async () => { + const BURST = 30; + console.error(`\n[RPD] Sending ${BURST} concurrent requests to ${RPD_MODEL} (20 RPD)...`); + const fetches = Array.from({ length: BURST }, (_, i) => + chat(RPD_MODEL, `Count to 5. Only numbers. Request ${i}.`) + ); + const results = await Promise.all(fetches); + const statuses = results.map((r) => r.status); + const successes = results.filter((r) => r.status === 200); + const rateLimited = results.filter((r) => r.status === 429); + + console.error( + `[RPD] ${successes.length} success, ${rateLimited.length} 429 (statuses: ${statuses.join(",")})` + ); + + assert.ok(successes.length > 0, "expected at least one successful request"); + + const quotaExhausted = rateLimited.filter((r) => + r.body.toLowerCase().includes("quota_exhausted") + ); + + if (quotaExhausted.length > 0) { + console.error(`[RPD] ${quotaExhausted.length} quota_exhausted responses ✓`); + } else if (rateLimited.length > 0) { + // Check that non-quota-exhausted 429s are still rate_limit, not some other error + for (const r of rateLimited) { + assert.equal( + r.body.includes("quota_exhausted"), + false, + `RPM 429 should not be quota_exhausted: ${r.body.slice(0, 200)}` + ); + } + console.error("[RPD] 429s present but none are quota_exhausted (RPD not yet hit)"); + } else { + console.error( + "[RPD] No 429s received (daily quota may not have been reached, or limits are higher)" + ); + console.error("[RPD] Classification logic verified by unit tests"); + } + } +); diff --git a/tests/integration/gemini-rate-limit-classification.test.ts b/tests/integration/gemini-rate-limit-classification.test.ts new file mode 100644 index 0000000000..bbeb7cbb6e --- /dev/null +++ b/tests/integration/gemini-rate-limit-classification.test.ts @@ -0,0 +1,253 @@ +/** + * Gemini rate-limit classification integration tests. + * + * Tests the full integration between geminiRateLimitTracker (in-memory + * daily/minute counters) and accountFallback.checkFallbackError (429 + * classification). No live Gemini API key needed — the tracker counters + * are incremented directly and a synthetic 429 error is passed to + * checkFallbackError. + * + * This validates that the whole pipeline works: + * incrementRequestCount → isRpdExhausted / isRpmExhausted → checkFallbackError + * + * Covers three classification outcomes: + * - RPM exhausted → RATE_LIMIT_EXCEEDED (exponential backoff) + * - RPD exhausted → QUOTA_EXHAUSTED (midnight lockout) + * - Neither exhausted → falls through to generic 429 (RATE_LIMIT_EXCEEDED) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts"); +const { RateLimitReason } = await import("../../open-sse/config/constants.ts"); +const { + incrementRequestCount, + getDailyRequestCount, + getMinuteRequestCount, + isRpdExhausted, + isRpmExhausted, + resetCounters, +} = await import("../../open-sse/services/geminiRateLimitTracker.ts"); + +const PROFILE = { + baseCooldownMs: 125, + useUpstreamRetryHints: false, + maxBackoffSteps: 3, + failureThreshold: 60, + degradationThreshold: 40, + resetTimeoutMs: 5000, + transientCooldown: 125, + rateLimitCooldown: 125, + maxBackoffLevel: 3, + circuitBreakerThreshold: 60, + circuitBreakerReset: 5000, + providerFailureThreshold: 5, + providerFailureWindowMs: 300000, + providerCooldownMs: 60000, +}; + +const GEMINI_429_BODY = "Resource has been exhausted (e.g. check quota)."; + +test.beforeEach(() => { + resetCounters(); +}); + +// ── Scenario 1: RPM exhausted, RPD not exhausted → RATE_LIMIT_EXCEEDED ──────── + +test("Gemini 2.5 Flash 5 RPM hit: 429 classifies as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => { + // gemini-2.5-flash: RPM=5, RPD=20 + for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "gemini", + null, + PROFILE + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.ok(result.cooldownMs > 0, "cooldownMs should be positive"); +}); + +// ── Scenario 2: RPD exhausted → QUOTA_EXHAUSTED ─────────────────────────────── + +test("Gemini 2.5 Flash 20 RPD hit: 429 classifies as QUOTA_EXHAUSTED", () => { + for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "gemini", + null, + PROFILE + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.ok(result.cooldownMs > 0, "cooldownMs should be positive"); +}); + +// ── Scenario 3: Neither RPM nor RPD exhausted → falls through to generic 429 ── + +test("Gemini 2.5 Flash 3 requests (below both): 429 falls through to generic RATE_LIMIT_EXCEEDED", () => { + for (let i = 0; i < 3; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), false); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "gemini", + null, + PROFILE + ); + + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.ok(result.cooldownMs > 0, "cooldownMs should be positive"); +}); + +// ── Scenario 4: Both limits exhausted → RPD takes priority → QUOTA_EXHAUSTED ── + +test("Gemini 2.5 Flash both RPM and RPD hit: RPD check runs first → QUOTA_EXHAUSTED", () => { + for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "gemini", + null, + PROFILE + ); + + // RPD check is first in the if-chain, so it takes priority + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +// ── Scenario 5: Gemma 4 — 15 RPM hit, 1500 RPD not hit → RATE_LIMIT_EXCEEDED ─ + +test("Gemma 4 15 RPM hit (RPD=1500 untouched): 429 classifies as RATE_LIMIT_EXCEEDED", () => { + for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini/gemma-4-31b-it", + "gemini", + null, + PROFILE + ); + + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +// ── Scenario 6: Non-Gemini provider bypasses the Gemini-specific check ───────── + +test("Non-Gemini provider: tracker state is irrelevant, 429 goes through generic path", () => { + for (let i = 0; i < 30; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + + // Provider is "openai" — Gemini-specific check is skipped + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "openai", + null, + PROFILE + ); + + // Falls through to generic 429 handling → RATE_LIMIT_EXCEEDED + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +// ── Scenario 7: Reset clears state → no longer exhausted ────────────────────── + +test("resetCounters clears both RPM and RPD exhaustion", () => { + for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + + resetCounters(); + + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); + assert.equal(isRpmExhausted("gemini-2.5-flash"), false); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); + + // Generic 429 path (no model-specific early return) + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini-2.5-flash", + "gemini", + null, + PROFILE + ); + + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); + +// ── Scenario 8: RPD exhaustion with Gemma 4 (high RPD, never hit with 15 RPM) ─ + +test("Gemma 4 1500 RPD exhaustion overrides RPM classification", () => { + // Pump 1500 daily requests to exhaust RPD + for (let i = 0; i < 1500; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true); + assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true); // 1500 >> 15 RPM + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini/gemma-4-31b-it", + "gemini", + null, + PROFILE + ); + + // RPD check runs first + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +// ── Scenario 9: Unknown model (no RPM/RPD in JSON) → generic 429 path ───────── + +test("Unknown Gemini model without published limits falls through to generic 429", () => { + incrementRequestCount("gemini/unknown-model"); + assert.equal(isRpmExhausted("gemini/unknown-model"), false); + assert.equal(isRpdExhausted("gemini/unknown-model"), false); + + const result = checkFallbackError( + 429, + GEMINI_429_BODY, + 0, + "gemini/unknown-model", + "gemini", + null, + PROFILE + ); + + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); +}); diff --git a/tests/integration/integration-wiring.test.ts b/tests/integration/integration-wiring.test.ts index a4af359c7a..6597b16616 100644 --- a/tests/integration/integration-wiring.test.ts +++ b/tests/integration/integration-wiring.test.ts @@ -526,7 +526,7 @@ describe("Page Integration — combos page empty state", () => { describe("Page Integration — provider test results privacy", () => { const providersSrc = readProjectFile("src/app/(dashboard)/dashboard/providers/page.tsx"); const providerDetailSrc = readProjectFile( - "src/app/(dashboard)/dashboard/providers/[id]/page.tsx" + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx" ); it("should mask provider test batch names with the global email privacy toggle", () => { @@ -541,7 +541,7 @@ describe("Page Integration — provider test results privacy", () => { it("should mask provider detail test result names with the global email privacy toggle", () => { assert.ok( providerDetailSrc, - "src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist" + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist" ); assert.match(providerDetailSrc, /const emailsVisible = useEmailPrivacyStore/); assert.match( @@ -553,7 +553,7 @@ describe("Page Integration — provider test results privacy", () => { it("should resolve provider detail metadata through the shared dashboard catalog", () => { assert.ok( providerDetailSrc, - "src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist" + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist" ); assert.match(providerDetailSrc, /resolveDashboardProviderInfo/); }); @@ -561,7 +561,7 @@ describe("Page Integration — provider test results privacy", () => { it("should treat upstream proxy entries as a dedicated management surface", () => { assert.ok( providerDetailSrc, - "src/app/(dashboard)/dashboard/providers/[id]/page.tsx should exist" + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx should exist" ); assert.match(providerDetailSrc, /isUpstreamProxyProvider/); assert.match(providerDetailSrc, /Managed via Upstream Proxy Settings/); diff --git a/tests/integration/proxy-context-passthrough.test.ts b/tests/integration/proxy-context-passthrough.test.ts new file mode 100644 index 0000000000..52c94a33b0 --- /dev/null +++ b/tests/integration/proxy-context-passthrough.test.ts @@ -0,0 +1,191 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; + +import { createChatPipelineHarness } from "./_chatPipelineHarness.ts"; + +// Proxy-context passthrough on execution paths: +// 1. combo targets must each run under their OWN connection's proxy +// 2. /v1/messages/count_tokens must apply the connection proxy to the +// provider-side count call (it used to run with no proxy context at all) + +const harness = await createChatPipelineHarness("proxy-context-passthrough"); +const { buildClaudeResponse, buildRequest, combosDb, handleChat, resetStorage, seedConnection, settingsDb, toPlainHeaders } = + harness; +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const { resolveProxyForRequest } = await import("../../open-sse/utils/proxyFetch.ts"); +const countTokensRoute = await import("../../src/app/api/v1/messages/count_tokens/route.ts"); + +type TcpStub = { port: number; close: () => Promise }; + +// T14 fast-fail does a real TCP reachability check on the proxy host before +// entering the proxy context — back each fake proxy with a real listener. +async function startTcpStub(): Promise { + const server = net.createServer((socket) => socket.destroy()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("no tcp stub address"); + return { + port: address.port, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +function activeProxyUrl(): string | null { + const resolved = resolveProxyForRequest("https://upstream.example/v1"); + return resolved?.proxyUrl ?? null; +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.afterEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +test("combo targets each execute under their own connection's proxy", async () => { + const stubA = await startTcpStub(); + const stubB = await startTcpStub(); + + try { + const openaiConn = (await seedConnection("openai", { + apiKey: "sk-openai-proxy-a", + })) as any; + const claudeConn = (await seedConnection("claude", { + apiKey: "sk-claude-proxy-b", + })) as any; + await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 }); + + await proxiesDb.createProxyAndAssign( + { name: "proxy-a", type: "http", host: "127.0.0.1", port: stubA.port }, + { scope: "account", scopeId: openaiConn.id } + ); + await proxiesDb.createProxyAndAssign( + { name: "proxy-b", type: "http", host: "127.0.0.1", port: stubB.port }, + { scope: "account", scopeId: claudeConn.id } + ); + + await combosDb.createCombo({ + name: "proxy-per-target-combo", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"], + }); + + const proxySeen: Record = {}; + + globalThis.fetch = async (_url: any, init: any = {}) => { + const headers = toPlainHeaders(init.headers); + const authHeader = headers.authorization ?? headers.Authorization; + const apiKeyHeader = headers["x-api-key"] ?? headers["X-Api-Key"]; + + if (authHeader === "Bearer sk-openai-proxy-a") { + proxySeen.openai = activeProxyUrl(); + return new Response(JSON.stringify({ error: { message: "upstream unavailable" } }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }); + } + + if (apiKeyHeader === "sk-claude-proxy-b" || authHeader === "Bearer sk-claude-proxy-b") { + proxySeen.claude = activeProxyUrl(); + return buildClaudeResponse("served through proxy-b"); + } + + throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "proxy-per-target-combo", + stream: false, + messages: [{ role: "user", content: "proxy per target" }], + }, + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.equal(body.choices[0].message.content, "served through proxy-b"); + + assert.ok(proxySeen.openai, "openai target must run inside a proxy context"); + assert.ok( + proxySeen.openai!.includes(`127.0.0.1:${stubA.port}`), + `openai target must use proxy-a (saw ${proxySeen.openai})` + ); + assert.ok(proxySeen.claude, "claude target must run inside a proxy context"); + assert.ok( + proxySeen.claude!.includes(`127.0.0.1:${stubB.port}`), + `claude fallback target must use proxy-b (saw ${proxySeen.claude})` + ); + } finally { + await stubA.close(); + await stubB.close(); + } +}); + +test("count_tokens provider call runs inside the connection's proxy context", async () => { + const stub = await startTcpStub(); + + try { + const claudeConn = (await seedConnection("claude", { + apiKey: "sk-claude-count-tokens", + })) as any; + await settingsDb.updateSettings({ requestRetry: 0, maxRetryIntervalSec: 0 }); + + await proxiesDb.createProxyAndAssign( + { name: "proxy-count", type: "http", host: "127.0.0.1", port: stub.port }, + { scope: "account", scopeId: claudeConn.id } + ); + + let providerCallProxy: string | null | undefined; + + globalThis.fetch = async (url: any, _init: any = {}) => { + const target = typeof url === "string" ? url : String(url); + if (target.includes("count_tokens")) { + providerCallProxy = activeProxyUrl(); + return new Response(JSON.stringify({ input_tokens: 42 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected upstream url: ${target}`); + }; + + const response = await countTokensRoute.POST( + new Request("http://localhost/v1/messages/count_tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "claude/claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "count me" }], + }), + }) + ); + + const body = (await response.json()) as any; + assert.equal(response.status, 200); + assert.equal(body.source, "provider", "provider-side count should be used"); + assert.equal(body.input_tokens, 42); + + assert.ok( + providerCallProxy, + "count_tokens provider call must run inside the connection's proxy context" + ); + assert.ok( + providerCallProxy!.includes(`127.0.0.1:${stub.port}`), + `count_tokens must use the account proxy (saw ${providerCallProxy})` + ); + } finally { + await stub.close(); + } +}); diff --git a/tests/integration/resilience-http-e2e.test.ts b/tests/integration/resilience-http-e2e.test.ts index 7dc7135100..dccee521fa 100644 --- a/tests/integration/resilience-http-e2e.test.ts +++ b/tests/integration/resilience-http-e2e.test.ts @@ -558,6 +558,7 @@ test("resilience API only exposes configuration, not runtime breaker state", asy "connectionCooldown", "legacy", "providerBreaker", + "providerCooldown", "requestQueue", "waitForCooldown", ]); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 3b52caa560..c9bcd31648 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -30,6 +30,8 @@ const { isProviderFailureCode, getProvidersInCooldown, getProviderBreakerState, + isCreditsExhausted, + CREDITS_EXHAUSTED_SIGNALS, } = accountFallback; const { selectAccount } = accountSelector; @@ -41,7 +43,6 @@ function makeProfile(overrides: Record = {}): any { useUpstreamRetryHints: false, maxBackoffSteps: 3, failureThreshold: 60, - degradationThreshold: 40, resetTimeoutMs: 5000, transientCooldown: 125, rateLimitCooldown: 125, @@ -108,7 +109,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset 429, message, 0, - "gemini-3.5-flash-high", + "gemini-3-flash-agent", "antigravity", null, makeProfile() @@ -124,7 +125,7 @@ test("checkFallbackError locks Antigravity quota-reached 429 for the full reset test("recordModelLockoutFailure honors a multi-day exactCooldownMs (under 30-day cap)", () => { const provider = "antigravity"; const connectionId = "conn-quota-window"; - const model = "gemini-3.5-flash-high"; + const model = "gemini-3-flash-agent"; const exactCooldownMs = (164 * 3600 + 27 * 60 + 24) * 1000; clearModelLock(provider, connectionId, model); @@ -384,7 +385,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => { assert.equal(oauthProfile.circuitBreakerReset, PROVIDER_PROFILES.oauth.circuitBreakerReset); assert.equal(oauthProfile.baseCooldownMs, PROVIDER_PROFILES.oauth.transientCooldown); assert.equal(oauthProfile.failureThreshold, PROVIDER_PROFILES.oauth.circuitBreakerThreshold); - assert.equal(oauthProfile.degradationThreshold, PROVIDER_PROFILES.oauth.degradationThreshold); assert.equal(oauthProfile.resetTimeoutMs, PROVIDER_PROFILES.oauth.circuitBreakerReset); const apiKeyProfile = getProviderProfile("openai"); @@ -401,7 +401,6 @@ test("getProviderProfile differentiates oauth and api-key providers", () => { assert.equal(apiKeyProfile.circuitBreakerReset, PROVIDER_PROFILES.apikey.circuitBreakerReset); assert.equal(apiKeyProfile.baseCooldownMs, PROVIDER_PROFILES.apikey.transientCooldown); assert.equal(apiKeyProfile.failureThreshold, PROVIDER_PROFILES.apikey.circuitBreakerThreshold); - assert.equal(apiKeyProfile.degradationThreshold, PROVIDER_PROFILES.apikey.degradationThreshold); assert.equal(apiKeyProfile.resetTimeoutMs, PROVIDER_PROFILES.apikey.circuitBreakerReset); }); @@ -610,7 +609,6 @@ test("recordProviderFailure honors runtime provider breaker profile", () => { try { const runtimeProfile = { failureThreshold: PROVIDER_PROFILES.apikey.circuitBreakerThreshold + 7, - degradationThreshold: PROVIDER_PROFILES.apikey.degradationThreshold + 3, resetTimeoutMs: PROVIDER_PROFILES.apikey.circuitBreakerReset + 45_000, }; @@ -618,13 +616,11 @@ test("recordProviderFailure honors runtime provider breaker profile", () => { const breaker = getCircuitBreaker(provider); assert.equal(breaker.failureThreshold, runtimeProfile.failureThreshold); - assert.equal(breaker.degradationThreshold, runtimeProfile.degradationThreshold); assert.equal(breaker.resetTimeout, runtimeProfile.resetTimeoutMs); assert.equal(isProviderInCooldown(provider), false); const breakerAfterStatusCheck = getCircuitBreaker(provider); assert.equal(breakerAfterStatusCheck.failureThreshold, runtimeProfile.failureThreshold); - assert.equal(breakerAfterStatusCheck.degradationThreshold, runtimeProfile.degradationThreshold); assert.equal(breakerAfterStatusCheck.resetTimeout, runtimeProfile.resetTimeoutMs); } finally { clearProviderFailure(provider); @@ -1115,6 +1111,182 @@ test("checkFallbackError ignores structured error with unrelated code on 400", ( assert.equal(result.shouldFallback, false); }); +// ─── Gemini RPM 429 Classification (CREDITS_EXHAUSTED_SIGNALS fix) ───── + +test("isCreditsExhausted returns false for Gemini RPM 429 body text", () => { + const geminiRpmText = "Resource has been exhausted (e.g. check quota)."; + assert.equal(isCreditsExhausted(geminiRpmText), false); +}); + +test("isCreditsExhausted returns true for actual credits-exhausted signals", () => { + assert.equal(isCreditsExhausted("insufficient_quota"), true); + assert.equal(isCreditsExhausted("credits exhausted"), true); + assert.equal(isCreditsExhausted("payment required"), true); + assert.equal(isCreditsExhausted("free tier of the model has been exhausted"), true); + assert.equal(isCreditsExhausted("exceeded your current usage quota"), true); +}); + +test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => { + // These patterns were removed because they falsely matched Gemini RPM 429 errors + assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource has been exhausted"), false); + assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("resource_exhausted"), false); + assert.equal(CREDITS_EXHAUSTED_SIGNALS.includes("check quota"), false); +}); + +test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED (not QUOTA_EXHAUSTED)", () => { + // provider=null → preserveQuota429=true → text quota checks run + // isCreditsExhausted must NOT match Gemini's "Resource has been exhausted" + const result = checkFallbackError( + 429, + "Resource has been exhausted (e.g. check quota).", + 0, + null, + null, + null, + makeProfile() + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.equal(result.creditsExhausted, undefined); + assert.equal(result.dailyQuotaExhausted, undefined); + assert.ok(result.cooldownMs > 0, "cooldownMs should be positive"); +}); + +test("checkFallbackError classifies Gemini RPM 429 as RATE_LIMIT_EXCEEDED for API-key provider", () => { + // provider="gemini" → preserveQuota429=false → status-based rule applies + const result = checkFallbackError( + 429, + "Resource has been exhausted (e.g. check quota).", + 0, + null, + "gemini", + null, + makeProfile() + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); + assert.equal(result.cooldownMs, 125); // makeProfile().baseCooldownMs +}); + +test("checkFallbackError still classifies genuine OAuth quota-exhausted text as QUOTA_EXHAUSTED", () => { + // Regression: OAuth providers must still get QUOTA_EXHAUSTED for actual quota messages + const result = checkFallbackError( + 429, + "Coding Plan hour quota has been exceeded", + 0, + null, + "codex", + null, + makeProfile() + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("checkFallbackError preserves daily-quota exhaustion for non-429 status codes", () => { + // Non-429 status codes with daily quota text must still be QUOTA_EXHAUSTED + const result = checkFallbackError( + 402, + "You have exceeded today's quota, please try again tomorrow" + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); + assert.equal(result.dailyQuotaExhausted, true); +}); + +// ─── Gemini 429 → Model Lockout: rate_limited (not quota_exhausted) ──── + +test("Gemini RPM 429: recordModelLockoutFailure uses exponential backoff for rate_limited reason", () => { + const originalNow = Date.now; + const now = 1_700_000_000_000; + Date.now = () => now; + const provider = "gemini"; + const connectionId = "test-conn-gemini-rpm"; + const model = "gemini/gemma-4-31b-it"; + + try { + clearModelLock(provider, connectionId, model); + + const profile = makeProfile({ + baseCooldownMs: 5000, + transientCooldown: 5000, + rateLimitCooldown: 5000, + }); + + // auth.ts flow: 429 + fallbackResult.reason=RATE_LIMIT_EXCEEDED + // → reason="rate_limited" → recordModelLockoutFailure + const first = recordModelLockoutFailure( + provider, + connectionId, + model, + "rate_limited", + 429, + 0, + profile + ); + assert.equal(first.failureCount, 1); + assert.equal(first.cooldownMs, 5000, "first failure: 5s base cooldown"); + + const second = recordModelLockoutFailure( + provider, + connectionId, + model, + "rate_limited", + 429, + 0, + profile + ); + assert.equal(second.failureCount, 2); + assert.equal(second.cooldownMs, 10000, "second failure: 10s exponential backoff"); + + assert.equal(isModelLocked(provider, connectionId, model), true); + clearModelLock(provider, connectionId, model); + } finally { + Date.now = originalNow; + clearModelLock("gemini", "test-conn-gemini-rpm", "gemini/gemma-4-31b-it"); + } +}); + +test("Gemini RPD (quota_exhausted) still triggers midnight lockout in recordModelLockoutFailure", () => { + // Regression: real daily quota exhaustion must still produce midnight reset + const originalNow = Date.now; + const testDate = new Date(); + testDate.setHours(12, 0, 0, 0); + const now = testDate.getTime(); + Date.now = () => now; + const provider = "gemini"; + const connectionId = "test-conn-gemini-rpd"; + const model = "gemini/gemma-4-31b-it"; + + try { + clearModelLock(provider, connectionId, model); + const profile = makeProfile(); + const result = recordModelLockoutFailure( + provider, + connectionId, + model, + "quota_exhausted", + 429, + 0, + profile + ); + + // Must lock until midnight, NOT exponential backoff + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + const expected = tomorrow.getTime() - now; + assert.ok( + Math.abs(result.cooldownMs - expected) <= 300_000, + `cooldown should be until tomorrow (expected ~${expected}, got ${result.cooldownMs})` + ); + clearModelLock(provider, connectionId, model); + } finally { + Date.now = originalNow; + clearModelLock("gemini", "test-conn-gemini-rpd", "gemini/gemma-4-31b-it"); + } +}); + // ─── G-02: X-Omni-Fallback-Hint: connection_cooldown ───────────────────────── // When 9router executor signals a supervisor-not-running 503, checkFallbackError // must return 5s cooldown with skipProviderBreaker:true — not trip the circuit breaker. @@ -1193,6 +1365,122 @@ test("G-02: five consecutive 503 service_not_running do NOT trip provider circui clearProviderFailure("9router"); // cleanup }); +test("recordModelLockoutFailure caps cooldown at BACKOFF_CONFIG.max to prevent absurdly long lockouts", () => { + const originalNow = Date.now; + let now = 1_700_000_000_000; + Date.now = () => now; + + try { + const provider = "openai"; + const connectionId = "conn-capped"; + const model = "gpt-5-trillium"; + + clearModelLock(provider, connectionId, model); + + // Fire 9 consecutive failures so the backoff exceeds the 120s cap + // baseCooldownMs=1000 (getQuotaCooldown(0)), failure 9: 1000*2^8=256000 > 120000 + let lastResult; + for (let i = 0; i < 9; i++) { + lastResult = recordModelLockoutFailure( + provider, + connectionId, + model, + "rate_limited", + 429, + 0, + null + ); + now += 50; // each failure within the reset window + } + + assert.ok( + lastResult.cooldownMs <= 120_000, + `cooldown ${lastResult.cooldownMs}ms should not exceed BACKOFF_CONFIG.max (120000ms)` + ); + assert.equal(lastResult.cooldownMs, 120_000); + assert.equal(lastResult.failureCount, 9); + + clearModelLock(provider, connectionId, model); + } finally { + Date.now = originalNow; + } +}); + +test("recordModelLockoutFailure groups provider aliases under canonical provider", () => { + const providerAlias = "cx"; + const providerCanonical = "codex"; + const connectionId = "conn-alias-test"; + const model = "gpt-5.5"; + + clearModelLock(providerCanonical, connectionId, model); + clearModelLock(providerAlias, connectionId, model); + + const result1 = recordModelLockoutFailure( + providerAlias, + connectionId, + model, + "rate_limited", + 429, + 1000, + null + ); + + assert.equal(isModelLocked(providerAlias, connectionId, model), true); + assert.equal(isModelLocked(providerCanonical, connectionId, model), true); + + clearModelLock(providerCanonical, connectionId, model); +}); + +test("recordModelLockoutFailure escalates backoff correctly after cooldown expiration (long interval)", () => { + const originalNow = Date.now; + let now = 1_700_000_000_000; + Date.now = () => now; + + try { + const provider = "openai"; + const connectionId = "conn-long-interval"; + const model = "gpt-5-escalate"; + + clearModelLock(provider, connectionId, model); + + const profile = makeProfile({ + baseCooldownMs: 120000, + resetTimeoutMs: 30000, + }); + + const first = recordModelLockoutFailure( + provider, + connectionId, + model, + "rate_limited", + 429, + 120000, + profile, + { maxCooldownMs: 1800000 } + ); + assert.equal(first.failureCount, 1); + assert.equal(first.cooldownMs, 120000); + + now += 130000; + + const second = recordModelLockoutFailure( + provider, + connectionId, + model, + "rate_limited", + 429, + 120000, + profile, + { maxCooldownMs: 1800000 } + ); + assert.equal(second.failureCount, 2); + assert.equal(second.cooldownMs, 240000); + + clearModelLock(provider, connectionId, model); + } finally { + Date.now = originalNow; + } +}); // ── Custom banned signals (PR #3454) ────────────────────────────────────────── // Operators can extend ACCOUNT_DEACTIVATED_SIGNALS with provider-specific // permanent-ban phrasing via Settings → Security. These persist in the diff --git a/tests/unit/agy-gemini-3696-tier-passthrough.test.ts b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts new file mode 100644 index 0000000000..d8bbad9a8b --- /dev/null +++ b/tests/unit/agy-gemini-3696-tier-passthrough.test.ts @@ -0,0 +1,41 @@ +/** + * #3696 — antigravity/agy gemini-3.1-pro-high / gemini-3.1-pro-low were being collapsed + * to the bare upstream id `gemini-3.1-pro`, losing the tier distinction. + * + * Wire evidence (captured by maintainer via `agy --model gemini-3.1-pro-high --log-file`): + * - `gemini-3.1-pro-high` sent literally to `/v1internal:streamGenerateContent` → 200 OK + * - `gemini-3.1-pro-low` sent literally → 200 OK + * + * CONCLUSION: the upstream ACCEPTS the suffixed ids directly. The old assumption in #3229 + * ("upstream rejects the suffix for gemini-3.x") was refuted by this wire capture. + * The collapse aliases must be removed so the tier-specific ids reach the upstream. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + ANTIGRAVITY_PUBLIC_MODELS, + resolveAntigravityModelId, +} from "../../open-sse/config/antigravityModelAliases.ts"; + +test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-high through unchanged", () => { + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high"); +}); + +test("(#3696) resolveAntigravityModelId passes gemini-3.1-pro-low through unchanged", () => { + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low"); +}); + +test("(#3696) no two ANTIGRAVITY_PUBLIC_MODELS entries resolve to the same upstream id", () => { + const seen = new Map(); + const collisions: string[] = []; + for (const model of ANTIGRAVITY_PUBLIC_MODELS) { + const upstream = resolveAntigravityModelId(model.id); + if (seen.has(upstream)) { + collisions.push(`${model.id} and ${seen.get(upstream)} both resolve to "${upstream}"`); + } else { + seen.set(upstream, model.id); + } + } + assert.deepEqual(collisions, [], `upstream-id collisions: ${collisions.join("; ")}`); +}); diff --git a/tests/unit/agy-gemini-400-3229.test.ts b/tests/unit/agy-gemini-400-3229.test.ts index 6d3fc448b0..783db2257a 100644 --- a/tests/unit/agy-gemini-400-3229.test.ts +++ b/tests/unit/agy-gemini-400-3229.test.ts @@ -3,11 +3,14 @@ * `chat.completion` envelope. * * Two parts: - * (a) the budget-suffix ids had no alias, so `resolveAntigravityModelId` sent them - * verbatim to upstream (which rejects -high/-low for gemini-3.x) → alias to plain. + * (a) ORIGINAL FIX (#3229): aliased -high/-low to plain `gemini-3.1-pro` (upstream + * was believed to reject the suffix). SUPERSEDED BY #3696: wire capture via + * `agy --model gemini-3.1-pro-high --log-file` confirmed upstream returns 200 OK + * with the suffixed id; the collapse aliases are now removed so the tier-specific + * id passes through verbatim. * (b) the non-stream branch fed the 4xx response into the SSE collector, producing a * synthetic `{"object":"chat.completion","content":""}` instead of a real error → - * build a proper sanitized error body for non-ok upstream responses. + * build a proper sanitized error body for non-ok upstream responses. (Still valid.) */ import test from "node:test"; import assert from "node:assert/strict"; @@ -15,9 +18,11 @@ import assert from "node:assert/strict"; import { resolveAntigravityModelId } from "../../open-sse/config/antigravityModelAliases.ts"; import { buildAntigravityUpstreamError } from "../../open-sse/executors/antigravityUpstreamError.ts"; -test("(a) agy gemini-3.1-pro -high/-low budget suffixes alias to the plain upstream id", () => { - assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro"); - assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro"); +test("(a) agy gemini-3.1-pro -high/-low budget suffixes pass through to upstream unchanged (#3696)", () => { + // #3696: wire capture confirmed upstream accepts the suffixed ids verbatim. + // The old #3229 collapse aliases ("gemini-3.1-pro-high" → "gemini-3.1-pro") were removed. + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-high"), "gemini-3.1-pro-high"); + assert.equal(resolveAntigravityModelId("gemini-3.1-pro-low"), "gemini-3.1-pro-low"); // plain id stays plain assert.equal(resolveAntigravityModelId("gemini-3.1-pro"), "gemini-3.1-pro"); }); diff --git a/tests/unit/arena-elo-sync.test.ts b/tests/unit/arena-elo-sync.test.ts new file mode 100644 index 0000000000..2c13923428 --- /dev/null +++ b/tests/unit/arena-elo-sync.test.ts @@ -0,0 +1,827 @@ +/** + * Unit tests for src/lib/arenaEloSync.ts + * + * Uses Node.js native test runner. All external fetch calls are mocked. + * DB functions use a real in-memory SQLite instance via node:sqlite (DatabaseSync), + * injected through the core module's globalThis.__omnirouteDb singleton. + * backupDbFile is called during first sync but safely no-ops (no file on disk). + */ + +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-arena-elo-test-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const MIGRATION_SQL = fs.readFileSync( + path.resolve( + import.meta.dirname ?? __dirname, + "../../src/lib/db/migrations/097_model_intelligence.sql", + ), + "utf8", +); + +import { tryOpenSync } from "../../src/lib/db/adapters/driverFactory"; +import type { SqliteAdapter } from "../../src/lib/db/adapters/types"; + +const core = await import("../../src/lib/db/core.ts"); + +const { + normalizeModelName, + transformToModelIntelligence, + fetchArenaLeaderboards, + syncArenaElo, + getArenaEloSyncStatus, + stopArenaEloSync, +} = await import("../../src/lib/arenaEloSync.ts"); + +import type { + ArenaLeaderboardData, + ArenaLeaderboardMap, + ArenaModelEntry, +} from "../../src/lib/arenaEloSync.ts"; + +const originalFetch = globalThis.fetch; + +function mockFetch( + impl: (url: string, opts?: RequestInit) => Promise, +): void { + globalThis.fetch = impl as typeof fetch; +} + +function restoreFetch(): void { + globalThis.fetch = originalFetch; +} + +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function makeModelEntry(overrides: Partial = {}): ArenaModelEntry { + return { + rank: 1, + model: "anthropic/claude-sonnet", + vendor: "Anthropic", + score: 1350, + ci: 10, + votes: 5000, + license: "proprietary", + ...overrides, + }; +} + +function makeLeaderboardData( + models: ArenaModelEntry[] = [], + category = "text", +): ArenaLeaderboardData { + return { + meta: { leaderboard: category, model_count: models.length }, + models, + }; +} + +function makeLeaderboardMap( + categories: Partial>, +): ArenaLeaderboardMap { + const map: ArenaLeaderboardMap = {}; + for (const [cat, models] of Object.entries(categories)) { + map[cat] = makeLeaderboardData(models ?? [], cat); + } + return map; +} + +let testAdapter: SqliteAdapter; + +function createTestAdapter(): SqliteAdapter { + const patchedSql = MIGRATION_SQL.replace( + /\n\s*synced_at TEXT NOT NULL DEFAULT \(datetime\('now'\)\)/, + "\n synced_at TEXT NOT NULL", + ); + const adapter = tryOpenSync(":memory:")!; + adapter.exec(patchedSql); + return adapter; +} + +function countArenaEloEntries(): number { + const row = testAdapter + .prepare("SELECT COUNT(*) as cnt FROM model_intelligence WHERE source = 'arena_elo'") + .get() as Record | undefined; + return Number(row?.cnt ?? 0); +} + +function getAllEntries(): Array> { + return testAdapter + .prepare("SELECT * FROM model_intelligence WHERE source = 'arena_elo' ORDER BY model, category") + .all() as Array>; +} + +beforeEach(() => { + core.resetDbInstance(); + testAdapter = createTestAdapter(); + globalThis.__omnirouteDb = testAdapter as never; + stopArenaEloSync(); +}); + +afterEach(() => { + restoreFetch(); + stopArenaEloSync(); + delete globalThis.__omnirouteDb; +}); + +// ═══════════════════════════════════════════════════════════ +// 1. normalizeModelName() +// ═══════════════════════════════════════════════════════════ + +describe("normalizeModelName()", () => { + it("strips 'anthropic/' vendor prefix", () => { + assert.strictEqual( + normalizeModelName("anthropic/claude-opus-4-6-thinking"), + "claude-opus-4-6-thinking", + ); + }); + + it("strips 'openai/' vendor prefix", () => { + assert.strictEqual(normalizeModelName("openai/gpt-5.5"), "gpt-5.5"); + }); + + it("strips 'google/' vendor prefix", () => { + assert.strictEqual(normalizeModelName("google/gemini-3-flash"), "gemini-3-flash"); + }); + + it("strips 'meta/' vendor prefix", () => { + assert.strictEqual(normalizeModelName("meta/llama-4"), "llama-4"); + }); + + it("strips 'deepseek/' vendor prefix", () => { + assert.strictEqual(normalizeModelName("deepseek/deepseek-r1"), "deepseek-r1"); + }); + + it("strips 'xai/' vendor prefix", () => { + assert.strictEqual(normalizeModelName("xai/grok-4"), "grok-4"); + }); + + it("lowercases the model name", () => { + assert.strictEqual(normalizeModelName("Claude-Sonnet-4"), "claude-sonnet-4"); + }); + + it("lowercases vendor prefix before matching", () => { + assert.strictEqual(normalizeModelName("OpenAI/GPT-5.5"), "gpt-5.5"); + }); + + it("returns name unchanged when no vendor prefix matches", () => { + assert.strictEqual(normalizeModelName("my-custom-model"), "my-custom-model"); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// 2. transformToModelIntelligence() +// ═══════════════════════════════════════════════════════════ + +describe("transformToModelIntelligence()", () => { + it("ELO normalization: 1500 ELO (max) with range 1000-1500 → score ≈ 0.98", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }), + makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const topEntry = entries.find( + (e) => e.model === "top-model" && e.category === "default", + ); + + assert.ok(topEntry); + // taskFit = 0.4 + 0.58 * ((1500-1000) / 500) = 0.98 + assert.ok(Math.abs(topEntry.score - 0.98) < 0.001, `got ${topEntry.score}`); + }); + + it("ELO normalization: 1000 ELO (min) with range 1000-1500 → score ≈ 0.40", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "top-model", score: 1500, votes: 5000, rank: 1 }), + makeModelEntry({ model: "low-model", score: 1000, votes: 5000, rank: 2 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const lowEntry = entries.find( + (e) => e.model === "low-model" && e.category === "default", + ); + + assert.ok(lowEntry); + // taskFit = 0.4 + 0.58 * ((1000-1000) / 500) = 0.4 + assert.ok(Math.abs(lowEntry.score - 0.4) < 0.001, `got ${lowEntry.score}`); + }); + + it("votes < 100 → confidence='low'", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "sparse-model", score: 1200, votes: 50, rank: 5 }), + makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const entry = entries.find( + (e) => e.model === "sparse-model" && e.category === "default", + ); + + assert.ok(entry); + assert.strictEqual(entry.confidence, "low"); + }); + + it("votes >= 1000 → confidence='medium'", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "mid-model", score: 1200, votes: 1500, rank: 3 }), + makeModelEntry({ model: "baseline", score: 1100, votes: 5000, rank: 10 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const entry = entries.find( + (e) => e.model === "mid-model" && e.category === "default", + ); + + assert.ok(entry); + assert.strictEqual(entry.confidence, "medium"); + }); + + it("votes >= 5000 → confidence='high'", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "popular-model", score: 1300, votes: 8000, rank: 1 }), + makeModelEntry({ model: "baseline", score: 1100, votes: 3000, rank: 5 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const entry = entries.find( + (e) => e.model === "popular-model" && e.category === "default", + ); + + assert.ok(entry); + assert.strictEqual(entry.confidence, "high"); + }); + + it("category mapping: 'text' → [default, review, documentation, debugging]", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const categories = entries + .filter((e) => e.model === "text-model") + .map((e) => e.category) + .sort(); + + assert.deepStrictEqual(categories, [ + "debugging", + "default", + "documentation", + "review", + ]); + }); + + it("category mapping: 'code' → [coding]", () => { + const data = makeLeaderboardMap({ + code: [ + makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const categories = entries + .filter((e) => e.model === "code-model") + .map((e) => e.category); + + assert.deepStrictEqual(categories, ["coding"]); + }); + + it("expires_at is set to ~7 days in the future", () => { + const before = Date.now(); + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const after = Date.now(); + + const entry = entries.find( + (e) => e.model === "test-model" && e.category === "default", + ); + assert.ok(entry); + assert.ok(entry.expiresAt); + + const expiresMs = new Date(entry.expiresAt).getTime(); + const sevenDaysMs = 7 * 24 * 60 * 60 * 1000; + + assert.ok( + expiresMs >= before + sevenDaysMs - 2000, + `expiresAt too early: ${entry.expiresAt}`, + ); + assert.ok( + expiresMs <= after + sevenDaysMs + 2000, + `expiresAt too late: ${entry.expiresAt}`, + ); + }); + + it("source is 'arena_elo' for all entries", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + for (const entry of entries) { + assert.strictEqual(entry.source, "arena_elo"); + } + }); + + it("expands model aliases for known models", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ + model: "anthropic/claude-opus-4-6-thinking", + score: 1400, + votes: 5000, + rank: 1, + }), + ], + }); + + const entries = transformToModelIntelligence(data); + const models = entries.map((e) => e.model); + + assert.ok(models.includes("claude-opus-4-6-thinking")); + assert.ok(models.includes("claude-opus-4")); + assert.ok(models.includes("anthropic/claude-opus-4")); + }); + + it("empty leaderboard → no entries", () => { + const data = makeLeaderboardMap({ text: [] }); + const entries = transformToModelIntelligence(data); + assert.strictEqual(entries.length, 0); + }); + + it("skips unknown leaderboard categories (e.g. vision)", () => { + const data = makeLeaderboardMap({ + vision: [ + makeModelEntry({ model: "vision-model", score: 1300, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + assert.strictEqual(entries.length, 0); + }); + + it("preserves eloRaw from the leaderboard", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "test-model", score: 1337, votes: 5000, rank: 1 }), + makeModelEntry({ model: "other-model", score: 1100, votes: 3000, rank: 2 }), + ], + }); + + const entries = transformToModelIntelligence(data); + const entry = entries.find( + (e) => e.model === "test-model" && e.category === "default", + ); + assert.ok(entry); + assert.strictEqual(entry.eloRaw, 1337); + }); + + it("handles single-model leaderboard (eloRange = 1, avoids division by zero)", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "only-model", score: 1200, votes: 5000, rank: 1 }), + ], + }); + + const entries = transformToModelIntelligence(data); + assert.ok(entries.length > 0); + + const entry = entries.find( + (e) => e.model === "only-model" && e.category === "default", + ); + assert.ok(entry); + assert.ok(Math.abs(entry.score - 0.4) < 0.001); + }); + + it("rounds score to 4 decimal places", () => { + const data = makeLeaderboardMap({ + text: [ + makeModelEntry({ model: "model-a", score: 1300, votes: 200, rank: 1 }), + makeModelEntry({ model: "model-b", score: 1000, votes: 200, rank: 2 }), + ], + }); + + const entries = transformToModelIntelligence(data); + for (const entry of entries) { + const str = entry.score.toString(); + const dot = str.indexOf("."); + if (dot !== -1) { + assert.ok(str.length - dot - 1 <= 4, `score ${entry.score} > 4 decimals`); + } + } + }); +}); + +// ═══════════════════════════════════════════════════════════ +// 3. fetchArenaLeaderboards() +// ═══════════════════════════════════════════════════════════ + +describe("fetchArenaLeaderboards()", () => { + it("successful fetch with valid JSON returns both leaderboards", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + const codeData = makeLeaderboardData( + [makeModelEntry({ model: "code-model", score: 1300, votes: 5000, rank: 1 })], + "code", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) return jsonResponse(codeData); + return new Response("Not found", { status: 404 }); + }); + + const result = await fetchArenaLeaderboards(); + + assert.ok(result.text); + assert.ok(result.code); + assert.strictEqual(result.text.models.length, 1); + assert.strictEqual(result.code.models.length, 1); + }); + + it("failed fetch (non-200 status) throws with descriptive message", async () => { + mockFetch(async () => { + return new Response("Internal Server Error", { status: 500 }); + }); + + await assert.rejects( + () => fetchArenaLeaderboards(), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes("All Arena leaderboard fetches failed")); + return true; + }, + ); + }); + + it("all fetches fail (network error) → throws", async () => { + mockFetch(async () => { + throw new Error("Network error: ECONNREFUSED"); + }); + + await assert.rejects( + () => fetchArenaLeaderboards(), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes("All Arena leaderboard fetches failed")); + return true; + }, + ); + }); + + it("succeeds when one category fails but another succeeds", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "text-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return new Response("Error", { status: 500 }); + return new Response("Not found", { status: 404 }); + }); + + const result = await fetchArenaLeaderboards(); + assert.ok(result.text); + assert.strictEqual(result.code, undefined); + }); + + it("invalid JSON in response → throws when all responses are invalid", async () => { + mockFetch(async () => { + return new Response("not-json", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + }); + + await assert.rejects( + () => fetchArenaLeaderboards(), + (err: unknown) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes("All Arena leaderboard fetches failed")); + return true; + }, + ); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// 4. syncArenaElo() +// ═══════════════════════════════════════════════════════════ + +describe("syncArenaElo()", () => { + it("happy path: returns success=true with correct modelCount", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "gpt-5.5", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + const codeData = makeLeaderboardData( + [makeModelEntry({ model: "deepseek-r1", score: 1300, votes: 5000, rank: 1 })], + "code", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) return jsonResponse(codeData); + return new Response("Not found", { status: 404 }); + }); + + const result = await syncArenaElo(); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.source, "arena_elo"); + assert.ok(result.modelCount > 0); + + const dbCount = countArenaEloEntries(); + assert.ok(dbCount > 0); + assert.strictEqual(dbCount, result.modelCount); + }); + + it("happy path: entries in DB have correct source and categories", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "unique-test-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + await syncArenaElo(); + + const entries = getAllEntries().filter((e) => String(e.model) === "unique-test-model"); + const categories = entries.map((e) => String(e.category)).sort(); + assert.deepStrictEqual(categories, [ + "debugging", + "default", + "documentation", + "review", + ]); + + for (const entry of entries) { + assert.strictEqual(entry.source, "arena_elo"); + } + }); + + it("dryRun=true → does not call bulkUpsertModelIntelligence", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + const result = await syncArenaElo(true); + + assert.strictEqual(result.success, true); + assert.ok(result.modelCount > 0); + + const dbCount = countArenaEloEntries(); + assert.strictEqual(dbCount, 0, "dryRun should not write to DB"); + }); + + it("dryRun=true does not update lastSyncTime", async () => { + // Reset module-level state by observing that before this test's dryRun, + // lastSync should remain unchanged from whatever it was. + // Since we can't reset module-level vars, we verify that a dryRun + // after a non-dryRun doesn't overwrite the model count. + const statusBefore = getArenaEloSyncStatus(); + + const textData = makeLeaderboardData( + [makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + await syncArenaElo(true); + + const statusAfter = getArenaEloSyncStatus(); + // dryRun should not change the modelCount + assert.strictEqual(statusAfter.lastSyncModelCount, statusBefore.lastSyncModelCount); + }); + + it("API failure → returns success=false with error", async () => { + mockFetch(async () => { + throw new Error("Network down"); + }); + + const result = await syncArenaElo(); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.source, "arena_elo"); + assert.strictEqual(result.modelCount, 0); + assert.ok(result.error); + assert.ok( + result.error!.includes("All Arena leaderboard fetches failed"), + `unexpected: ${result.error}`, + ); + }); + + it("calls deleteExpiredIntelligence before writing new entries", async () => { + testAdapter.prepare( + "INSERT INTO model_intelligence (model, source, category, score, elo_raw, confidence, synced_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ).run("old-model", "arena_elo", "default", 0.5, 1000, "low", "2025-01-01T00:00:00Z", "2020-01-01T00:00:00Z"); + + assert.strictEqual(countArenaEloEntries(), 1); + + const textData = makeLeaderboardData( + [makeModelEntry({ model: "new-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + const syncResult = await syncArenaElo(); + assert.strictEqual(syncResult.success, true); + + const expiredEntry = testAdapter + .prepare("SELECT * FROM model_intelligence WHERE model = 'old-model'") + .get(); + assert.strictEqual(expiredEntry, undefined); + }); + + it("handles empty leaderboard gracefully (0 entries, no DB write)", async () => { + mockFetch(async (url: string) => { + if (url.includes("name=text")) + return jsonResponse(makeLeaderboardData([], "text")); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + const result = await syncArenaElo(); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.modelCount, 0); + assert.strictEqual(countArenaEloEntries(), 0); + }); + + it("updates lastSyncTime after successful sync", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ model: "test-model", score: 1200, votes: 5000, rank: 1 })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + await syncArenaElo(); + + const status = getArenaEloSyncStatus(); + assert.ok(status.lastSync); + assert.ok(status.lastSyncModelCount > 0); + }); + + it("model aliases are stored in DB alongside canonical names", async () => { + const textData = makeLeaderboardData( + [makeModelEntry({ + model: "anthropic/claude-opus-4-6-thinking", + score: 1400, + votes: 5000, + rank: 1, + })], + "text", + ); + + mockFetch(async (url: string) => { + if (url.includes("name=text")) return jsonResponse(textData); + if (url.includes("name=code")) + return jsonResponse(makeLeaderboardData([], "code")); + return new Response("Not found", { status: 404 }); + }); + + await syncArenaElo(); + + const entries = getAllEntries(); + const models = entries.map((e) => String(e.model)); + + assert.ok(models.includes("claude-opus-4-6-thinking")); + assert.ok(models.includes("claude-opus-4")); + }); +}); + +// ═══════════════════════════════════════════════════════════ +// 5. getArenaEloSyncStatus() +// ═══════════════════════════════════════════════════════════ + +describe("getArenaEloSyncStatus()", () => { + it("returns correct structure with all expected keys", () => { + const status = getArenaEloSyncStatus(); + + assert.ok("enabled" in status); + assert.ok("lastSync" in status); + assert.ok("lastSyncModelCount" in status); + assert.ok("nextSync" in status); + assert.ok("intervalMs" in status); + assert.ok("sources" in status); + }); + + it("returns sources containing 'arena_elo'", () => { + const status = getArenaEloSyncStatus(); + assert.deepStrictEqual(status.sources, ["arena_elo"]); + }); + + it("returns intervalMs as a positive number", () => { + const status = getArenaEloSyncStatus(); + assert.ok(typeof status.intervalMs === "number"); + assert.ok(status.intervalMs > 0); + }); + + it("returns lastSyncModelCount as 0 before any non-dryRun sync completes in this suite context", () => { + // Module-level lastSyncTime may leak from earlier tests in the suite, + // but the structural fields are always present. + const status = getArenaEloSyncStatus(); + assert.ok(typeof status.lastSync === "string" || status.lastSync === null); + assert.ok(typeof status.lastSyncModelCount === "number"); + assert.ok(typeof status.nextSync === "string" || status.nextSync === null); + }); + + it("reflects ARENA_ELO_SYNC_ENABLED env var", () => { + const original = process.env.ARENA_ELO_SYNC_ENABLED; + + process.env.ARENA_ELO_SYNC_ENABLED = "true"; + const enabledStatus = getArenaEloSyncStatus(); + assert.strictEqual(enabledStatus.enabled, true); + + process.env.ARENA_ELO_SYNC_ENABLED = "false"; + const disabledStatus = getArenaEloSyncStatus(); + assert.strictEqual(disabledStatus.enabled, false); + + if (original !== undefined) { + process.env.ARENA_ELO_SYNC_ENABLED = original; + } else { + delete process.env.ARENA_ELO_SYNC_ENABLED; + } + }); +}); + +// ═══════════════════════════════════════════════════════════ +// 6. stopArenaEloSync() +// ═══════════════════════════════════════════════════════════ + +describe("stopArenaEloSync()", () => { + it("does not throw when no timer is running", () => { + assert.doesNotThrow(() => stopArenaEloSync()); + }); + + it("can be called multiple times without error", () => { + stopArenaEloSync(); + assert.doesNotThrow(() => stopArenaEloSync()); + assert.doesNotThrow(() => stopArenaEloSync()); + }); +}); diff --git a/tests/unit/autoCombo/tieredRotation.test.ts b/tests/unit/autoCombo/tieredRotation.test.ts index 21c74ed098..dd2974b7bb 100644 --- a/tests/unit/autoCombo/tieredRotation.test.ts +++ b/tests/unit/autoCombo/tieredRotation.test.ts @@ -4,7 +4,7 @@ * and that tiered rotation distributes traffic fairly. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { selectProvider, type AutoComboConfig } from "../../../open-sse/services/autoCombo/engine"; import { calculateFactors, @@ -17,6 +17,15 @@ import { import { getTaskFitness } from "../../../open-sse/services/autoCombo/taskFitness"; import { resetDiversity } from "../../../open-sse/services/autoCombo/providerDiversity"; +// Stub DB calls introduced by PR #3660 (Arena ELO / models.dev intelligence scoring). +// This file tests rotation logic, not intelligence scoring — the DB shouldn't be +// initialized during these unit tests, and the stub makes them fast and isolated. +vi.mock("../../../src/lib/db/modelIntelligence.ts", () => ({ + getModelIntelligenceBySource: vi.fn(() => null), + setUserFitnessOverrideEntry: vi.fn(), + deleteUserFitnessOverrideEntry: vi.fn(), +})); + function makeCandidate(overrides: Partial): ProviderCandidate { return { provider: "unknown", diff --git a/tests/unit/chat-context-relay.test.ts b/tests/unit/chat-context-relay.test.ts index 044e38ea6c..a5c0d1f5a2 100644 --- a/tests/unit/chat-context-relay.test.ts +++ b/tests/unit/chat-context-relay.test.ts @@ -343,6 +343,19 @@ test("handleChat injects context-relay handoffs during live failover for Respons "Continue from where you left off" ); assert.match(relayedSecondaryCall.body.instructions, /Continue with the current task/); - // Handoff persists in DB because emergency fallback doesn't consume it - assert.ok(handoffDb.getHandoff(sessionId, "relay-live-combo")); + // The failover request must still target the requested model — the old + // emergency-fallback detour silently swapped it for openai/gpt-oss-120b. + assert.notEqual(relayedSecondaryCall.body.model, "openai/gpt-oss-120b"); + // The account switch must deliver the stored handoff to the secondary account + // (the whole point of context-relay). Before the emergency-fallback fix the + // switch happened inside the emergency detour with comboStrategy=null, so the + // handoff was never injected — and therefore never consumed. + assert.match( + relayedSecondaryCall.serializedBody, + /Carry over the Responses-native Codex session/, + "handoff summary must be injected into the secondary account's request" + ); + // Delivered handoffs are one-shot: consumed (deleted) after a successful + // injected request (src/sse/handlers/chat.ts deleteHandoff-on-success). + assert.equal(handoffDb.getHandoff(sessionId, "relay-live-combo"), null); }); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 0f9cae0471..6b15123611 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -459,7 +459,11 @@ test("handleChat returns the primary budget error when emergency fallback also f const json = (await response.json()) as any; assert.equal(response.status, 402); - assert.deepEqual(seenModels, ["gpt-4.1", "openai/gpt-oss-120b", "openai/gpt-oss-120b"]); + // Exactly ONE emergency hop: the routing layer resolves nvidia credentials and + // tries the free model once. (The second hop used to come from the executor-level + // fallback inside chatCore, which re-sent the OpenAI credentials to the nvidia + // endpoint — removed as a cross-provider credential leak.) + assert.deepEqual(seenModels, ["gpt-4.1", "openai/gpt-oss-120b"]); assert.match(json.error.message, /quota exceeded/i); }); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 73a9f3ad61..30d31c0d3b 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -2297,7 +2297,13 @@ test("chatCore records Claude prompt cache and cache usage metadata in call logs }); }); -test("chatCore serves emergency fallback responses for budget errors on non-streaming requests", async () => { +test("chatCore propagates budget errors without an executor-level emergency hop", async () => { + // The emergency budget fallback is orchestrated by the routing layer + // (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency + // provider through account selection. The old executor-level hop here re-sent + // the FAILING provider's credentials to the emergency provider's endpoint + // (cross-provider credential leak) — the engine must now surface the budget + // error as-is, with no extra upstream call. const { calls, result } = await invokeChatCore({ provider: "openai", model: "gpt-4o-mini", @@ -2307,30 +2313,28 @@ test("chatCore serves emergency fallback responses for budget errors on non-stre max_tokens: 9000, messages: [{ role: "user", content: "keep the request alive after budget exhaustion" }], }, - responseFactory(_captured, seenCalls) { - if (seenCalls.length === 1) { - return new Response( - JSON.stringify({ - error: { message: "insufficient funds on this account" }, - }), - { - status: 402, - headers: { "Content-Type": "application/json" }, - } - ); - } - - return buildOpenAIResponse(false, "served by emergency fallback"); + responseFactory() { + return new Response( + JSON.stringify({ + error: { message: "insufficient funds on this account" }, + }), + { + status: 402, + headers: { "Content-Type": "application/json" }, + } + ); }, }); - const payload = (await result.response.json()) as any; - - assert.equal(result.success, true); - assert.equal(calls.length, 2); - assert.equal(calls[1].body.model, "openai/gpt-oss-120b"); - assert.equal(calls[1].body.max_tokens, 4096); - assert.equal(payload.choices[0].message.content, "served by emergency fallback"); + assert.equal(result.success, false); + assert.equal(result.status, 402); + assert.equal(calls.length, 1, "no executor-level emergency hop may fire"); + const body = (await result.response.json()) as any; + assert.match(String(body?.error?.message ?? ""), /insufficient funds/); + assert.ok( + !calls.some((c: any) => String(c.body?.model ?? "").includes("gpt-oss-120b")), + "emergency fallback model must not be called at executor level" + ); }); test("chatCore injects progress events into streaming responses when requested", async () => { diff --git a/tests/unit/claude-empty-stream-error-3685.test.ts b/tests/unit/claude-empty-stream-error-3685.test.ts new file mode 100644 index 0000000000..719f2cb638 --- /dev/null +++ b/tests/unit/claude-empty-stream-error-3685.test.ts @@ -0,0 +1,278 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// #3685 — When a Claude stream completes with lifecycle events (message_start / +// message_delta / message_stop) but zero content_block events, the router was +// injecting a synthetic success message instead of failing over. Fix: emit a +// real SSE error event and call controller.error() so the combo layer can retry. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-3685-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { getPendingRequests, clearPendingRequests } = + await import("../../src/lib/usage/usageHistory.ts"); + +const enc = new TextEncoder(); + +async function readTransformed(chunks: string[], options: Record) { + const source = new ReadableStream({ + start(c) { + for (const chunk of chunks) c.enqueue(enc.encode(chunk)); + c.close(); + }, + }); + return new Response(source.pipeThrough(createSSEStream(options as any))).text(); +} + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + for (const entry of fs.readdirSync(TEST_DATA_DIR)) { + fs.rmSync(path.join(TEST_DATA_DIR, entry), { recursive: true, force: true }); + } + } +}); + +// --- Golden path: empty content-block stream (the bug case) should now error --- + +test("#3685 passthrough: empty Claude SSE (no content_block) rejects the stream", async () => { + let failurePayload: Record | null = null; + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_3685", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "content_filter", stop_sequence: null }, + usage: { output_tokens: 1 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "hello" }] }, + onFailure(p: Record) { + failurePayload = p; + }, + } + ), + /empty response/i, + "stream should reject with empty-response error" + ); + assert.ok(failurePayload, "onFailure callback must be invoked"); + assert.equal((failurePayload as any).status, 502); + assert.match((failurePayload as any).message as string, /empty response/i); +}); + +test("#3685 passthrough: empty Claude SSE emits event: error SSE line before aborting", async () => { + const collected: string[] = []; + const source = new ReadableStream({ + start(c) { + const chunks = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_3685b", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ]; + for (const chunk of chunks) c.enqueue(enc.encode(chunk)); + c.close(); + }, + }); + const transformed = source.pipeThrough( + createSSEStream({ + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + body: { messages: [{ role: "user", content: "hello" }] }, + } as any) + ); + const reader = transformed.getReader(); + const dec = new TextDecoder(); + let gotError = false; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + collected.push(dec.decode(value)); + } + } catch { + gotError = true; + } + assert.ok(gotError, "stream reader should throw on error"); + const full = collected.join(""); + assert.match(full, /event: error/, "SSE error event must be emitted before abort"); + assert.doesNotMatch( + full, + /event: content_block_start/, + "no synthetic content_block must be emitted" + ); +}); + +// --- Regression guards: excluded cases must NOT be turned into errors --- + +test("#3685 regression: stream with content_block events is NOT turned into an error", async () => { + // A max_tokens:1 ping returns exactly 1 token → content_block events exist. + // hasContentBlock = true → shouldInjectClaudeEmptyResponseOnFlush = false → no error. + const text = await readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_ping", + type: "message", + role: "assistant", + model: "claude-haiku-4-5", + content: [], + stop_reason: null, + usage: { input_tokens: 3, output_tokens: 0 }, + }, + })}\n\n`, + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}\n\n`, + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hi" }, + })}\n\n`, + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}\n\n`, + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "max_tokens", stop_sequence: null }, + usage: { output_tokens: 1 }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-haiku-4-5", + body: { messages: [{ role: "user", content: "ping" }] }, + } + ); + assert.match(text, /Hi/, "content must pass through untouched"); + assert.doesNotMatch(text, /event: error/, "must NOT emit an error event"); +}); + +test("#3685 pending request counter is decremented when empty-stream error fires", async () => { + // Regression guard for the bug caught by Cursor/Codex: emitClaudeEmptyStreamErrorAndAbort + // was marking the error with PENDING_REQUEST_CLEARED_MARKER but never calling + // trackPendingRequest(..., false). streamHandler.clearPendingRequest() trusts the marker + // and skips its own decrement, leaving the counter permanently inflated. + clearPendingRequests(); + const { trackPendingRequest } = await import("../../src/lib/usage/usageHistory.ts"); + + // Simulate the stream engine incrementing the counter at request start. + trackPendingRequest("claude-sonnet-4-6", "anthropic", "conn-test", true); + assert.equal( + getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"], + 1, + "pending count should start at 1 after request begins" + ); + + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_pending_test", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + usage: { input_tokens: 3, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "anthropic", + model: "claude-sonnet-4-6", + connectionId: "conn-test", + body: { messages: [{ role: "user", content: "hello" }] }, + } + ), + /empty response/i + ); + + // emitClaudeEmptyStreamErrorAndAbort must call trackPendingRequest(..., false) so the + // counter is back to 0 after the stream terminates. + assert.equal( + getPendingRequests().byModel["claude-sonnet-4-6 (anthropic)"], + 0, + "pending count must be 0 after empty-stream error — not left inflated" + ); +}); + +test("#3685 regression: upstream error event sets hasError=true and does NOT trigger empty-stream path", () => { + // If Claude itself emits type:error, lifecycle.hasError=true. + // shouldInjectClaudeEmptyResponseBeforeCurrentEvent / shouldInjectClaudeEmptyResponseOnFlush + // both check !lifecycle.hasError first — so neither our new error path nor the old synthetic + // path is triggered. Verified by inspecting the guard functions directly. + const lifecycle = { + hasMessageStart: true, + hasContentBlock: false, + hasMessageDelta: false, + hasMessageStop: false, + hasError: false, + syntheticContentInjected: false, + warningLogged: false, + }; + + // Simulate receiving an error event: sets hasError = true. + const lifecycleWithError = { ...lifecycle, hasError: true }; + + // shouldInjectClaudeEmptyResponseOnFlush equivalent: hasError blocks it + const wouldInjectOnFlush = + !lifecycleWithError.hasError && + !lifecycleWithError.hasContentBlock && + (lifecycleWithError.hasMessageStart || + lifecycleWithError.hasMessageDelta || + lifecycleWithError.hasMessageStop); + + assert.equal( + wouldInjectOnFlush, + false, + "hasError=true must prevent the empty-stream error path from firing" + ); +}); diff --git a/tests/unit/cli-setup-opencode.test.ts b/tests/unit/cli-setup-opencode.test.ts new file mode 100644 index 0000000000..714e901db8 --- /dev/null +++ b/tests/unit/cli-setup-opencode.test.ts @@ -0,0 +1,125 @@ +/** + * tests/unit/cli-setup-opencode.test.ts + * + * `omniroute setup opencode` wires the bundled @omniroute/opencode-plugin into a + * local OpenCode install: copies the built plugin into `/plugins/omniroute/` + * and registers a tuple entry in `opencode.json` (idempotent, replacing the legacy + * `opencode-omniroute-auth` entry from issue #3711). + * + * The command resolves the bundled plugin at module load, so the + * OMNIROUTE_OPENCODE_PLUGIN_DIR fixture override MUST be set before the import. + * `opts.configDir` keeps the test off the real OpenCode config dir on every platform. + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const FIXTURE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oc-setup-")); +const FAKE_PLUGIN_DIR = path.join(FIXTURE_ROOT, "plugin"); +const CONFIG_DIR = path.join(FIXTURE_ROOT, "opencode-config"); + +// Must be set before the module under test is imported (resolved at load time). +process.env.OMNIROUTE_OPENCODE_PLUGIN_DIR = FAKE_PLUGIN_DIR; + +const { runSetupOpenCodeCommand } = await import("../../bin/cli/commands/setup-open-code.mjs"); + +function makeFakePluginDist() { + fs.mkdirSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(FAKE_PLUGIN_DIR, "package.json"), + JSON.stringify({ name: "@omniroute/opencode-plugin", version: "0.0.0-test" }) + ); + fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.js"), "export {};\n"); + fs.writeFileSync(path.join(FAKE_PLUGIN_DIR, "dist", "index.cjs"), "module.exports = {};\n"); +} + +function readConfig() { + return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, "opencode.json"), "utf8")); +} + +describe("omniroute setup opencode", () => { + before(() => { + makeFakePluginDist(); + }); + + after(() => { + try { + fs.rmSync(FIXTURE_ROOT, { recursive: true, force: true }); + } catch { + // best-effort temp cleanup + } + }); + + it("installs the plugin and registers a tuple entry honouring --base-url (camelCased by Commander)", async () => { + const r = await runSetupOpenCodeCommand({ + configDir: CONFIG_DIR, + // Commander turns `--base-url` into `baseUrl` — the runner must accept it. + baseUrl: "http://10.0.0.5:20128", + nonInteractive: true, + }); + assert.equal(r.exitCode, 0); + + // dist copied into the OpenCode plugins dir + assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "dist", "index.js"))); + assert.ok(fs.existsSync(path.join(CONFIG_DIR, "plugins", "omniroute", "package.json"))); + + const cfg = readConfig(); + assert.ok(Array.isArray(cfg.plugin)); + assert.equal(cfg.plugin.length, 1); + const [modulePath, options] = cfg.plugin[0]; + assert.equal(modulePath, "./plugins/omniroute/dist/index.js"); + assert.equal(options.providerId, "omniroute"); + assert.equal(options.baseURL, "http://10.0.0.5:20128", "--base-url flag must reach the registered entry"); + }); + + it("is idempotent: re-running updates the entry in place instead of duplicating it", async () => { + const r = await runSetupOpenCodeCommand({ + configDir: CONFIG_DIR, + baseUrl: "http://10.0.0.9:20128", + nonInteractive: true, + }); + assert.equal(r.exitCode, 0); + + const cfg = readConfig(); + const omniEntries = cfg.plugin.filter( + (p: unknown) => Array.isArray(p) && (p[1] as { providerId?: string })?.providerId === "omniroute" + ); + assert.equal(omniEntries.length, 1, "re-run must not duplicate the entry"); + assert.equal(omniEntries[0][1].baseURL, "http://10.0.0.9:20128", "re-run updates baseURL in place"); + }); + + it("removes the legacy opencode-omniroute-auth entry (#3711) and preserves unrelated plugins", async () => { + const cfgPath = path.join(CONFIG_DIR, "opencode.json"); + fs.writeFileSync( + cfgPath, + JSON.stringify({ + plugin: [ + "opencode-omniroute-auth", + ["./plugins/other/dist/index.js", { providerId: "other" }], + ], + }) + ); + + const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true }); + assert.equal(r.exitCode, 0); + + const cfg = readConfig(); + const flat = JSON.stringify(cfg.plugin); + assert.ok(!flat.includes("opencode-omniroute-auth"), "legacy entry must be dropped"); + assert.ok(flat.includes('"providerId":"other"'), "unrelated plugin entries must survive"); + assert.equal(cfg.plugin.length, 2, "other + omniroute"); + }); + + it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => { + fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true }); + try { + const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true }); + assert.equal(r.exitCode, 1); + } finally { + makeFakePluginDist(); + } + }); +}); diff --git a/tests/unit/combo-config.test.ts b/tests/unit/combo-config.test.ts index 69eac65af2..783d2106c0 100644 --- a/tests/unit/combo-config.test.ts +++ b/tests/unit/combo-config.test.ts @@ -24,6 +24,7 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => { assert.equal(first.failoverBeforeRetry, true); assert.equal(first.maxSetRetries, 0); assert.equal(first.setRetryDelayMs, 2000); + assert.equal(first.reasoningTokenBufferEnabled, true); assert.equal(first.zeroLatencyOptimizationsEnabled, false); assert.equal(first.hedging, false); assert.equal(first.fallbackCompressionMode, "lite"); @@ -70,6 +71,39 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid assert.ok(!("healthCheckEnabled" in result)); }); +test("resolveComboConfig cascades reasoning token buffer feature flag", () => { + const providerDisabled = resolveComboConfig( + {}, + { + comboDefaults: { + reasoningTokenBufferEnabled: true, + }, + providerOverrides: { + openai: { + reasoningTokenBufferEnabled: false, + }, + }, + }, + "openai" + ); + + const comboEnabled = resolveComboConfig( + { + config: { + reasoningTokenBufferEnabled: true, + }, + }, + { + comboDefaults: { + reasoningTokenBufferEnabled: false, + }, + } + ); + + assert.equal(providerDisabled.reasoningTokenBufferEnabled, false); + assert.equal(comboEnabled.reasoningTokenBufferEnabled, true); +}); + test("resolveComboConfig preserves nested routing defaults for partial overrides", () => { const result = resolveComboConfig( { @@ -132,19 +166,23 @@ test("updateComboDefaultsSchema accepts arbitrarily large timeout defaults and p comboDefaults: { timeoutMs: 3600000, targetTimeoutMs: 30000, + reasoningTokenBufferEnabled: false, }, providerOverrides: { anthropic: { timeoutMs: 5400000, targetTimeoutMs: 45000, + reasoningTokenBufferEnabled: false, }, }, }); assert.equal(parsed.comboDefaults.timeoutMs, 3600000); assert.equal(parsed.comboDefaults.targetTimeoutMs, 30000); + assert.equal(parsed.comboDefaults.reasoningTokenBufferEnabled, false); assert.equal(parsed.providerOverrides.anthropic.timeoutMs, 5400000); assert.equal(parsed.providerOverrides.anthropic.targetTimeoutMs, 45000); + assert.equal(parsed.providerOverrides.anthropic.reasoningTokenBufferEnabled, false); }); test("combo config schema accepts explicit zero-latency opt-in controls", () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 3322d54f47..2f3972f785 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -15,6 +15,8 @@ const { resolveNestedComboModels, handleComboChat, } = await import("../../open-sse/services/combo.ts"); +const { resolveReasoningBufferedMaxTokens } = + await import("../../open-sse/services/reasoningTokenBuffer.ts"); const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts"); const { registerStrategy } = await import("../../open-sse/services/autoCombo/routerStrategy.ts"); const { touchSession, clearSessions } = await import("../../open-sse/services/sessionManager.ts"); @@ -2874,7 +2876,7 @@ test("handleComboChat aborts combo when 503 response does NOT contain the unavai test("#3587 reasoning model gets max_tokens buffer applied", async () => { saveModelsDevCapabilities({ openai: { - "gpt-4o-reasoning": capabilityEntry(4096, { reasoning: true }), + "gpt-4o-reasoning": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), }, }); @@ -2885,14 +2887,14 @@ test("#3587 reasoning model gets max_tokens buffer applied", async () => { name: "reasoning-buffer", models: ["openai/gpt-4o-reasoning"], }, - handleSingleModel: async (body: any) => { + handleSingleModel: async (body: Record) => { bodies.push(body); return okResponse(); }, isModelAvailable: async () => true, log: createLog(), settings: null, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2902,6 +2904,129 @@ test("#3587 reasoning model gets max_tokens buffer applied", async () => { assert.equal(bodies[0].max_tokens, 6144, "max_tokens should be buffered for reasoning model"); }); +test("#3587 reasoning buffer preserves max_tokens when the full buffer exceeds model cap", async () => { + saveModelsDevCapabilities({ + openai: { + "gemini-high-cap": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", 64000), + 64000, + "near-cap requests should not be inflated beyond the model's accepted range" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "4096"), + 6144, + "numeric string max_tokens should be normalized before applying a safe buffer" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", "not-a-number"), + null, + "non-numeric string max_tokens should not be changed" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/gemini-high-cap", 70000), + 65536, + "already over-cap max_tokens should be clamped to a known explicit cap" + ); + + const bodies: Array> = []; + const result = await handleComboChat({ + body: { max_tokens: 64000 }, + combo: { + name: "reasoning-buffer-near-cap", + models: ["openai/gemini-high-cap"], + }, + handleSingleModel: async (body: Record) => { + bodies.push(body); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(bodies.length, 1, "should have called handleSingleModel once"); + assert.equal(bodies[0].max_tokens, 64000, "max_tokens should remain within the cap"); +}); + +test("#3587 reasoning buffer is disabled without explicit model capability data", async () => { + assert.equal( + resolveReasoningBufferedMaxTokens("missing-provider/unknown-reasoning-model", 100), + null, + "unknown models must not receive heuristic token inflation" + ); + + saveModelsDevCapabilities({ + openai: { + "capless-reasoning": capabilityEntry(8192, { + reasoning: true, + limit_output: null, + }), + "default-cap-reasoning": capabilityEntry(8192, { + reasoning: true, + limit_output: 8192, + }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/capless-reasoning", 100), + null, + "reasoning metadata without an explicit output cap is not safe enough to inflate" + ); + assert.equal( + resolveReasoningBufferedMaxTokens("openai/default-cap-reasoning", 100), + null, + "default-sized caps are treated as unknown because registry fallbacks use the same value" + ); +}); + +test("#3588 reasoning token buffer feature flag preserves client max_tokens", async () => { + saveModelsDevCapabilities({ + openai: { + "flagged-reasoning": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + }, + }); + + assert.equal( + resolveReasoningBufferedMaxTokens("openai/flagged-reasoning", 4096, { enabled: false }), + null, + "disabled feature flag should skip reasoning token inflation" + ); + + const bodies: Array> = []; + const result = await handleComboChat({ + body: { max_tokens: 4096 }, + combo: { + name: "reasoning-buffer-disabled", + models: ["openai/flagged-reasoning"], + }, + handleSingleModel: async (body: Record) => { + bodies.push(body); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + reasoningTokenBufferEnabled: false, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(bodies.length, 1, "should have called handleSingleModel once"); + assert.equal(bodies[0].max_tokens, 4096, "feature flag should preserve client max_tokens"); +}); + test("#3587 non-reasoning model does not get max_tokens buffer", async () => { saveModelsDevCapabilities({ openai: { @@ -2916,14 +3041,14 @@ test("#3587 non-reasoning model does not get max_tokens buffer", async () => { name: "no-reasoning-buffer", models: ["openai/gpt-4o-plain"], }, - handleSingleModel: async (body: any) => { + handleSingleModel: async (body: Record) => { bodies.push(body); return okResponse(); }, isModelAvailable: async () => true, log: createLog(), settings: null, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2945,8 +3070,8 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async // the shared-`body` mutation that compounded the buffer on every RR iteration. saveModelsDevCapabilities({ openai: { - "rr-reasoning-a": capabilityEntry(4096, { reasoning: true }), - "rr-reasoning-b": capabilityEntry(4096, { reasoning: true }), + "rr-reasoning-a": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + "rr-reasoning-b": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), }, }); @@ -2958,7 +3083,7 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async strategy: "round-robin", models: ["openai/rr-reasoning-a", "openai/rr-reasoning-b"], }, - handleSingleModel: async (body: any, modelStr: any) => { + handleSingleModel: async (body: Record, modelStr: string) => { seen.push({ model: modelStr, maxTokens: body.max_tokens }); if (modelStr === "openai/rr-reasoning-a") { return new Response(JSON.stringify({ error: { message: "transient" } }), { @@ -2978,7 +3103,7 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async retryDelayMs: 1, }, }, - relayOptions: null as any, + relayOptions: null, allCombos: null, }); @@ -2992,3 +3117,96 @@ test("#3587 round-robin buffer does NOT compound across reasoning models", async "second reasoning model must ALSO buffer from original 4096, not 6144" ); }); + +test("#3588 round-robin honors disabled reasoning token buffer feature flag", async () => { + saveModelsDevCapabilities({ + openai: { + "rr-flagged-a": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + "rr-flagged-b": capabilityEntry(12000, { reasoning: true, limit_output: 12000 }), + }, + }); + + const seen: Array<{ model: string; maxTokens: unknown }> = []; + const result = await handleComboChat({ + body: { max_tokens: 4096 }, + combo: { + name: "rr-reasoning-buffer-disabled", + strategy: "round-robin", + models: ["openai/rr-flagged-a", "openai/rr-flagged-b"], + }, + handleSingleModel: async (body: Record, modelStr: string) => { + seen.push({ model: modelStr, maxTokens: body.max_tokens }); + if (modelStr === "openai/rr-flagged-a") { + return new Response(JSON.stringify({ error: { message: "transient" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + concurrencyPerModel: 1, + queueTimeoutMs: 5, + maxRetries: 0, + retryDelayMs: 1, + reasoningTokenBufferEnabled: false, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 200); + assert.equal(seen.length, 2, "both reasoning models should have been attempted"); + assert.equal(seen[0].maxTokens, 4096, "first model should preserve client max_tokens"); + assert.equal(seen[1].maxTokens, 4096, "second model should preserve client max_tokens"); +}); + +test("#3587 round-robin keeps near-cap reasoning max_tokens unchanged", async () => { + saveModelsDevCapabilities({ + openai: { + "rr-near-cap-a": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + "rr-near-cap-b": capabilityEntry(65536, { reasoning: true, limit_output: 65536 }), + }, + }); + + const seen: Array<{ model: string; maxTokens: unknown }> = []; + const result = await handleComboChat({ + body: { max_tokens: 64000 }, + combo: { + name: "rr-reasoning-near-cap", + strategy: "round-robin", + models: ["openai/rr-near-cap-a", "openai/rr-near-cap-b"], + }, + handleSingleModel: async (body: Record, modelStr: string) => { + seen.push({ model: modelStr, maxTokens: body.max_tokens }); + if (modelStr === "openai/rr-near-cap-a") { + return new Response(JSON.stringify({ error: { message: "transient" } }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: { + comboDefaults: { + concurrencyPerModel: 1, + queueTimeoutMs: 5, + maxRetries: 0, + retryDelayMs: 1, + }, + }, + relayOptions: null, + allCombos: null, + }); + + assert.equal(result.status, 200); + assert.equal(seen.length, 2, "both reasoning models should have been attempted"); + assert.equal(seen[0].maxTokens, 64000, "first reasoning model should keep max_tokens"); + assert.equal(seen[1].maxTokens, 64000, "second reasoning model should keep max_tokens"); +}); diff --git a/tests/unit/combo-strategy-fallbacks.test.ts b/tests/unit/combo-strategy-fallbacks.test.ts new file mode 100644 index 0000000000..f6e38ed34c --- /dev/null +++ b/tests/unit/combo-strategy-fallbacks.test.ts @@ -0,0 +1,454 @@ +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"; + +// Fallback coverage for the strategies that previously only had happy-path tests +// (fill-first, p2c, random, cost-optimized, strict-random), plus circuit-breaker +// HALF_OPEN recovery in the combo loop and strategy-name normalization. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-fallbacks-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { handleComboChat, preScreenTargets } = await import("../../open-sse/services/combo.ts"); +const core = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); +const { resetAllCircuitBreakers, getCircuitBreaker } = + await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts"); +const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); +const { clearSessions } = await import("../../open-sse/services/sessionManager.ts"); + +function createLog() { + const entries: any[] = []; + return { + info: (tag: any, msg: any) => entries.push({ level: "info", tag, msg }), + warn: (tag: any, msg: any) => entries.push({ level: "warn", tag, msg }), + error: (tag: any, msg: any) => entries.push({ level: "error", tag, msg }), + debug: (tag: any, msg: any) => entries.push({ level: "debug", tag, msg }), + entries, + }; +} + +function okResponse(body: any = { choices: [{ message: { content: "ok" } }] }) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function errorResponse(status: number, message: string = `Error ${status}`) { + return new Response(JSON.stringify({ error: { message } }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function cleanupTestDataDir() { + let lastError; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + return; + } catch (error: any) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + if (lastError) throw lastError; +} + +test.beforeEach(async () => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + _resetAllDecks(); + clearSessions(); + await cleanupTestDataDir(); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + await settingsDb.resetAllPricing(); + settingsDb.clearAllLKGP(); +}); + +test.after(async () => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + _resetAllDecks(); + settingsDb.clearAllLKGP(); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + await cleanupTestDataDir(); +}); + +test("fill-first falls back to the second target when the first fails", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "fill-first-fallback", + strategy: "fill-first", + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + if (modelStr === "openai/gpt-4o-mini") return errorResponse(503); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/gpt-4o-mini", "claude/sonnet"]); +}); + +test("p2c falls back to the remaining target when the selected one fails", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "p2c-fallback", + strategy: "p2c", + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + // Whichever target p2c selects first fails; the other one serves. + if (calls.length === 1) return errorResponse(500); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2, "both targets should be attempted"); + assert.notEqual(calls[0], calls[1], "fallback must go to the other target"); +}); + +test("p2c works with a single-target pool", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "p2c-single", + strategy: "p2c", + models: ["openai/gpt-4o-mini"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/gpt-4o-mini"]); +}); + +test("random strategy falls through the shuffle order until the healthy target serves", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "random-fallback", + strategy: "random", + models: ["openai/gpt-4o-mini", "claude/sonnet", "gemini/gemini-2.5-flash"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + // Only one target is healthy — wherever the shuffle puts it, the combo + // must keep falling through until it is reached. + if (modelStr === "gemini/gemini-2.5-flash") return okResponse(); + return errorResponse(500); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(calls[calls.length - 1], "gemini/gemini-2.5-flash"); + assert.ok(calls.length >= 1 && calls.length <= 3); +}); + +test("cost-optimized falls back to the next-cheapest target when the cheapest fails", async () => { + // Cross-provider on purpose: a 5xx on a target without a pinned connectionId + // conservatively marks the whole provider for the rest of the request + // (#1731v2), so a same-provider second target would be skipped, not retried. + await settingsDb.updatePricing({ + openai: { + "gpt-4o-nano": { input: 0.1, output: 0.2 }, + }, + claude: { + sonnet: { input: 5, output: 10 }, + }, + }); + + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "cost-fallback", + strategy: "cost-optimized", + models: ["claude/sonnet", "openai/gpt-4o-nano"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + if (modelStr === "openai/gpt-4o-nano") return errorResponse(500); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual( + calls, + ["openai/gpt-4o-nano", "claude/sonnet"], + "cheapest first, then next-cheapest on failure" + ); +}); + +test("cost-optimized preserves the original order on price ties", async () => { + await settingsDb.updatePricing({ + openai: { + "gpt-4o-mini": { input: 1, output: 2 }, + "gpt-4o": { input: 1, output: 2 }, + }, + }); + + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "cost-tie", + strategy: "cost-optimized", + models: ["openai/gpt-4o", "openai/gpt-4o-mini"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/gpt-4o"], "tie keeps the configured order (stable sort)"); +}); + +test("strict-random falls back to the remaining target when the deck pick fails", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "strict-random-fallback", + strategy: "strict-random", + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + if (calls.length === 1) return errorResponse(500); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.equal(calls.length, 2); + assert.notEqual(calls[0], calls[1]); +}); + +test("strict-random survives a stale deck entry after a target is removed", async () => { + const comboTwoTargets = { + name: "strict-random-stale", + strategy: "strict-random", + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0 }, + }; + + // First request builds the deck with both targets. + const first = await handleComboChat({ + body: {}, + combo: comboTwoTargets, + handleSingleModel: async () => okResponse(), + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.equal(first.ok, true); + + // Same combo name, but one target was removed by the operator. The deck may + // still hold the removed target's key; the combo must degrade gracefully. + const calls: string[] = []; + const second = await handleComboChat({ + body: {}, + combo: { + ...comboTwoTargets, + models: ["claude/sonnet"], + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(second.ok, true, "stale deck entry must not break routing"); + assert.deepEqual(calls, ["claude/sonnet"]); +}); + +test("unknown strategy value normalizes to priority order", async () => { + const calls: string[] = []; + const result = await handleComboChat({ + body: {}, + combo: { + name: "typo-strategy", + strategy: "weigthed", // typo on purpose + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0 }, + }, + handleSingleModel: async (_body: any, modelStr: string) => { + calls.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + + assert.equal(result.ok, true); + assert.deepEqual(calls, ["openai/gpt-4o-mini"], "typo falls back to priority (first model)"); +}); + +test("combo skips a provider while its breaker is OPEN and attempts it again after the reset timeout (HALF_OPEN)", async () => { + const breaker = getCircuitBreaker("openai", { failureThreshold: 1, resetTimeout: 40 }); + try { + await breaker.execute(async () => { + throw new Error("simulated provider failure"); + }); + } catch { + // expected — trips the breaker OPEN + } + assert.equal(breaker.getStatus().state, "OPEN"); + + const comboDef = { + name: "half-open-recovery", + strategy: "priority", + models: ["openai/gpt-4o-mini", "claude/sonnet"], + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + }; + + // While OPEN: the openai target must be skipped, claude serves. + const callsWhileOpen: string[] = []; + const blocked = await handleComboChat({ + body: {}, + combo: comboDef, + handleSingleModel: async (_body: any, modelStr: string) => { + callsWhileOpen.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.equal(blocked.ok, true); + assert.deepEqual(callsWhileOpen, ["claude/sonnet"], "OPEN breaker target must be skipped"); + + // After the reset timeout the breaker reads HALF_OPEN — the combo must probe + // the provider again instead of excluding it forever (lazy recovery contract). + await new Promise((resolve) => setTimeout(resolve, 80)); + assert.equal(breaker.getStatus().state, "HALF_OPEN"); + + const callsAfterExpiry: string[] = []; + const probed = await handleComboChat({ + body: {}, + combo: comboDef, + handleSingleModel: async (_body: any, modelStr: string) => { + callsAfterExpiry.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.equal(probed.ok, true); + assert.deepEqual( + callsAfterExpiry, + ["openai/gpt-4o-mini"], + "HALF_OPEN provider must be probed again" + ); +}); + +test("preScreenTargets marks an expired-OPEN (HALF_OPEN) target as available", async () => { + const breaker = getCircuitBreaker("openai", { failureThreshold: 1, resetTimeout: 30 }); + try { + await breaker.execute(async () => { + throw new Error("simulated failure"); + }); + } catch { + // expected + } + await new Promise((resolve) => setTimeout(resolve, 60)); + + const targets = [ + { + kind: "model" as const, + stepId: "step-1", + executionKey: "openai/gpt-4o", + modelStr: "openai/gpt-4o", + provider: "openai", + providerId: "conn-1", + connectionId: "conn-1", + weight: 1, + label: null, + }, + ]; + + const results = await preScreenTargets(targets as any); + const openaiResult = results.get("openai/gpt-4o"); + assert.ok(openaiResult, "openai target should have a pre-screen result"); + assert.equal( + openaiResult.available, + true, + "HALF_OPEN (expired OPEN) target must be available for a probe request" + ); +}); diff --git a/tests/unit/combo-streaming-empty-content-failover.test.ts b/tests/unit/combo-streaming-empty-content-failover.test.ts new file mode 100644 index 0000000000..249a70bfb7 --- /dev/null +++ b/tests/unit/combo-streaming-empty-content-failover.test.ts @@ -0,0 +1,283 @@ +/** + * Issue #3685 — streaming combos must fail over to the next target when the + * upstream Claude stream emits a complete lifecycle (message_start → + * message_delta with stop_reason → message_stop) but ZERO content_block_* + * events (e.g. content_filter). Previously `validateResponseQuality` returned + * `{ valid: true }` for ALL streaming responses, so the combo loop never saw + * the empty response as a failure and never advanced to the next target. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateResponseQuality } = await import("../../open-sse/services/combo.ts"); + +const encoder = new TextEncoder(); +const silentLog = { warn: () => {} }; + +/** Build a ReadableStream from an array of SSE-formatted strings (single chunk). */ +function claudeSseStream(events: string[]): ReadableStream { + const body = events.join("\n") + "\n"; + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(body)); + controller.close(); + }, + }); +} + +/** + * Build a ReadableStream that emits each SSE event as a SEPARATE chunk, + * simulating a real incremental network delivery. + */ +function claudeSseStreamMultiChunk(events: string[]): ReadableStream { + const chunks = events.map((e) => encoder.encode(e + "\n")); + let idx = 0; + return new ReadableStream({ + pull(controller) { + if (idx < chunks.length) { + controller.enqueue(chunks[idx++]); + } else { + controller.close(); + } + }, + }); +} + +/** Build a mock Claude 200 streaming response with no content blocks (content_filter case). */ +function makeEmptyClaudeStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_empty", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "content_filter", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 0 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +/** Build a mock Claude 200 streaming response WITH a content block (normal case). */ +function makeNonEmptyClaudeStream(): Response { + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_test_content", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello, world!" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ + type: "content_block_stop", + index: 0, + })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 5 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + return new Response(claudeSseStream(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("#3685 empty Claude stream (content_filter, no content blocks) is marked invalid", async () => { + const res = makeEmptyClaudeStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + false, + `expected invalid for empty content-filtered stream, got valid=true (reason: ${out.reason})` + ); + assert.match( + out.reason ?? "", + /empty/i, + `reason should mention 'empty', got: "${out.reason}"` + ); +}); + +test("#3685 non-empty Claude stream (has content blocks) remains valid", async () => { + const res = makeNonEmptyClaudeStream(); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal( + out.valid, + true, + `expected valid for non-empty stream, got invalid: ${out.reason}` + ); + // The clonedResponse must be present so the combo loop can pipe the stream body + assert.ok(out.clonedResponse, "clonedResponse must be returned for valid streaming response"); + assert.ok(out.clonedResponse!.body, "clonedResponse must have a body stream"); +}); + +test("#3685 non-SSE streaming response (e.g. plain JSON 200) still passes through as valid", async () => { + // A streaming=true call that returns plain JSON is not our target — preserve existing behavior. + const res = new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, "non-SSE streaming response should still be valid"); +}); + +test("#3685 empty Claude stream without message_start lifecycle → valid (incomplete lifecycle, not content_filter)", async () => { + // A stream that only has partial events (e.g. disconnected before message_start) + // should not trigger the failover since the lifecycle isn't complete — this is + // handled by other mechanisms (stream readiness timeout). + const partialEvents = [ + `event: ping\ndata: ${JSON.stringify({ type: "ping" })}`, + "", + ]; + const res = new Response(claudeSseStream(partialEvents), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const out = await validateResponseQuality(res, true, silentLog); + assert.equal(out.valid, true, "incomplete lifecycle (no message_start) should pass through"); +}); + +test("#3685 streaming is preserved for non-empty response: clonedResponse body yields full original SSE byte sequence in order", async () => { + // Use a multi-chunk stream to simulate real incremental delivery. + // The content_block_start arrives in its own chunk so the peek loop can + // detect it mid-stream and stop buffering — the rest should forward via + // the original reader. + const events = [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_multipart", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 0 }, + }, + })}`, + "", + `event: content_block_start\ndata: ${JSON.stringify({ + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + })}`, + "", + `event: content_block_delta\ndata: ${JSON.stringify({ + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: ", world!" }, + })}`, + "", + `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`, + "", + `event: message_delta\ndata: ${JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { input_tokens: 0, output_tokens: 7 }, + })}`, + "", + `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}`, + "", + ]; + + // Build the original byte sequence for comparison. + const originalBody = events.map((e) => e + "\n").join(""); + const originalBytes = encoder.encode(originalBody); + + const res = new Response(claudeSseStreamMultiChunk(events), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const out = await validateResponseQuality(res, true, silentLog); + + assert.equal(out.valid, true, "non-empty multi-chunk stream must be valid"); + assert.ok(out.clonedResponse, "clonedResponse must be present"); + assert.ok(out.clonedResponse!.body, "clonedResponse must have a readable body"); + + // Drain the clonedResponse body and reconstruct the full byte sequence. + const reader = out.clonedResponse!.body!.getReader(); + const receivedChunks: Uint8Array[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + const { done, value } = await reader.read(); + if (done) break; + receivedChunks.push(value); + } + const totalLength = receivedChunks.reduce((sum, c) => sum + c.length, 0); + const reconstructed = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of receivedChunks) { + reconstructed.set(chunk, offset); + offset += chunk.length; + } + + // The reconstructed bytes must exactly match the original SSE byte sequence — + // no data is lost, duplicated, or reordered by the bounded-peek mechanism. + assert.deepEqual( + reconstructed, + originalBytes, + "clonedResponse body must reproduce the FULL original SSE byte sequence (buffered prefix + piped remainder = original)" + ); + + // Verify the response carries SSE content blocks in the decoded text, + // confirming real content was streamed through. + const decoded = new TextDecoder().decode(reconstructed); + assert.ok(decoded.includes("content_block_start"), "decoded body must contain content_block_start"); + assert.ok(decoded.includes("Hello"), "decoded body must contain the actual text content"); + assert.ok(decoded.includes(", world!"), "decoded body must contain the full text delta"); +}); diff --git a/tests/unit/cost-explorer-params.test.ts b/tests/unit/cost-explorer-params.test.ts new file mode 100644 index 0000000000..6eb00b3edc --- /dev/null +++ b/tests/unit/cost-explorer-params.test.ts @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseApiKeyIds, + parseCostRange, + parseExplorerGroupBy, +} from "../../src/app/(dashboard)/dashboard/costs/costExplorerParams.ts"; + +// ─── parseCostRange ──────────────────────────────────────────────────────── + +test("parseCostRange accepts every valid range", () => { + for (const r of ["7d", "30d", "90d", "all"]) { + assert.equal(parseCostRange(r), r); + } +}); + +test("parseCostRange falls back to 30d for null/invalid input", () => { + assert.equal(parseCostRange(null), "30d"); + assert.equal(parseCostRange(""), "30d"); + assert.equal(parseCostRange("1y"), "30d"); + assert.equal(parseCostRange("ALL"), "30d"); // case-sensitive on purpose + assert.equal(parseCostRange("30d; DROP TABLE"), "30d"); // untrusted URL param +}); + +// ─── parseExplorerGroupBy ────────────────────────────────────────────────── + +test("parseExplorerGroupBy accepts every valid group", () => { + for (const g of ["provider", "model", "apiKey", "account", "serviceTier"]) { + assert.equal(parseExplorerGroupBy(g), g); + } +}); + +test("parseExplorerGroupBy falls back to provider for null/invalid input", () => { + assert.equal(parseExplorerGroupBy(null), "provider"); + assert.equal(parseExplorerGroupBy(""), "provider"); + assert.equal(parseExplorerGroupBy("apikey"), "provider"); // wrong casing + assert.equal(parseExplorerGroupBy("__proto__"), "provider"); +}); + +// ─── parseApiKeyIds ──────────────────────────────────────────────────────── + +test("parseApiKeyIds returns empty list for null/empty", () => { + assert.deepEqual(parseApiKeyIds(null), []); + assert.deepEqual(parseApiKeyIds(""), []); + assert.deepEqual(parseApiKeyIds(" "), []); + assert.deepEqual(parseApiKeyIds(",, ,"), []); +}); + +test("parseApiKeyIds splits, trims, and drops blanks", () => { + assert.deepEqual(parseApiKeyIds("a, b ,c"), ["a", "b", "c"]); + assert.deepEqual(parseApiKeyIds(" key-1 "), ["key-1"]); +}); + +test("parseApiKeyIds de-duplicates while preserving first-seen order", () => { + assert.deepEqual(parseApiKeyIds("a,b,a,c,b"), ["a", "b", "c"]); +}); diff --git a/tests/unit/executor-qwen-web.test.ts b/tests/unit/executor-qwen-web.test.ts index 2cf4893c85..a1dff956e3 100644 --- a/tests/unit/executor-qwen-web.test.ts +++ b/tests/unit/executor-qwen-web.test.ts @@ -1,28 +1,293 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; const mod = await import("../../open-sse/executors/qwen-web.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { FREE_MODEL_BUDGETS } = await import("../../open-sse/config/freeModelCatalog.data.ts"); -describe("QwenWebExecutor", () => { +type FetchCall = { url: string; init: any }; + +const realFetch = globalThis.fetch; +let calls: FetchCall[] = []; + +/** Build an SSE Response from an array of v2 "phase" delta events. */ +function sseResponse(events: Array>): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (const ev of events) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(ev)}\n\n`)); + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function chatCreatedResponse(id = "chat-abc"): Response { + return new Response(JSON.stringify({ success: true, data: { id } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +/** The 504 + HTML page Alibaba's gateway returns for the retired v1 endpoint + * and for WAF-blocked requests. */ +function wafHtmlResponse(status = 504): Response { + return new Response( + "\n504 Gateway Time-out\n\n" + + '

504 Gateway Time-out

\n
alibaba-ga
\n' + + '\n\n', + { status, headers: { "content-type": "text/html; charset=utf-8" } } + ); +} + +beforeEach(() => { + calls = []; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("QwenWebExecutor (v2 migration)", () => { it("can be instantiated", () => { - const executor = new mod.QwenWebExecutor(); - assert.ok(executor); + assert.ok(new mod.QwenWebExecutor()); }); - it("execute returns error on fetch failure", async () => { + it("uses the v2 two-step flow: chats/new then chat/completions?chat_id=", async () => { + globalThis.fetch = (async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse("chat-xyz"); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "Hello", status: "typing" } }] }, + { choices: [{ delta: { phase: "answer", content: " world", status: "finished" } }] }, + ]); + }) as any; + const executor = new mod.QwenWebExecutor(); - try { - const result = await executor.execute({ - model: "qwen-plus", - body: { messages: [{ role: "user", content: "hi" }] }, - stream: false, - credentials: { apiKey: "" }, - signal: null, - }); - assert.ok(result.response instanceof Response); - assert.ok(result.url.includes("qwen.ai")); - } catch { - // Network error expected + const result = await executor.execute({ + model: "qwen3.7-max", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=jwt-tok; cna=abc; ssxmod_itna=1-xyz" }, + signal: null, + } as any); + + assert.equal(calls.length, 2, "should make exactly two upstream calls"); + assert.match(calls[0].url, /\/api\/v2\/chats\/new$/); + assert.equal(calls[0].init.method, "POST"); + assert.match(calls[1].url, /\/api\/v2\/chat\/completions\?chat_id=chat-xyz/); + assert.equal(calls[1].init.method, "POST"); + + // chats/new payload shape + const newBody = JSON.parse(calls[0].init.body); + assert.deepEqual(newBody.models, ["qwen3.7-max"]); + assert.equal(newBody.chat_type, "t2t"); + assert.equal(newBody.chat_mode, "normal"); + + // completion payload references the created chat_id + const compBody = JSON.parse(calls[1].init.body); + assert.equal(compBody.chat_id, "chat-xyz"); + assert.equal(compBody.model, "qwen3.7-max"); + assert.equal(compBody.messages[0].role, "user"); + assert.equal(compBody.messages[0].content, "hi"); + + const json = (await result.response.json()) as any; + assert.equal(json.choices[0].message.content, "Hello world"); + }); + + it("replays the full cookie jar and the extracted bearer token on every call", async () => { + globalThis.fetch = (async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] }, + ]); + }) as any; + + const cookieBlob = "token=jwt-secret; cna=CNA1; ssxmod_itna=1-AAA; ssxmod_itna2=1-BBB"; + const executor = new mod.QwenWebExecutor(); + await executor.execute({ + model: "qwen3.7-plus", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: cookieBlob }, + signal: null, + } as any); + + for (const call of calls) { + const headers = call.init.headers as Record; + const cookie = headers.Cookie || headers.cookie || ""; + assert.match(cookie, /cna=CNA1/, "full cookie jar must be replayed"); + assert.match(cookie, /ssxmod_itna=1-AAA/, "WAF cookies must be replayed"); + const auth = headers.Authorization || headers.authorization || ""; + assert.equal(auth, "Bearer jwt-secret", "bearer token extracted from token= cookie"); } }); + + it("sends the anti-bot headers required by the v2 endpoint", async () => { + globalThis.fetch = (async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] }, + ]); + }) as any; + + const executor = new mod.QwenWebExecutor(); + await executor.execute({ + model: "qwen3.7-plus", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=t; cna=c" }, + signal: null, + } as any); + + const headers = calls[0].init.headers as Record; + assert.ok(headers["bx-v"], "bx-v header present"); + assert.ok(headers["bx-umidtoken"], "bx-umidtoken header present"); + assert.equal(headers.source || headers.Source, "web", "source: web header present"); + }); + + it("maps the thinking phase to reasoning_content, not the answer content", async () => { + globalThis.fetch = (async (url: any) => { + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "think", content: "let me think", status: "typing" } }] }, + { choices: [{ delta: { phase: "think", content: "...", status: "finished" } }] }, + { choices: [{ delta: { phase: "answer", content: "Final answer", status: "finished" } }] }, + ]); + }) as any; + + const executor = new mod.QwenWebExecutor(); + const result = await executor.execute({ + model: "qwen3.7-max", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=t; cna=c" }, + signal: null, + } as any); + + const json = (await result.response.json()) as any; + assert.equal(json.choices[0].message.content, "Final answer"); + assert.ok( + !String(json.choices[0].message.content).includes("let me think"), + "thinking content must not leak into the answer" + ); + }); + + it("classifies the retired-v1 / WAF 504 HTML page as a clear auth error (not raw HTML)", async () => { + globalThis.fetch = (async (url: any) => { + if (String(url).includes("/api/v2/chats/new")) return wafHtmlResponse(504); + return chatCreatedResponse(); + }) as any; + + const executor = new mod.QwenWebExecutor(); + const result = await executor.execute({ + model: "qwen3.7-max", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=stale; cna=c" }, + signal: null, + } as any); + + assert.ok([401, 403].includes(result.response.status), "should map to an auth status"); + const json = (await result.response.json()) as any; + const msg = String(json.error?.message || ""); + assert.ok(!msg.includes(" { + globalThis.fetch = (async (url: any) => { + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "Hi", status: "typing" } }] }, + { choices: [{ delta: { phase: "answer", content: " there", status: "finished" } }] }, + ]); + }) as any; + + const executor = new mod.QwenWebExecutor(); + const result = await executor.execute({ + model: "qwen3.7-max", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: true, + credentials: { apiKey: "token=t; cna=c" }, + signal: null, + } as any); + + const text = await result.response.text(); + assert.match(text, /chat\.completion\.chunk/); + assert.match(text, /"content":"Hi"/); + assert.match(text, /"content":" there"/); + assert.match(text, /data: \[DONE\]/); + }); + + it("accepts a bare token (back-compat) without a cookie jar", async () => { + globalThis.fetch = (async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] }, + ]); + }) as any; + + const executor = new mod.QwenWebExecutor(); + await executor.execute({ + model: "qwen3.7-plus", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "barejwttoken" }, + signal: null, + } as any); + + const headers = calls[0].init.headers as Record; + assert.equal(headers.Authorization || headers.authorization, "Bearer barejwttoken"); + }); + + it("registry points at the v2 endpoint and the current model catalog", () => { + const provider = (REGISTRY as any)["qwen-web"]; + assert.ok(provider, "qwen-web must be registered"); + assert.match(provider.baseUrl, /\/api\/v2\/chat\/completions$/, "registry must use v2 endpoint"); + const ids = provider.models.map((m: any) => m.id); + assert.deepEqual(ids.sort(), ["qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]); + }); + + it("free-model catalog lists the current qwen-web ids (not the retired ones)", () => { + const qwenModels = (FREE_MODEL_BUDGETS as any[]).filter((m) => m.provider === "qwen-web"); + const ids = qwenModels.map((m) => m.modelId); + assert.ok(ids.includes("qwen3.7-max"), "catalog must list qwen3.7-max"); + assert.ok(!ids.includes("qwen-plus"), "retired qwen-plus must be gone"); + assert.ok( + qwenModels.every((m) => m.freeType !== "discontinued"), + "qwen-web is no longer discontinued after the v2 migration" + ); + }); + + it("maps legacy model ids to the current upstream catalog", async () => { + globalThis.fetch = (async (url: any, init: any = {}) => { + calls.push({ url: String(url), init }); + if (String(url).includes("/api/v2/chats/new")) return chatCreatedResponse(); + return sseResponse([ + { choices: [{ delta: { phase: "answer", content: "ok", status: "finished" } }] }, + ]); + }) as any; + + const executor = new mod.QwenWebExecutor(); + await executor.execute({ + model: "qwen3-max", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "token=t; cna=c" }, + signal: null, + } as any); + + const newBody = JSON.parse(calls[0].init.body); + assert.match(newBody.models[0], /^qwen3\.[67]-/, "legacy qwen3-max maps to a current model id"); + }); }); diff --git a/tests/unit/executor-vertex-extended.test.ts b/tests/unit/executor-vertex-extended.test.ts index 02cce26a2a..f7ac599518 100644 --- a/tests/unit/executor-vertex-extended.test.ts +++ b/tests/unit/executor-vertex-extended.test.ts @@ -51,18 +51,24 @@ test("VertexExecutor.buildUrl defaults to us-central1 and unknown-project when p apiKey: createServiceAccountJson({ includeProjectId: false }), providerSpecificData: {}, }); - const invalidJson = executor.buildUrl("gemini-2.5-flash", false, 0, { - apiKey: "not-json", - }); assert.equal( missingProject, "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" ); +}); + +test("VertexExecutor.buildUrl routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const expressUrl = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "express-key-abc", + }); + assert.equal( - invalidJson, - "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + expressUrl, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-abc" ); + assert.ok(!expressUrl.includes("/projects/"), "Express key URL must not route through a project path"); }); test("VertexExecutor.buildUrl routes partner and org-prefixed models to the global partner endpoint", () => { @@ -191,20 +197,12 @@ test("VertexExecutor.execute skips Service Account parsing when accessToken is a } }); -test("VertexExecutor.execute rejects invalid or incomplete Service Account JSON clearly", async () => { +test("VertexExecutor.execute rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); - await assert.rejects( - () => - executor.execute({ - model: "gemini-2.5-flash", - body: { contents: [] }, - stream: false, - credentials: { apiKey: "not-json" }, - }), - /Service Account JSON/ - ); - + // A JSON object missing client_email/private_key is treated as a Service Account (not an Express + // key) and must fail clearly when minting a JWT. A non-JSON string is an Express key (covered + // elsewhere) and is intentionally NOT rejected here. await assert.rejects( () => executor.execute({ diff --git a/tests/unit/gemini-models-parser.test.ts b/tests/unit/gemini-models-parser.test.ts new file mode 100644 index 0000000000..bdaac7f0fd --- /dev/null +++ b/tests/unit/gemini-models-parser.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseGeminiModelsList } from "../../src/lib/providerModels/geminiModelsParser"; + +// A representative slice of the live generativelanguage v1beta/models response — including the +// image models (gemini-*-image via generateContent, imagen-* via predict) that the Vertex catalog +// must surface dynamically. +const SAMPLE = { + models: [ + { + name: "models/gemini-2.5-flash", + displayName: "Gemini 2.5 Flash", + inputTokenLimit: 1048576, + outputTokenLimit: 65536, + supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"], + thinking: true, + }, + { + name: "models/gemini-3-pro-image-preview", + displayName: "Gemini 3 Pro Image Preview", + supportedGenerationMethods: ["generateContent", "countTokens"], + }, + { + name: "models/imagen-4.0-generate-001", + displayName: "Imagen 4.0", + supportedGenerationMethods: ["predict"], + }, + { + name: "models/text-embedding-004", + displayName: "Text Embedding 004", + supportedGenerationMethods: ["embedContent"], + }, + { + name: "models/gemini-live-2.5-flash", + displayName: "Gemini Live", + supportedGenerationMethods: ["bidiGenerateContent"], + }, + ], +}; + +test("parseGeminiModelsList strips the models/ prefix and maps display name", () => { + const models = parseGeminiModelsList(SAMPLE); + const flash = models.find((m) => m.id === "gemini-2.5-flash"); + assert.ok(flash, "gemini-2.5-flash should be present"); + assert.equal(flash!.name, "Gemini 2.5 Flash"); + assert.equal(flash!.inputTokenLimit, 1048576); + assert.equal(flash!.outputTokenLimit, 65536); + assert.equal(flash!.supportsThinking, true); + assert.deepEqual(flash!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview"); + assert.ok(proImage, "gemini-3-pro-image-preview should be present"); + // gemini-*-image models generate images via generateContent (chat-with-modalities path). + assert.deepEqual(proImage!.supportedEndpoints, ["chat"]); +}); + +test("parseGeminiModelsList maps Imagen predict models to the images endpoint", () => { + const models = parseGeminiModelsList(SAMPLE); + const imagen = models.find((m) => m.id === "imagen-4.0-generate-001"); + assert.ok(imagen, "imagen-4.0-generate-001 should be present"); + assert.deepEqual(imagen!.supportedEndpoints, ["images"]); +}); + +test("parseGeminiModelsList maps embedContent and bidiGenerateContent", () => { + const models = parseGeminiModelsList(SAMPLE); + assert.deepEqual( + models.find((m) => m.id === "text-embedding-004")!.supportedEndpoints, + ["embeddings"] + ); + assert.deepEqual( + models.find((m) => m.id === "gemini-live-2.5-flash")!.supportedEndpoints, + ["audio"] + ); +}); + +test("parseGeminiModelsList defaults to chat and tolerates empty/missing input", () => { + assert.deepEqual(parseGeminiModelsList({}), []); + assert.deepEqual(parseGeminiModelsList(null), []); + const [m] = parseGeminiModelsList({ models: [{ name: "models/mystery" }] }); + assert.equal(m.id, "mystery"); + assert.deepEqual(m.supportedEndpoints, ["chat"]); +}); diff --git a/tests/unit/kiro-iam-profilearn-usage.test.ts b/tests/unit/kiro-iam-profilearn-usage.test.ts new file mode 100644 index 0000000000..7c98eb9c7f --- /dev/null +++ b/tests/unit/kiro-iam-profilearn-usage.test.ts @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildKiroUsageResult, + discoverKiroProfileArn, +} from "@omniroute/open-sse/services/usage.ts"; + +// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER") +// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST"). +const IAM_CREDIT_RESPONSE = { + daysUntilReset: 0, + nextDateReset: 1.782864e9, + subscriptionInfo: { subscriptionTitle: "KIRO POWER", type: "Q_DEVELOPER_STANDALONE_POWER" }, + usageBreakdownList: [ + { + currency: "USD", + currentUsage: 3670, + currentUsageWithPrecision: 3670.9, + displayName: "Credit", + resourceType: "CREDIT", + unit: "INVOCATIONS", + usageLimit: 10000, + usageLimitWithPrecision: 10000.0, + }, + ], +}; + +test("buildKiroUsageResult parses the IAM CREDIT breakdown into non-zero usage", () => { + const result = buildKiroUsageResult(IAM_CREDIT_RESPONSE) as { + plan: string; + quotas: Record; + }; + assert.ok("quotas" in result, "must return quotas for a CREDIT breakdown"); + assert.equal(result.plan, "KIRO POWER"); + const credit = result.quotas.credit; + assert.ok(credit, "CREDIT resource should map to a 'credit' quota key"); + assert.equal(credit.used, 3670.9); + assert.equal(credit.total, 10000); + assert.equal(credit.remaining, 10000 - 3670.9); +}); + +test("discoverKiroProfileArn prefers the region-matched profile ARN", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + profiles: [ + { arn: "arn:aws:codewhisperer:us-east-1:111111111111:profile/AAAA" }, + { arn: "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn( + "tok", + "https://q.eu-central-1.amazonaws.com", + "eu-central-1" + ); + assert.equal(arn, "arn:aws:codewhisperer:eu-central-1:820374639727:profile/RX4VNUHGHGAQ"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn falls back to the first profile when no region match", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/X" }] }), + { status: 200, headers: { "Content-Type": "application/json" } } + )) as typeof fetch; + try { + const arn = await discoverKiroProfileArn("tok", "https://q.eu-west-1.amazonaws.com", "eu-west-1"); + assert.equal(arn, "arn:aws:codewhisperer:us-east-1:1:profile/X"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("discoverKiroProfileArn returns undefined for empty profiles or non-ok response", async () => { + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async () => + new Response(JSON.stringify({ profiles: [] }), { status: 200 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + + globalThis.fetch = (async () => new Response("nope", { status: 403 })) as typeof fetch; + assert.equal( + await discoverKiroProfileArn("tok", "https://q.eu-central-1.amazonaws.com", "eu-central-1"), + undefined + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/mimocode-executor.test.ts b/tests/unit/mimocode-executor.test.ts index ed89ca81ca..ee2f9848a9 100644 --- a/tests/unit/mimocode-executor.test.ts +++ b/tests/unit/mimocode-executor.test.ts @@ -1,6 +1,10 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { MimocodeExecutor, generateFingerprint } from "../../open-sse/executors/mimocode.ts"; +import { + MimocodeExecutor, + generateFingerprint, + MIMO_SYSTEM_MARKER, +} from "../../open-sse/executors/mimocode.ts"; const executor = new MimocodeExecutor(); @@ -48,7 +52,7 @@ describe("MimocodeExecutor", () => { const result = (executor as any).transformRequest( "mcode/mimo-auto", { model: "mcode/mimo-auto", messages: [{ role: "user", content: "hi" }] }, - false, + false ); assert.strictEqual(result.model, "mimo-auto"); }); @@ -57,11 +61,91 @@ describe("MimocodeExecutor", () => { const result = (executor as any).transformRequest( "mimo-auto", { model: "mimo-auto", messages: [{ role: "user", content: "hi" }] }, - false, + false ); assert.strictEqual(result.model, "mimo-auto"); }); + // The Xiaomi free endpoint rejects requests with `403 "Illegal access"` unless the + // body contains a recognized MiMoCode prompt signature inside a `system`-role message. + // The executor must inject that marker so user requests pass the upstream anti-abuse gate. + it("transformRequest injects a MiMoCode system marker when none is present", () => { + const result = (executor as any).transformRequest( + "mcode/mimo-auto", + { model: "mcode/mimo-auto", messages: [{ role: "user", content: "write a haiku" }] }, + true + ); + assert.ok(Array.isArray(result.messages)); + const first = result.messages[0]; + assert.strictEqual(first.role, "system"); + assert.ok( + typeof first.content === "string" && first.content.includes(MIMO_SYSTEM_MARKER), + "first message must be a system message containing the MiMoCode marker" + ); + }); + + it("transformRequest preserves the original user message after injection", () => { + const result = (executor as any).transformRequest( + "mcode/mimo-auto", + { model: "mcode/mimo-auto", messages: [{ role: "user", content: "write a haiku" }] }, + true + ); + const userMsg = result.messages.find((m: any) => m.role === "user"); + assert.ok(userMsg); + assert.strictEqual(userMsg.content, "write a haiku"); + }); + + it("transformRequest preserves a caller-provided system prompt alongside the marker", () => { + const result = (executor as any).transformRequest( + "mcode/mimo-auto", + { + model: "mcode/mimo-auto", + messages: [ + { role: "system", content: "You are a pirate." }, + { role: "user", content: "hi" }, + ], + }, + true + ); + const systemContents = result.messages + .filter((m: any) => m.role === "system") + .map((m: any) => m.content) + .join("\n"); + assert.ok(systemContents.includes(MIMO_SYSTEM_MARKER), "marker present"); + assert.ok(systemContents.includes("You are a pirate."), "caller system prompt preserved"); + }); + + it("transformRequest does not duplicate the marker when already present", () => { + const result = (executor as any).transformRequest( + "mcode/mimo-auto", + { + model: "mcode/mimo-auto", + messages: [ + { role: "system", content: `${MIMO_SYSTEM_MARKER}\nExtra context.` }, + { role: "user", content: "hi" }, + ], + }, + true + ); + const count = result.messages.filter( + (m: any) => + m.role === "system" && + typeof m.content === "string" && + m.content.includes(MIMO_SYSTEM_MARKER) + ).length; + assert.strictEqual(count, 1, "marker should not be duplicated"); + }); + + it("transformRequest leaves a body without a messages array untouched", () => { + const result = (executor as any).transformRequest( + "mcode/mimo-auto", + { model: "mcode/mimo-auto", prompt: "legacy" }, + true + ); + assert.strictEqual((result as any).messages, undefined); + assert.strictEqual((result as any).model, "mimo-auto"); + }); + it("returns 499 on pre-aborted signal", async () => { const controller = new AbortController(); controller.abort(new Error("cancelled")); diff --git a/tests/unit/model-intelligence-db.test.ts b/tests/unit/model-intelligence-db.test.ts new file mode 100644 index 0000000000..ba15f9f805 --- /dev/null +++ b/tests/unit/model-intelligence-db.test.ts @@ -0,0 +1,443 @@ +/** + * Unit tests for src/lib/db/modelIntelligence.ts + * + * Uses the project's own DB infrastructure (core.ts getDbInstance) + * with a temp DATA_DIR. Follows the Node.js native test runner pattern. + */ + +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-mi-test-"), +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const mi = await import("../../src/lib/db/modelIntelligence.ts"); + +function resetStorage(): void { + core.resetDbInstance(); + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + } catch { /* EBUSY — ignore */ } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function insertEntry( + model: string, + source: string, + category: string, + score: number, + opts: { + eloRaw?: number | null; + confidence?: string | null; + expiresAt?: string | null; + } = {}, +): void { + const db = core.getDbInstance(); + db.prepare( + `INSERT OR REPLACE INTO model_intelligence + (model, source, category, score, elo_raw, confidence, synced_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`, + ).run( + model, + source, + category, + score, + opts.eloRaw ?? null, + opts.confidence ?? null, + opts.expiresAt ?? null, + ); +} + +// ─── Tests ─────────────────────────────────────────────── + +describe("upsertModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("inserts a new entry", () => { + mi.upsertModelIntelligence({ + model: "claude-sonnet", + source: "arena_elo", + category: "coding", + score: 0.92, + eloRaw: 1350, + confidence: "high", + expiresAt: null, + }); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "arena_elo", "coding"); + assert.ok(entry, "Row should exist after upsert"); + assert.strictEqual(entry.model, "claude-sonnet"); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.category, "coding"); + assert.strictEqual(entry.score, 0.92); + assert.strictEqual(entry.eloRaw, 1350); + assert.strictEqual(entry.confidence, "high"); + assert.ok(entry.syncedAt, "synced_at should be auto-populated"); + }); + + it("updates an existing entry (INSERT OR REPLACE)", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.85); + + mi.upsertModelIntelligence({ + model: "gpt-4o", + source: "arena_elo", + category: "coding", + score: 0.90, + eloRaw: 1400, + confidence: "high", + expiresAt: null, + }); + + const entry = mi.getModelIntelligenceBySource("gpt-4o", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.score, 0.90); + assert.strictEqual(entry.eloRaw, 1400); + }); +}); + +describe("getModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("returns user_override when all three sources exist (highest priority)", () => { + insertEntry("claude-sonnet", "models_dev_tier", "coding", 0.75); + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const entry = mi.getModelIntelligence("claude-sonnet", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "user_override"); + assert.strictEqual(entry.score, 0.99); + }); + + it("returns arena_elo when no user_override exists", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + insertEntry("gpt-4o", "models_dev_tier", "coding", 0.70); + + const entry = mi.getModelIntelligence("gpt-4o", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.score, 0.87); + }); + + it("returns models_dev_tier when only that source exists", () => { + insertEntry("llama-4", "models_dev_tier", "coding", 0.65); + + const entry = mi.getModelIntelligence("llama-4", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "models_dev_tier"); + assert.strictEqual(entry.score, 0.65); + }); + + it("returns null when no entry exists", () => { + const entry = mi.getModelIntelligence("nonexistent-model", "coding"); + assert.strictEqual(entry, null); + }); + + it("skips expired entries and falls through to next source", () => { + insertEntry("gemini-pro", "arena_elo", "coding", 0.82, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("gemini-pro", "models_dev_tier", "coding", 0.70); + + const entry = mi.getModelIntelligence("gemini-pro", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "models_dev_tier"); + }); + + it("returns null when all entries for a model+category are expired", () => { + insertEntry("expired-model", "arena_elo", "coding", 0.80, { + expiresAt: "2000-01-01T00:00:00Z", + }); + + const entry = mi.getModelIntelligence("expired-model", "coding"); + assert.strictEqual(entry, null); + }); + + it("model names require exact match (case-sensitive in DB)", () => { + insertEntry("Claude-Sonnet", "arena_elo", "coding", 0.90); + + const exact = mi.getModelIntelligence("Claude-Sonnet", "coding"); + assert.ok(exact); + + const different = mi.getModelIntelligence("claude-sonnet", "coding"); + assert.strictEqual(different, null); + }); +}); + +describe("getModelIntelligenceBySource", () => { + beforeEach(() => { resetStorage(); }); + + it("returns a specific source entry", () => { + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.source, "arena_elo"); + assert.strictEqual(entry.score, 0.88); + }); + + it("returns null when the specific source does not exist", () => { + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + + const entry = mi.getModelIntelligenceBySource("claude-sonnet", "user_override", "coding"); + assert.strictEqual(entry, null); + }); + + it("returns null when no entries exist at all", () => { + const entry = mi.getModelIntelligenceBySource("nonexistent", "arena_elo", "coding"); + assert.strictEqual(entry, null); + }); +}); + +describe("deleteModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("deletes an entry and returns true", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + + const deleted = mi.deleteModelIntelligence("gpt-4o", "arena_elo", "coding"); + assert.strictEqual(deleted, true); + + const entry = mi.getModelIntelligenceBySource("gpt-4o", "arena_elo", "coding"); + assert.strictEqual(entry, null); + }); + + it("returns false when entry does not exist", () => { + const deleted = mi.deleteModelIntelligence("nonexistent", "arena_elo", "coding"); + assert.strictEqual(deleted, false); + }); +}); + +describe("deleteExpiredIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("deletes only expired entries leaving valid ones", () => { + insertEntry("old-model", "arena_elo", "coding", 0.7, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("fresh-model", "arena_elo", "coding", 0.9, { + expiresAt: "2099-12-31T23:59:59Z", + }); + insertEntry("permanent-model", "user_override", "coding", 0.95); + + const deleted = mi.deleteExpiredIntelligence(); + assert.strictEqual(deleted, 1); + + const remaining = mi.listModelIntelligence(); + assert.strictEqual(remaining.length, 2); + }); + + it("deletes expired entries for a specific source only", () => { + insertEntry("old-arena", "arena_elo", "coding", 0.7, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("old-tier", "models_dev_tier", "coding", 0.5, { + expiresAt: "2000-01-01T00:00:00Z", + }); + + const deleted = mi.deleteExpiredIntelligence("arena_elo"); + assert.strictEqual(deleted, 1); + + const remaining = mi.listModelIntelligence(); + assert.strictEqual(remaining.length, 1); + assert.strictEqual(remaining[0].source, "models_dev_tier"); + }); + + it("returns 0 when no expired entries exist", () => { + insertEntry("fresh-model", "arena_elo", "coding", 0.9, { + expiresAt: "2099-12-31T23:59:59Z", + }); + + const deleted = mi.deleteExpiredIntelligence(); + assert.strictEqual(deleted, 0); + }); +}); + +describe("listModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("lists all entries when no filters provided", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "user_override", "coding", 0.95); + + const entries = mi.listModelIntelligence(); + assert.strictEqual(entries.length, 3); + }); + + it("filters by source", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "user_override", "coding", 0.95); + insertEntry("model-c", "models_dev_tier", "coding", 0.6); + + const entries = mi.listModelIntelligence({ source: "arena_elo" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].source, "arena_elo"); + }); + + it("filters by category", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "arena_elo", "documentation", 0.7); + + const entries = mi.listModelIntelligence({ category: "review" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].category, "review"); + }); + + it("filters by both source and category", () => { + insertEntry("model-a", "arena_elo", "coding", 0.8); + insertEntry("model-b", "arena_elo", "review", 0.75); + insertEntry("model-c", "user_override", "coding", 0.95); + + const entries = mi.listModelIntelligence({ source: "arena_elo", category: "coding" }); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].model, "model-a"); + }); + + it("returns empty array for empty table", () => { + const entries = mi.listModelIntelligence(); + assert.ok(Array.isArray(entries)); + assert.strictEqual(entries.length, 0); + }); +}); + +describe("bulkUpsertModelIntelligence", () => { + beforeEach(() => { resetStorage(); }); + + it("bulk inserts multiple entries", () => { + const count = mi.bulkUpsertModelIntelligence([ + { model: "model-a", source: "arena_elo", category: "coding", score: 0.80, eloRaw: 1300, confidence: "high", expiresAt: "2099-12-31T23:59:59Z" }, + { model: "model-b", source: "arena_elo", category: "coding", score: 0.70, eloRaw: 1200, confidence: "medium", expiresAt: "2099-12-31T23:59:59Z" }, + { model: "model-c", source: "arena_elo", category: "review", score: 0.85, eloRaw: 1350, confidence: "high", expiresAt: "2099-12-31T23:59:59Z" }, + ]); + + assert.strictEqual(count, 3); + const entries = mi.listModelIntelligence(); + assert.strictEqual(entries.length, 3); + }); + + it("returns 0 for empty input array", () => { + const count = mi.bulkUpsertModelIntelligence([]); + assert.strictEqual(count, 0); + }); + + it("replaces existing entries on conflict (INSERT OR REPLACE)", () => { + insertEntry("model-a", "arena_elo", "coding", 0.70); + + mi.bulkUpsertModelIntelligence([ + { model: "model-a", source: "arena_elo", category: "coding", score: 0.90, eloRaw: 1450, confidence: "high", expiresAt: null }, + ]); + + const entry = mi.getModelIntelligenceBySource("model-a", "arena_elo", "coding"); + assert.ok(entry); + assert.strictEqual(entry.score, 0.90); + assert.strictEqual(entry.eloRaw, 1450); + }); +}); + +describe("getResolvedTaskFitness", () => { + beforeEach(() => { resetStorage(); }); + + it("returns user_override score when all sources exist", () => { + insertEntry("claude-sonnet", "models_dev_tier", "coding", 0.75); + insertEntry("claude-sonnet", "arena_elo", "coding", 0.88); + insertEntry("claude-sonnet", "user_override", "coding", 0.99); + + const score = mi.getResolvedTaskFitness("claude-sonnet", "coding"); + assert.strictEqual(score, 0.99); + }); + + it("returns arena_elo score when no user_override exists", () => { + insertEntry("gpt-4o", "arena_elo", "coding", 0.87); + insertEntry("gpt-4o", "models_dev_tier", "coding", 0.70); + + const score = mi.getResolvedTaskFitness("gpt-4o", "coding"); + assert.strictEqual(score, 0.87); + }); + + it("returns models_dev_tier score when only that source exists", () => { + insertEntry("llama-4", "models_dev_tier", "coding", 0.65); + + const score = mi.getResolvedTaskFitness("llama-4", "coding"); + assert.strictEqual(score, 0.65); + }); + + it("returns null when nothing exists", () => { + const score = mi.getResolvedTaskFitness("nonexistent", "coding"); + assert.strictEqual(score, null); + }); + + it("skips expired entries in the resolution chain", () => { + insertEntry("gemini-pro", "arena_elo", "coding", 0.82, { + expiresAt: "2000-01-01T00:00:00Z", + }); + insertEntry("gemini-pro", "models_dev_tier", "coding", 0.70); + + const score = mi.getResolvedTaskFitness("gemini-pro", "coding"); + assert.strictEqual(score, 0.70); + }); +}); + +describe("edge cases", () => { + beforeEach(() => { resetStorage(); }); + + it("score values are stored and retrieved with float precision", () => { + mi.upsertModelIntelligence({ + model: "precise-model", + source: "arena_elo", + category: "coding", + score: 0.1234, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("precise-model", "coding"); + assert.ok(entry); + assert.ok(Math.abs(entry.score - 0.1234) < 0.0001); + }); + + it("null eloRaw and confidence are handled correctly", () => { + mi.upsertModelIntelligence({ + model: "minimal-model", + source: "models_dev_tier", + category: "default", + score: 0.6, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("minimal-model", "default"); + assert.ok(entry); + assert.strictEqual(entry.eloRaw, null); + assert.strictEqual(entry.confidence, null); + }); + + it("NULL expires_at means never expires", () => { + mi.upsertModelIntelligence({ + model: "permanent-model", + source: "user_override", + category: "coding", + score: 0.95, + eloRaw: null, + confidence: null, + expiresAt: null, + }); + + const entry = mi.getModelIntelligence("permanent-model", "coding"); + assert.ok(entry); + assert.strictEqual(entry.expiresAt, null); + assert.strictEqual(entry.score, 0.95); + }); +}); diff --git a/tests/unit/model-lockout-decay.test.ts b/tests/unit/model-lockout-decay.test.ts new file mode 100644 index 0000000000..aedc9100a4 --- /dev/null +++ b/tests/unit/model-lockout-decay.test.ts @@ -0,0 +1,104 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; + +describe("decayModelFailureCount — /2 on success", () => { + let accountFallback: typeof import("../../open-sse/services/accountFallback.ts"); + + before(async () => { + accountFallback = await import("../../open-sse/services/accountFallback.ts"); + }); + + it("S1: halves failureCount when model is locked with failureCount=4", () => { + accountFallback.clearAllModelLockouts(); + // Record a lockout with failureCount=4 + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + // Manually bump failureCount to 4 by calling it 3 more times + for (let i = 0; i < 3; i++) { + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + } + + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 2, "failureCount=4 should halve to 2"); + assert.equal(result.cleared, false, "should not be cleared"); + }); + + it("S2: clears lockout when failureCount reaches 0 (failureCount=1 → /2 = 0)", () => { + accountFallback.clearAllModelLockouts(); + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 0, "failureCount=1 should halve to 0 (floor(1/2))"); + assert.equal(result.cleared, true, "should be cleared when count reaches 0"); + }); + + it("S3: no-op when model has no lockout or failure state", () => { + accountFallback.clearAllModelLockouts(); + const result = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(result.newFailureCount, 0, "no state → 0"); + assert.equal(result.cleared, false, "no state → not cleared"); + }); + + it("S4: no-op when model is null/undefined", () => { + accountFallback.clearAllModelLockouts(); + const r1 = accountFallback.decayModelFailureCount("openai", "conn-1", null); + assert.equal(r1.newFailureCount, 0, "null model → 0"); + assert.equal(r1.cleared, false, "null model → not cleared"); + + const r2 = accountFallback.decayModelFailureCount("openai", "conn-1", undefined); + assert.equal(r2.newFailureCount, 0, "undefined model → 0"); + assert.equal(r2.cleared, false, "undefined model → not cleared"); + }); + + it("S5: Math.floor(3/2) = 1, then Math.floor(1/2) = 0 → cleared", () => { + accountFallback.clearAllModelLockouts(); + // Start with failureCount=3 + for (let i = 0; i < 2; i++) { + accountFallback.recordModelLockoutFailure( + "openai", + "conn-1", + "gpt-4", + "rate_limit_exceeded", + 429, + 120_000, + null, + { exactCooldownMs: 60_000 } + ); + } + // Should be failureCount=3 + const r1 = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(r1.newFailureCount, 1, "3/2=1.5 → floor=1"); + assert.equal(r1.cleared, false); + + // Second decay: 1/2=0.5 → floor=0 → cleared + const r2 = accountFallback.decayModelFailureCount("openai", "conn-1", "gpt-4"); + assert.equal(r2.newFailureCount, 0, "1/2=0.5 → floor=0 → cleared"); + assert.equal(r2.cleared, true); + }); +}); diff --git a/tests/unit/oauth-refresh-error-resilience.test.ts b/tests/unit/oauth-refresh-error-resilience.test.ts new file mode 100644 index 0000000000..aa3125ab79 --- /dev/null +++ b/tests/unit/oauth-refresh-error-resilience.test.ts @@ -0,0 +1,180 @@ +/** + * TDD — OAuth refresh error classification must be resilient to body SHAPE. + * + * Root cause of the production "1352× refresh loop" (claude/aa5dd5cf): when the + * Anthropic 400 body reaches `refreshClaudeOAuthToken` in a non-canonical shape + * (a JSON string instead of an object, a double-encoded string, a nested + * `{error:{code}}`, or the raw text in the catch branch), the old check + * `errorBody.error === "invalid_grant"` evaluated to false, so the function + * returned `null` instead of the `unrecoverable_refresh_error` sentinel. + * + * `null` makes the HealthCheck treat it as a recoverable failure → keeps the + * connection `active` → retries every 60s forever (the loop). The fix is a + * shape-agnostic extractor used by all refreshers that classify invalid_grant. + * + * These tests FAIL before the fix (functions return null) and pass after. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const tokenRefresh = await import("../../open-sse/services/tokenRefresh.ts"); +const { + extractOAuthErrorCode, + refreshClaudeOAuthToken, + refreshClineToken, + refreshQoderToken, + refreshGitHubToken, + isUnrecoverableRefreshError, +} = tokenRefresh as unknown as { + extractOAuthErrorCode: (raw: unknown) => string | null; + refreshClaudeOAuthToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshClineToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshQoderToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + refreshGitHubToken: (rt: string, log?: unknown, proxy?: unknown) => Promise; + isUnrecoverableRefreshError: (r: unknown) => boolean; +}; + +function rawResponse(body: string, status = 400, contentType = "application/json") { + return new Response(body, { status, headers: { "content-type": contentType } }); +} + +async function withMockedFetch(impl: typeof fetch, fn: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { + return await fn(); + } finally { + globalThis.fetch = original; + } +} + +// ── extractOAuthErrorCode: shape matrix ───────────────────────────────────── + +test("extractOAuthErrorCode: canonical object { error: 'invalid_grant' }", () => { + assert.equal(extractOAuthErrorCode({ error: "invalid_grant" }), "invalid_grant"); +}); + +test("extractOAuthErrorCode: nested object { error: { code: 'invalid_grant' } }", () => { + assert.equal(extractOAuthErrorCode({ error: { code: "invalid_grant" } }), "invalid_grant"); +}); + +test("extractOAuthErrorCode: bare string code 'invalid_grant'", () => { + assert.equal(extractOAuthErrorCode("invalid_grant"), "invalid_grant"); +}); + +test("extractOAuthErrorCode: JSON string body '{\"error\":\"invalid_grant\"}'", () => { + assert.equal(extractOAuthErrorCode('{"error": "invalid_grant"}'), "invalid_grant"); +}); + +test("extractOAuthErrorCode: double-encoded JSON string (the production case)", () => { + // response.json() returned the inner JSON AS a string (proxy double-encode) + const doubleEncoded = JSON.stringify('{"error": "invalid_grant", "error_description": "x"}'); + const parsedOnce = JSON.parse(doubleEncoded); // a string, still JSON inside + assert.equal(extractOAuthErrorCode(parsedOnce), "invalid_grant"); +}); + +test("extractOAuthErrorCode: catch-branch shape { error: '' }", () => { + // refreshClaudeOAuthToken's catch did errorBody = { error: text } + const errorBody = { error: '{"error": "invalid_grant", "error_description": "x"}' }; + assert.equal(extractOAuthErrorCode(errorBody), "invalid_grant"); +}); + +test("extractOAuthErrorCode: invalid_request is recognized", () => { + assert.equal(extractOAuthErrorCode({ error: "invalid_request" }), "invalid_request"); +}); + +test("extractOAuthErrorCode: transient errors are NOT misclassified (no false positives)", () => { + assert.equal(extractOAuthErrorCode({ error: "server_error" }), null); + assert.equal(extractOAuthErrorCode("rate_limited"), null); + assert.equal(extractOAuthErrorCode("502 Bad Gateway"), null); + assert.equal(extractOAuthErrorCode(""), null); + assert.equal(extractOAuthErrorCode(null), null); + assert.equal(extractOAuthErrorCode(undefined), null); +}); + +// ── refreshClaudeOAuthToken: every shape → unrecoverable sentinel ──────────── + +const SENTINEL_SHAPES: Array<{ name: string; body: string; ct?: string }> = [ + { name: "canonical object", body: '{"error": "invalid_grant", "error_description": "Refresh token not found or invalid"}' }, + { name: "double-encoded JSON string", body: JSON.stringify('{"error": "invalid_grant", "error_description": "x"}') }, + { name: "bare string code", body: '"invalid_grant"' }, + { name: "nested error.code", body: '{"error": {"code": "invalid_grant", "message": "x"}}' }, + { name: "json served as text/plain", body: '{"error": "invalid_grant"}', ct: "text/plain" }, +]; + +for (const shape of SENTINEL_SHAPES) { + test(`refreshClaudeOAuthToken → unrecoverable sentinel for shape: ${shape.name}`, async () => { + await withMockedFetch( + (async () => rawResponse(shape.body, 400, shape.ct ?? "application/json")) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("dead-refresh-token"); + assert.ok( + isUnrecoverableRefreshError(result), + `shape "${shape.name}" must yield an unrecoverable sentinel, got ${JSON.stringify(result)}` + ); + assert.equal((result as { code?: string }).code, "invalid_grant"); + } + ); + }); +} + +test("refreshClaudeOAuthToken: transient 500 server_error stays null (NOT unrecoverable)", async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("token"); + assert.equal(result, null, "transient errors must remain recoverable (null), not deactivate the account"); + } + ); +}); + +test("refreshClaudeOAuthToken: 502 HTML gateway error stays null", async () => { + await withMockedFetch( + (async () => rawResponse("502 Bad Gateway", 502, "text/html")) as unknown as typeof fetch, + async () => { + const result = await refreshClaudeOAuthToken("token"); + assert.equal(result, null); + } + ); +}); + +// ── Previously-frágil refreshers that NEVER emitted a sentinel ────────────── +// refreshClineToken / refreshQoderToken / refreshGitHubToken returned null on +// ANY error → invalid_grant looked recoverable → HealthCheck refresh loop. + +// Note: refreshQoderToken also got the same fix, but it early-returns null via a +// config guard (no clientId/secret in the test env) so it can't be exercised here. +const FRAGILE_REFRESHERS: Array<{ + name: string; + fn: (rt: string) => Promise; +}> = [ + { name: "refreshClineToken", fn: (rt) => refreshClineToken(rt) }, + { name: "refreshGitHubToken", fn: (rt) => refreshGitHubToken(rt) }, +]; + +void refreshQoderToken; // fixed in source; not unit-testable without OAuth config + +for (const r of FRAGILE_REFRESHERS) { + test(`${r.name}: invalid_grant now yields an unrecoverable sentinel`, async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "invalid_grant"}', 400)) as unknown as typeof fetch, + async () => { + const result = await r.fn("dead-token"); + assert.ok( + isUnrecoverableRefreshError(result), + `${r.name} must classify invalid_grant as unrecoverable, got ${JSON.stringify(result)}` + ); + } + ); + }); + + test(`${r.name}: transient 500 server_error stays null`, async () => { + await withMockedFetch( + (async () => rawResponse('{"error": "server_error"}', 500)) as unknown as typeof fetch, + async () => { + const result = await r.fn("token"); + assert.equal(result, null, `${r.name} must keep transient errors recoverable`); + } + ); + }); +} diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index eedcd16049..c78b798cfd 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -114,6 +114,74 @@ test("configured-only filter keeps no-auth providers even without a saved connec ); }); +test("compact provider entries dedupe providers and move no-auth entries to the end", () => { + const openRouterFromFree = { + providerId: "openrouter", + provider: { id: "openrouter", name: "OpenRouter" }, + stats: { total: 1 }, + displayAuthType: "apikey", + toggleAuthType: "apikey", + }; + const openRouterFromAggregator = { + providerId: "openrouter", + provider: { id: "openrouter", name: "OpenRouter" }, + stats: { total: 1 }, + displayAuthType: "apikey", + toggleAuthType: "apikey", + }; + const claude = { + providerId: "claude", + provider: { id: "claude", name: "Claude" }, + stats: { total: 1 }, + displayAuthType: "oauth", + toggleAuthType: "oauth", + }; + const opencode = { + providerId: "opencode", + provider: { id: "opencode", name: "OpenCode" }, + stats: { total: 0 }, + displayAuthType: "no-auth", + toggleAuthType: "no-auth", + }; + + const visible = providerPageUtils.buildCompactProviderEntries( + [[opencode, openRouterFromFree], [claude, openRouterFromAggregator], [opencode]], + { deferNoAuth: true } + ); + + assert.deepEqual( + visible.map((entry) => entry.providerId), + ["openrouter", "claude", "opencode"] + ); + assert.equal(visible.filter((entry) => entry.providerId === "openrouter").length, 1); +}); + +test("compact provider entries prefer non-no-auth duplicates over deferred no-auth entries", () => { + const noAuthEntry = { + providerId: "opencode", + provider: { id: "opencode", name: "OpenCode" }, + stats: { total: 0 }, + displayAuthType: "no-auth", + toggleAuthType: "no-auth", + }; + const configuredEntry = { + providerId: "opencode", + provider: { id: "opencode", name: "OpenCode" }, + stats: { total: 1 }, + displayAuthType: "apikey", + toggleAuthType: "apikey", + }; + + const visible = providerPageUtils.buildCompactProviderEntries( + [[noAuthEntry], [configuredEntry]], + { deferNoAuth: true } + ); + + assert.equal(visible.length, 1); + assert.equal(visible[0].providerId, "opencode"); + assert.equal(visible[0].displayAuthType, "apikey"); +}); + test("search filter matches provider name and id case-insensitively", () => { const entries = [ { @@ -227,12 +295,29 @@ test("configured-only preference parser only enables explicit true values", () = assert.equal(providerPageStorage.parseConfiguredOnlyPreference(undefined), false); }); +test("provider display mode preference parser accepts only known modes", () => { + assert.equal(providerPageStorage.parseProviderDisplayModePreference("all"), "all"); + assert.equal(providerPageStorage.parseProviderDisplayModePreference("configured"), "configured"); + assert.equal(providerPageStorage.parseProviderDisplayModePreference("compact"), "compact"); + assert.equal(providerPageStorage.parseProviderDisplayModePreference("true"), null); + assert.equal(providerPageStorage.parseProviderDisplayModePreference(null), null); +}); + test("configured-only filter is ignored before the first provider is connected", () => { assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(true, 0), false); assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(false, 0), false); assert.equal(providerPageUtils.shouldApplyConfiguredOnlyFilter(true, 1), true); }); +test("compact display mode always uses the configured provider set", () => { + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("all", 0), false); + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("all", 2), false); + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("configured", 0), false); + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("configured", 2), true); + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("compact", 0), true); + assert.equal(providerPageUtils.shouldFilterProviderEntriesForDisplayMode("compact", 2), true); +}); + test("first-provider hint is shown only when no providers are connected and search is empty", () => { assert.equal(providerPageUtils.shouldShowFirstProviderHint(0, ""), true); assert.equal(providerPageUtils.shouldShowFirstProviderHint(0, " "), true); @@ -265,6 +350,37 @@ test("configured-only preference storage round-trips correctly", () => { assert.equal(providerPageStorage.readConfiguredOnlyPreference(mockStorage), false); }); +test("provider display mode storage round-trips and migrates the old configured-only key", () => { + const storage = new Map(); + const mockStorage = { + getItem(key) { + return storage.has(key) ? storage.get(key) : null; + }, + setItem(key, value) { + storage.set(key, value); + }, + removeItem(key) { + storage.delete(key); + }, + }; + + assert.equal(providerPageStorage.readProviderDisplayModePreference(mockStorage), "all"); + + storage.set(providerPageStorage.SHOW_CONFIGURED_ONLY_STORAGE_KEY, "true"); + assert.equal(providerPageStorage.readProviderDisplayModePreference(mockStorage), "configured"); + assert.equal(storage.get(providerPageStorage.PROVIDER_DISPLAY_MODE_STORAGE_KEY), "configured"); + assert.equal(storage.has(providerPageStorage.SHOW_CONFIGURED_ONLY_STORAGE_KEY), false); + + providerPageStorage.writeProviderDisplayModePreference("compact", mockStorage); + assert.equal(storage.get(providerPageStorage.PROVIDER_DISPLAY_MODE_STORAGE_KEY), "compact"); + assert.equal(storage.has(providerPageStorage.SHOW_CONFIGURED_ONLY_STORAGE_KEY), false); + assert.equal(providerPageStorage.readProviderDisplayModePreference(mockStorage), "compact"); + + providerPageStorage.writeProviderDisplayModePreference("all", mockStorage); + assert.equal(storage.has(providerPageStorage.PROVIDER_DISPLAY_MODE_STORAGE_KEY), false); + assert.equal(providerPageStorage.readProviderDisplayModePreference(mockStorage), "all"); +}); + test("static catalog entries resolve local, search, audio, web-cookie and upstream providers", () => { const freeProvider = providerPageUtils.resolveDashboardProviderInfo("amazon-q"); const localProvider = providerPageUtils.resolveDashboardProviderInfo("sdwebui"); diff --git a/tests/unit/proxy-egress-visibility.test.ts b/tests/unit/proxy-egress-visibility.test.ts new file mode 100644 index 0000000000..86d11905e0 --- /dev/null +++ b/tests/unit/proxy-egress-visibility.test.ts @@ -0,0 +1,190 @@ +/** + * TDD — proxy egress IP visibility. Confirms by which IP each OAuth connection + * leaves, and flags same-rotation-group accounts sharing one egress IP (the + * exact codex anomaly-revocation trigger). Network probe is injected. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const egress = await import("../../src/lib/proxyEgress.ts"); +const { + resolveEgressIp, + analyzeEgressSharing, + diagnoseAllEgressIps, + _setEgressProbeForTests, + clearEgressCache, +} = egress as unknown as { + resolveEgressIp: (u: string | null, o?: any) => Promise<{ ip: string | null; cached: boolean }>; + analyzeEgressSharing: (c: any[]) => { + byEgressIp: Record; + sharedWithinRotationGroup: Array<{ egressIp: string; rotationGroup: string; connections: string[] }>; + }; + diagnoseAllEgressIps: (deps?: any) => Promise; + validateProxyPool: (deps?: any) => Promise; + _setEgressProbeForTests: (fn: any) => void; + clearEgressCache: () => void; +}; +const { validateProxyPool, planProxyDistribution, applyProxyDistribution } = egress as any; + +test("resolveEgressIp returns the probed IP and caches by proxy URL", async () => { + clearEgressCache(); + let calls = 0; + _setEgressProbeForTests(async (proxyUrl: string | null) => { + calls++; + return { ip: proxyUrl ? "203.0.113.7" : "198.51.100.1", latencyMs: 5 }; + }); + + const viaProxy = await resolveEgressIp("http://1.2.3.4:8080"); + assert.equal(viaProxy.ip, "203.0.113.7"); + assert.equal(viaProxy.cached, false); + + const direct = await resolveEgressIp(null); + assert.equal(direct.ip, "198.51.100.1"); + + // second call for the same proxy URL is served from cache (no extra probe) + const again = await resolveEgressIp("http://1.2.3.4:8080"); + assert.equal(again.cached, true); + assert.equal(calls, 2, "only 2 distinct probes (proxy + direct), not 3"); + + _setEgressProbeForTests(null); +}); + +test("analyzeEgressSharing flags ≥2 same-rotation-group accounts on one egress IP", () => { + const result = analyzeEgressSharing([ + { connectionId: "a", provider: "codex", account: "acc-a", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "b", provider: "codex", account: "acc-b", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "c", provider: "openai", account: "acc-c", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + { connectionId: "d", provider: "claude", account: "acc-d", proxyLevel: "direct", proxyHost: null, egressIp: "100.115.194.84" }, + ]); + + // codex + openai share the openai-auth0 family → 3 accounts on one IP = warning + const warn = result.sharedWithinRotationGroup.find((w) => w.rotationGroup === "openai-auth0"); + assert.ok(warn, "must warn about the codex/openai family sharing an egress IP"); + assert.equal(warn!.egressIp, "100.115.194.84"); + assert.equal(warn!.connections.length, 3, "acc-a + acc-b + acc-c"); + + // claude alone on the IP is NOT a warning (different family, single account) + assert.ok( + !result.sharedWithinRotationGroup.some((w) => w.connections.includes("acc-d")), + "a lone claude account must not be flagged" + ); + + assert.deepEqual(result.byEgressIp["100.115.194.84"].sort(), ["acc-a", "acc-b", "acc-c", "acc-d"]); +}); + +test("analyzeEgressSharing: distinct IPs per account = no warning (the healthy .17 case)", () => { + const result = analyzeEgressSharing([ + { connectionId: "a", provider: "codex", account: "acc-a", proxyLevel: "account", proxyHost: "p1", egressIp: "203.0.113.1" }, + { connectionId: "b", provider: "codex", account: "acc-b", proxyLevel: "account", proxyHost: "p2", egressIp: "203.0.113.2" }, + ]); + assert.equal(result.sharedWithinRotationGroup.length, 0, "1 IP per account is safe"); +}); + +test("diagnoseAllEgressIps wires resolution + probe and surfaces the shared-IP warning", async () => { + clearEgressCache(); + _setEgressProbeForTests(async () => ({ ip: "100.115.194.84", latencyMs: 3 })); + + const diag = await diagnoseAllEgressIps({ + getConnections: async () => [ + { id: "c1", provider: "codex", email: "one@x.com" }, + { id: "c2", provider: "codex", email: "two@x.com" }, + ], + resolveProxy: async () => ({ proxy: { type: "http", host: "9.9.9.9", port: 8080 }, level: "global" }), + }); + + assert.equal(diag.connections.length, 2); + assert.equal(diag.connections[0].egressIp, "100.115.194.84"); + assert.equal(diag.connections[0].proxyLevel, "global"); + assert.equal(diag.sharedWithinRotationGroup.length, 1, "both codex accounts share one egress IP"); + assert.equal(diag.sharedWithinRotationGroup[0].connections.length, 2); + + _setEgressProbeForTests(null); +}); + +test("validateProxyPool marks live proxies active and dead proxies error", async () => { + clearEgressCache(); + // proxy p-live reaches the internet; p-dead times out + _setEgressProbeForTests(async (proxyUrl: string | null) => { + if (proxyUrl && proxyUrl.includes("9.9.9.9")) return { ip: "203.0.113.9", latencyMs: 12 }; + return { ip: null, latencyMs: 7000, error: "timeout" }; + }); + + const marked: Record = {}; + const report = await validateProxyPool({ + listProxies: async () => [ + { id: "p-live", type: "http", host: "9.9.9.9", port: 8080, status: "error" }, + { id: "p-dead", type: "http", host: "1.1.1.1", port: 8080, status: "active" }, + ], + markStatus: async (id: string, status: string) => { + marked[id] = status; + }, + }); + + assert.equal(marked["p-live"], "active", "a reachable proxy must be (re)marked active"); + assert.equal(marked["p-dead"], "error", "an unreachable proxy must be marked error (so resolution skips it)"); + + const live = report.find((r) => r.proxyId === "p-live"); + assert.equal(live!.alive, true); + assert.equal(live!.egressIp, "203.0.113.9"); + assert.equal(live!.previousStatus, "error"); + const dead = report.find((r) => r.proxyId === "p-dead"); + assert.equal(dead!.alive, false); + assert.equal(dead!.previousStatus, "active", "was wrongly active before validation"); + + _setEgressProbeForTests(null); +}); + +test("planProxyDistribution: strict 1:1, extras left unassigned (no shared IP)", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"] + ); + assert.equal(plan.assignments.length, 2); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2"]); + assert.equal(plan.unassigned.length, 1, "c3 has no proxy → unassigned, not sharing"); + assert.equal(plan.unassigned[0].connectionId, "c3"); + assert.equal(plan.sharingRisk, false); +}); + +test("planProxyDistribution: enough proxies → 1 distinct per account", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2", "p3"] + ); + assert.equal(plan.assignments.length, 2); + assert.equal(plan.unassigned.length, 0); + assert.match(plan.note, /1 distinct proxy/); +}); + +test("planProxyDistribution: allowSharing round-robins and flags sharingRisk", () => { + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }, { id: "c3", account: "a3" }], + ["p1", "p2"], + { allowSharing: true } + ); + assert.equal(plan.assignments.length, 3); + assert.deepEqual(plan.assignments.map((a: any) => a.proxyId), ["p1", "p2", "p1"]); + assert.equal(plan.sharingRisk, true); +}); + +test("planProxyDistribution: no live proxies → all unassigned with guidance", () => { + const plan = planProxyDistribution([{ id: "c1", account: "a1" }], []); + assert.equal(plan.assignments.length, 0); + assert.equal(plan.unassigned.length, 1); + assert.match(plan.note, /No live proxies/); +}); + +test("applyProxyDistribution assigns each proxy to its connection", async () => { + const calls: Array<[string, string]> = []; + const plan = planProxyDistribution( + [{ id: "c1", account: "a1" }, { id: "c2", account: "a2" }], + ["p1", "p2"] + ); + const res = await applyProxyDistribution(plan, { + assign: async (connectionId: string, proxyId: string) => { + calls.push([connectionId, proxyId]); + }, + }); + assert.equal(res.applied, 2); + assert.deepEqual(calls, [["c1", "p1"], ["c2", "p2"]]); +}); diff --git a/tests/unit/proxy-resolution-status-filter.test.ts b/tests/unit/proxy-resolution-status-filter.test.ts new file mode 100644 index 0000000000..c2286be251 --- /dev/null +++ b/tests/unit/proxy-resolution-status-filter.test.ts @@ -0,0 +1,95 @@ +/** + * TDD — proxy resolution must not hand out a proxy that has been explicitly + * marked dead (status inactive/error/disabled). Today the resolution queries + * JOIN proxy_registry without any status filter, so an operator (or a health + * check) marking a proxy dead has no effect — it keeps being assigned, every + * request pays the timeout, and accounts fail. Part of the codex-invalidation + * proxy hardening. + * + * Conservative: only EXCLUDE explicit dead states; active/null/unknown stay + * usable so we never strand a working proxy. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-proxy-status-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("resolution SKIPS an account proxy marked inactive", async () => { + await resetStorage(); + const proxy = await proxiesDb.createProxy({ + name: "Dead Account Proxy", + type: "http", + host: "127.0.0.1", + port: 9991, + }); + await proxiesDb.updateProxy(proxy!.id, { status: "inactive" }); + await proxiesDb.assignProxyToScope("account", "conn-dead", proxy!.id); + + const resolved = await proxiesDb.resolveProxyForConnectionFromRegistry("conn-dead"); + assert.equal(resolved, null, "a dead account proxy must not be resolved"); +}); + +test("resolution STILL returns an active account proxy", async () => { + await resetStorage(); + const proxy = await proxiesDb.createProxy({ + name: "Live Account Proxy", + type: "http", + host: "127.0.0.1", + port: 8081, + }); + await proxiesDb.assignProxyToScope("account", "conn-live", proxy!.id); + + const resolved = await proxiesDb.resolveProxyForConnectionFromRegistry("conn-live"); + assert.ok(resolved, "active proxy must resolve"); + assert.equal((resolved as any).proxy.host, "127.0.0.1"); + assert.equal((resolved as any).level, "account"); +}); + +test("scope resolver skips a dead provider proxy (status=error)", async () => { + await resetStorage(); + const provProxy = await proxiesDb.createProxy({ + name: "Dead Provider Proxy", + type: "http", + host: "10.0.0.1", + port: 9992, + }); + await proxiesDb.updateProxy(provProxy!.id, { status: "error" }); + await proxiesDb.assignProxyToScope("provider", "codex", provProxy!.id); + + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("provider", "codex"); + assert.equal(resolved, null, "a dead provider proxy must not be resolved"); +}); + +test("scope resolver still returns a live global proxy", async () => { + await resetStorage(); + const globalProxy = await proxiesDb.createProxy({ + name: "Live Global Proxy", + type: "http", + host: "10.0.0.2", + port: 8082, + }); + await proxiesDb.assignProxyToScope("global", null, globalProxy!.id); + + const resolved = await proxiesDb.resolveProxyForScopeFromRegistry("global"); + assert.ok(resolved, "live global proxy must resolve"); + assert.equal((resolved as any).proxy.host, "10.0.0.2"); +}); diff --git a/tests/unit/semantic-cache.test.ts b/tests/unit/semantic-cache.test.ts index 69185613a0..2205594b17 100644 --- a/tests/unit/semantic-cache.test.ts +++ b/tests/unit/semantic-cache.test.ts @@ -71,6 +71,29 @@ describe("Semantic Cache", () => { const sig2 = generateSignature("gpt-5", input2, 0, 1); assert.equal(sig1, sig2); }); + + // #3740: cross-user cache isolation — different API keys must not share cached responses + it("generates different signatures for different API key IDs (#3740)", () => { + const messages = [{ role: "user", content: "what is 2+2?" }]; + const sigKeyA = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + const sigKeyB = generateSignature("gpt-4o", messages, 0, 1, "key-id-bob"); + assert.notEqual(sigKeyA, sigKeyB, "different API keys must produce different cache signatures"); + }); + + it("generates consistent signatures for same API key ID (#3740)", () => { + const messages = [{ role: "user", content: "what is 2+2?" }]; + const sig1 = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + const sig2 = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + assert.equal(sig1, sig2); + }); + + it("matches keyless signature when apiKeyId is undefined (#3740)", () => { + const messages = [{ role: "user", content: "hello" }]; + // Unauthenticated requests (apiKeyId=undefined) must not collide with keyed requests + const sigKeyed = generateSignature("gpt-4o", messages, 0, 1, "some-key-id"); + const sigKeyless = generateSignature("gpt-4o", messages, 0, 1, undefined); + assert.notEqual(sigKeyed, sigKeyless); + }); }); describe("isCacheableForRead", () => { diff --git a/tests/unit/services/emergency-fallback.test.ts b/tests/unit/services/emergency-fallback.test.ts new file mode 100644 index 0000000000..bf43dd5d59 --- /dev/null +++ b/tests/unit/services/emergency-fallback.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + shouldUseFallback, + isEmergencyFallbackEnvEnabled, + EMERGENCY_FALLBACK_CONFIG, +} from "../../../open-sse/services/emergencyFallback.ts"; + +function withEnv(value: string | undefined, fn: () => void) { + const previous = process.env.OMNIROUTE_EMERGENCY_FALLBACK; + if (value === undefined) { + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + } else { + process.env.OMNIROUTE_EMERGENCY_FALLBACK = value; + } + try { + fn(); + } finally { + if (previous === undefined) { + delete process.env.OMNIROUTE_EMERGENCY_FALLBACK; + } else { + process.env.OMNIROUTE_EMERGENCY_FALLBACK = previous; + } + } +} + +test("emergency fallback stays enabled when the env switch is unset (default behavior)", () => { + withEnv(undefined, () => { + assert.equal(isEmergencyFallbackEnvEnabled(), true); + const decision = shouldUseFallback(402, "", false); + assert.equal(decision.shouldFallback, true); + if (decision.shouldFallback) { + assert.equal(decision.provider, EMERGENCY_FALLBACK_CONFIG.provider); + assert.equal(decision.model, EMERGENCY_FALLBACK_CONFIG.model); + } + }); +}); + +test("budget keywords trigger fallback when the env switch is unset", () => { + withEnv(undefined, () => { + const decision = shouldUseFallback(429, "All accounts quota exceeded", false); + assert.equal(decision.shouldFallback, true); + }); +}); + +test("OMNIROUTE_EMERGENCY_FALLBACK=false disables the 402 redirect", () => { + withEnv("false", () => { + assert.equal(isEmergencyFallbackEnvEnabled(), false); + const decision = shouldUseFallback(402, "", false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + }); +}); + +test("OMNIROUTE_EMERGENCY_FALLBACK=0 disables the budget-keyword redirect", () => { + withEnv("0", () => { + const decision = shouldUseFallback(429, "quota exceeded for account", false); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /OMNIROUTE_EMERGENCY_FALLBACK/); + }); +}); + +test("explicit truthy values keep the fallback enabled", () => { + withEnv("true", () => { + assert.equal(isEmergencyFallbackEnvEnabled(), true); + assert.equal(shouldUseFallback(402, "", false).shouldFallback, true); + }); +}); + +test("env switch does not override config.enabled=false", () => { + withEnv("true", () => { + const decision = shouldUseFallback(402, "", false, { + ...EMERGENCY_FALLBACK_CONFIG, + enabled: false, + }); + assert.equal(decision.shouldFallback, false); + }); +}); + +test("tool-bearing requests are still skipped regardless of env switch", () => { + withEnv(undefined, () => { + const decision = shouldUseFallback(402, "", true); + assert.equal(decision.shouldFallback, false); + assert.match(decision.reason, /tools/); + }); +}); diff --git a/tests/unit/services/geminiRateLimitTracker.test.ts b/tests/unit/services/geminiRateLimitTracker.test.ts new file mode 100644 index 0000000000..a9d7deb5c3 --- /dev/null +++ b/tests/unit/services/geminiRateLimitTracker.test.ts @@ -0,0 +1,323 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + getModelRpd, + getModelRpm, + incrementRequestCount, + getDailyRequestCount, + getMinuteRequestCount, + isRpdExhausted, + isRpmExhausted, + resetCounters, +} from "../../../open-sse/services/geminiRateLimitTracker.ts"; + +test.beforeEach(() => { + resetCounters(); +}); + +// ── getModelRpd ────────────────────────────────────────────────────────────── + +test("getModelRpd returns known RPD for exact model match", () => { + assert.equal(getModelRpd("gemini-2.5-flash"), 20); +}); + +test("getModelRpd returns known RPD for model with gemini/ prefix", () => { + assert.equal(getModelRpd("gemini/gemini-2.5-flash"), 20); +}); + +test("getModelRpd returns 0 for model with zero RPD (Gemini 2.5 Pro)", () => { + assert.equal(getModelRpd("gemini-2.5-pro"), 0); +}); + +test("getModelRpd returns 0 for unknown model", () => { + assert.equal(getModelRpd("gemini/fake-model-not-in-list"), 0); +}); + +test("getModelRpd returns 0 for empty string", () => { + assert.equal(getModelRpd(""), 0); +}); + +test("getModelRpd matches gemma-4-31b-it via suffix fallback", () => { + // The JSON has "gemma-4-31b-it", and callers may pass "gemma-4-31b-it" + assert.equal(getModelRpd("gemma-4-31b-it"), 1500); +}); + +test("getModelRpd strips gemma- prefix correctly for gemma models", () => { + // stripModelPrefix("gemini/gemma-4-31b-it") → "gemma-4-31b-it" + assert.equal(getModelRpd("gemini/gemma-4-31b-it"), 1500); +}); + +test("getModelRpd handles image-generation models (no RPM value, -1)", () => { + // RPD is 25 for imagen models; RPM is -1 in the JSON + assert.equal(getModelRpd("imagen-4-generate"), 25); +}); + +test("getModelRpd handles models with unlimited RPD (-1)", () => { + // gemini-3.5-live-translate has rpd: -1 + assert.equal(getModelRpd("gemini-3.5-live-translate"), 0); +}); + +test("getModelRpd returns 0 for null input", () => { + assert.equal(getModelRpd(null as unknown as string), 0); +}); + +test("getModelRpd returns 0 for undefined input", () => { + assert.equal(getModelRpd(undefined as unknown as string), 0); +}); + +// ── incrementRequestCount / getDailyRequestCount ───────────────────────────── + +test("incrementRequestCount starts at 1 for first request", () => { + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 1); +}); + +test("incrementRequestCount increments sequentially", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 3); +}); + +test("incrementRequestCount treats gemini/ prefix and bare name as same model", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2); + assert.equal(getDailyRequestCount("gemini/gemini-2.5-flash"), 2); +}); + +test("models have independent counters", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 2); + assert.equal(getDailyRequestCount("gemma-4-31b-it"), 1); +}); + +test("getDailyRequestCount returns 0 for model with no requests", () => { + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); +}); + +test("incrementRequestCount does nothing for empty model ID", () => { + incrementRequestCount(""); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); +}); + +test("resetCounters clears all state between tests", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + resetCounters(); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 0); + assert.equal(getDailyRequestCount("gemma-4-31b-it"), 0); +}); + +// ── isRpdExhausted ─────────────────────────────────────────────────────────── + +test("isRpdExhausted returns false when count is below RPD limit", () => { + // gemini-2.5-flash has RPD=20 + for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); +}); + +test("isRpdExhausted returns true when count equals RPD limit", () => { + // gemini-2.5-flash has RPD=20 + for (let i = 0; i < 20; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); +}); + +test("isRpdExhausted returns true when count exceeds RPD limit", () => { + for (let i = 0; i < 25; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); +}); + +test("isRpdExhausted returns false for model with RPD=0", () => { + // gemini-2.5-pro has rpd=0 + incrementRequestCount("gemini-2.5-pro"); + assert.equal(isRpdExhausted("gemini-2.5-pro"), false); +}); + +test("isRpdExhausted returns false for unknown model", () => { + incrementRequestCount("gemini/unknown-model"); + assert.equal(isRpdExhausted("gemini/unknown-model"), false); +}); + +test("isRpdExhausted returns false for model with unlimited RPD (-1, no data)", () => { + // gemini-3.5-live-translate has rpd: -1 + incrementRequestCount("gemini-3.5-live-translate"); + assert.equal(isRpdExhausted("gemini-3.5-live-translate"), false); +}); + +test("isRpdExhausted works with gemini/ prefix for the model", () => { + for (let i = 0; i < 20; i++) incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini/gemini-2.5-flash"), true); +}); + +// ── Integration: Gemma 4 RPM scenario ──────────────────────────────────────── + +test("Gemma 4: 15 RPM hits never trigger quota_exhausted (RPD=1500)", () => { + // Gemma 4 has RPD=1500, so 15 RPM hits should not trigger RPD exhaustion + for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); + assert.equal(getDailyRequestCount("gemini/gemma-4-31b-it"), 15); +}); + +test("Gemma 4: RPD exhaustion requires 1500 requests", () => { + for (let i = 0; i < 1499; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); + + incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), true); +}); + +// ── Integration: Gemini 2.5 Flash RPD scenario ─────────────────────────────── + +test("Gemini 2.5 Flash: first 19 requests do NOT exhaust RPD, 20th does", () => { + for (let i = 0; i < 19; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false, "19 requests < 20 RPD"); + + incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true, "20 requests = 20 RPD"); +}); + +test("Gemini 2.5 Flash: excess requests stay exhausted", () => { + for (let i = 0; i < 22; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpdExhausted("gemini-2.5-flash"), true); + assert.equal(getDailyRequestCount("gemini-2.5-flash"), 22); +}); + +// ── getModelRpm ─────────────────────────────────────────────────────────────── + +test("getModelRpm returns known RPM for exact model match", () => { + assert.equal(getModelRpm("gemini-2.5-flash"), 5); +}); + +test("getModelRpm returns known RPM for model with gemini/ prefix", () => { + assert.equal(getModelRpm("gemini/gemini-2.5-flash"), 5); +}); + +test("getModelRpm returns 0 for model with zero RPM", () => { + assert.equal(getModelRpm("gemini-2.5-pro"), 0); +}); + +test("getModelRpm returns 0 for unknown model", () => { + assert.equal(getModelRpm("gemini/fake-model-not-in-list"), 0); +}); + +test("getModelRpm returns 0 for empty string", () => { + assert.equal(getModelRpm(""), 0); +}); + +test("getModelRpm returns 0 for models with RPM=-1 (imagen)", () => { + assert.equal(getModelRpm("imagen-4-generate"), 0); +}); + +test("getModelRpm returns 0 for null input", () => { + assert.equal(getModelRpm(null as unknown as string), 0); +}); + +// ── incrementMinuteRequestCount / getMinuteRequestCount ─────────────────────── + +test("getMinuteRequestCount returns 0 before first request", () => { + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); +}); + +test("incrementMinuteRequestCount starts at 1", () => { + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 1); +}); + +test("incrementMinuteRequestCount increments with each call", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 3); +}); + +test("incrementMinuteRequestCount normalizes gemini/ prefix", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2); +}); + +test("minute counters are independent per model", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 2); + assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 1); +}); + +test("incrementRequestCount does nothing for empty model ID", () => { + incrementRequestCount(""); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); +}); + +test("resetCounters clears minute windows", () => { + incrementRequestCount("gemini-2.5-flash"); + incrementRequestCount("gemma-4-31b-it"); + resetCounters(); + assert.equal(getMinuteRequestCount("gemini-2.5-flash"), 0); + assert.equal(getMinuteRequestCount("gemma-4-31b-it"), 0); +}); + +// ── isRpmExhausted ──────────────────────────────────────────────────────────── + +test("isRpmExhausted returns false below RPM limit", () => { + // gemini-2.5-flash has RPM=5 + for (let i = 0; i < 4; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), false); +}); + +test("isRpmExhausted returns true at RPM limit", () => { + for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); +}); + +test("isRpmExhausted returns true above RPM limit", () => { + for (let i = 0; i < 8; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); +}); + +test("isRpmExhausted returns false for model with RPM=0", () => { + incrementRequestCount("gemini-2.5-pro"); + assert.equal(isRpmExhausted("gemini-2.5-pro"), false); +}); + +test("isRpmExhausted returns false for unknown model", () => { + incrementRequestCount("gemini/unknown-model"); + assert.equal(isRpmExhausted("gemini/unknown-model"), false); +}); + +test("isRpmExhausted returns false for model with RPM=-1 (imagen)", () => { + incrementRequestCount("imagen-4-generate"); + assert.equal(isRpmExhausted("imagen-4-generate"), false); +}); + +test("isRpmExhausted works with gemini/ prefix", () => { + for (let i = 0; i < 5; i++) incrementRequestCount("gemini/gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini/gemini-2.5-flash"), true); +}); + +// ── Integration: RPM + RPD work independently ───────────────────────────────── + +test("RPM and RPD limits are tracked independently", () => { + // Gemma 4: RPM=15, RPD=1500 + // After 15 requests, RPM is exhausted but RPD is not + for (let i = 0; i < 15; i++) incrementRequestCount("gemini/gemma-4-31b-it"); + assert.equal(isRpmExhausted("gemini/gemma-4-31b-it"), true); + assert.equal(isRpdExhausted("gemini/gemma-4-31b-it"), false); +}); + +test("Gemini 2.5 Flash: RPM=5 always hits before RPD=20", () => { + // After 5 requests, RPM is exhausted; after 20, RPD is exhausted + for (let i = 0; i < 5; i++) incrementRequestCount("gemini-2.5-flash"); + assert.equal(isRpmExhausted("gemini-2.5-flash"), true); + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); + + incrementRequestCount("gemini-2.5-flash"); // 6 + incrementRequestCount("gemini-2.5-flash"); // 7 + incrementRequestCount("gemini-2.5-flash"); // 8 + + // Still at RPD=8 which is < 20 + assert.equal(isRpdExhausted("gemini-2.5-flash"), false); +}); diff --git a/tests/unit/socks5-default-on.test.ts b/tests/unit/socks5-default-on.test.ts new file mode 100644 index 0000000000..02ed37c616 --- /dev/null +++ b/tests/unit/socks5-default-on.test.ts @@ -0,0 +1,43 @@ +/** + * TDD — SOCKS5 proxy support must default ON (opt-OUT), so a fresh deploy + * (Docker/npm/Electron with no .env) honours SOCKS5 proxies out of the box. + * Previously the code defaulted OFF (`=== "true"`), so SOCKS5 proxies were + * silently rejected unless the operator set the env explicitly — accounts then + * fell back to the host IP. Only an explicit falsey value disables it now. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { isSocks5ProxyEnabled } = await import("../../open-sse/utils/proxyDispatcher.ts"); + +function withEnv(value: string | undefined, fn: () => void) { + const prev = process.env.ENABLE_SOCKS5_PROXY; + if (value === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = value; + try { + fn(); + } finally { + if (prev === undefined) delete process.env.ENABLE_SOCKS5_PROXY; + else process.env.ENABLE_SOCKS5_PROXY = prev; + } +} + +test("SOCKS5 is ON by default when the env is unset", () => { + withEnv(undefined, () => assert.equal(isSocks5ProxyEnabled(), true)); +}); + +test("SOCKS5 is ON for empty string (treated as unset)", () => { + withEnv("", () => assert.equal(isSocks5ProxyEnabled(), true)); +}); + +test("SOCKS5 stays ON for explicit true-ish values", () => { + for (const v of ["true", "TRUE", "1", "yes", "on"]) { + withEnv(v, () => assert.equal(isSocks5ProxyEnabled(), true, `value ${v} must enable`)); + } +}); + +test("SOCKS5 is OFF only for explicit falsey values (opt-out)", () => { + for (const v of ["false", "FALSE", "0", "no", "off"]) { + withEnv(v, () => assert.equal(isSocks5ProxyEnabled(), false, `value ${v} must disable`)); + } +}); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index eef3b244f7..ce049cef19 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -1077,6 +1077,14 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko circuitBreakerReset: 5000, }, }, + modelLockout: { + enabled: true, + baseCooldownMs: 250, + maxCooldownMs: 1000, + maxBackoffSteps: 3, + useExponentialBackoff: true, + errorCodes: [404], + }, }); const connection = await seedConnection("openai", { @@ -1101,6 +1109,8 @@ test("markAccountUnavailable uses configured cooldowns for local 404 model locko assert.equal(updated.rateLimitedUntil, undefined); assert.equal(updated.lastErrorType, "not_found"); assert.equal(Number(updated.errorCode), 404); + + await settingsDb.updateSettings({ modelLockout: null }); }); test("markAccountUnavailable applies a model-only lockout for Gemini 429 responses", async () => { @@ -1481,3 +1491,36 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () db.prepare = originalPrepare; } }); + +test("markAccountUnavailable persists in-memory model lockout for combo transient 429 when persistUnavailableState=false", async () => { + const connection = await seedConnection("openai", { + name: "combo-transient-test", + }); + const model = "gpt-4o"; + const connId = connection.id as string; + + assert.equal(fallback.isModelLocked("openai", connId, model), false); + + await auth.markAccountUnavailable( + connId, + 429, + "Rate limit exceeded", + "openai", + model, + null, + { persistUnavailableState: false } + ); + + assert.equal(fallback.isModelLocked("openai", connId, model), true); + + assert.equal(fallback.isModelLocked("openai", connId, "gpt-4o-mini"), false); + + const otherConn = await seedConnection("openai", { + name: "other-conn", + }); + assert.equal(fallback.isModelLocked("openai", (otherConn.id as string), model), false); + + const updated = await providersDb.getProviderConnectionById(connId); + assert.equal(updated.rateLimitedUntil == null, true); + assert.notEqual(updated.testStatus, "unavailable"); +}); diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7447ddc7dd..a955759ec1 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -725,7 +725,11 @@ test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for R created: 1, model: "gpt-5.4", choices: [ - { index: 0, delta: { role: "assistant", content: null, refusal: null }, finish_reason: null }, + { + index: 0, + delta: { role: "assistant", content: null, refusal: null }, + finish_reason: null, + }, ], })}\n\n`, `event: response.created\ndata: ${JSON.stringify({ @@ -1047,50 +1051,55 @@ test("createSSEStream passthrough merges Claude usage chunks and restores mapped assert.equal(onCompletePayload.responseBody.usage.total_tokens, 10); }); -test("createSSEStream passthrough injects a synthetic Claude text block for empty assistant SSE", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `event: message_start\ndata: ${JSON.stringify({ - type: "message_start", - message: { - id: "msg_empty_passthrough", - type: "message", - role: "assistant", - model: "claude-sonnet-4", - content: [], - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 7, output_tokens: 0 }, +test("#3685 createSSEStream passthrough emits SSE error (not synthetic text) for empty Claude assistant SSE", async () => { + let failurePayload = null; + let completePayload = null; + await assert.rejects( + readTransformed( + [ + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { + id: "msg_empty_passthrough", + type: "message", + role: "assistant", + model: "claude-sonnet-4", + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 7, output_tokens: 0 }, + }, + })}\n\n`, + `event: message_stop\ndata: ${JSON.stringify({ + type: "message_stop", + })}\n\n`, + ], + { + mode: "passthrough", + sourceFormat: FORMATS.CLAUDE, + provider: "claude", + model: "claude-sonnet-4", + body: { + messages: [{ role: "user", content: "hello" }], }, - })}\n\n`, - `event: message_stop\ndata: ${JSON.stringify({ - type: "message_stop", - })}\n\n`, - ], - { - mode: "passthrough", - sourceFormat: FORMATS.CLAUDE, - provider: "claude", - model: "claude-sonnet-4", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + onFailure(payload) { + failurePayload = payload; + }, + onComplete(payload) { + completePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.equal((text.match(/event: message_delta/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_stop") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); + assert.equal(failurePayload.code, "empty_response", "code must identify the failure kind"); + assert.equal(typeof failurePayload.code, "string", "code must be a string"); + assert.ok(failurePayload.message.length > 0, "message must be non-empty"); + assert.ok(failurePayload.status >= 500, "status must be a server error (5xx)"); + assert.equal(completePayload, null, "onComplete must not fire when stream is empty"); }); test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", async () => { @@ -1149,51 +1158,54 @@ test("createSSEStream passthrough does not emit [DONE] for Claude SSE clients", assert.doesNotMatch(text, /\[DONE\]/); }); -test("createSSEStream translate mode injects a synthetic Claude text block when OpenAI finishes empty", async () => { - let onCompletePayload = null; - const text = await readTransformed( - [ - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, +test("#3685 createSSEStream translate mode emits SSE error (not synthetic text) when OpenAI upstream finishes empty for Claude client", async () => { + let failurePayload = null; + let completePayload = null; + await assert.rejects( + readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_empty_1", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, + })}\n\n`, + ], + { + mode: "translate", + targetFormat: FORMATS.OPENAI, + sourceFormat: FORMATS.CLAUDE, + provider: "openai", model: "gpt-4.1-mini", - choices: [{ index: 0, delta: { role: "assistant" } }], - })}\n\n`, - `data: ${JSON.stringify({ - id: "chatcmpl_empty_1", - object: "chat.completion.chunk", - created: 1, - model: "gpt-4.1-mini", - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - usage: { prompt_tokens: 3, completion_tokens: 0, total_tokens: 3 }, - })}\n\n`, - ], - { - mode: "translate", - targetFormat: FORMATS.OPENAI, - sourceFormat: FORMATS.CLAUDE, - provider: "openai", - model: "gpt-4.1-mini", - body: { - messages: [{ role: "user", content: "hello" }], - }, - onComplete(payload) { - onCompletePayload = payload; - }, - } + body: { + messages: [{ role: "user", content: "hello" }], + }, + onFailure(payload) { + failurePayload = payload; + }, + onComplete(payload) { + completePayload = payload; + }, + } + ), + /empty response/i ); - - assert.equal((text.match(/event: message_start/g) || []).length, 1); - assert.match(text, /event: content_block_start/); - assert.match(text, /event: content_block_delta/); - assert.match(text, /event: message_delta/); - assert.match(text, /event: message_stop/); - assert.ok(text.indexOf("event: content_block_start") > text.indexOf("event: message_start")); - assert.ok(text.indexOf("event: message_delta") > text.indexOf("event: content_block_stop")); - // SYNTHETIC_CLAUDE_EMPTY_RESPONSE_TEXT is "" so the accumulator produces null content (empty delta is falsy). - assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); - assert.equal(onCompletePayload.responseBody.usage.total_tokens, 3); + assert.ok(failurePayload, "onFailure should be called"); + assert.equal(failurePayload.status, 502); + assert.match(failurePayload.message, /empty response/i); + assert.equal(failurePayload.code, "empty_response", "code must identify the failure kind"); + assert.ok(failurePayload.message.length > 0, "message must be non-empty"); + assert.ok(failurePayload.status >= 500, "status must be a server error (5xx)"); + assert.equal(completePayload, null, "onComplete must not fire when stream is empty"); }); test("createSSETransformStreamWithLogger flushes a trailing Claude usage event without a newline", async () => { @@ -1741,7 +1753,7 @@ test("createSSEStream passthrough logs empty response after tool_calls completio index: 0, id: "call_tc", type: "function", - function: { name: "task_complete", arguments: '{}' }, + function: { name: "task_complete", arguments: "{}" }, }, ], }, @@ -1771,7 +1783,10 @@ test("createSSEStream passthrough logs empty response after tool_calls completio assert.match(text, /"finish_reason":"tool_calls"/); assert.equal(onCompletePayload.status, 200); assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls"); - assert.equal(onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, "task_complete"); + assert.equal( + onCompletePayload.responseBody.choices[0].message.tool_calls[0].function.name, + "task_complete" + ); // Content should be null (empty) since no text was generated assert.equal(onCompletePayload.responseBody.choices[0].message.content, null); }); diff --git a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts index 0fcc59bb74..08c47b3e78 100644 --- a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts +++ b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts @@ -120,3 +120,26 @@ test("T26: explicit stream aliases resolve true/false correctly", () => { assert.equal(resolveExplicitStreamAlias({ disable_streaming: true }), false); assert.equal(resolveExplicitStreamAlias({}), undefined); }); + +test("T26: sourceFormat=openai-responses applies spec default (stream=false when omitted) (#3708)", () => { + // OpenAI Responses API spec: omitting `stream` means non-streaming, same as claude. + // Previously OmniRoute fell through to the wildcard-Accept heuristic, treating + // Accept: */* as streaming intent → STREAM_EARLY_EOF / 502 on spec-compliant upstreams. + + // Wildcard and undefined Accept must default to non-stream + assert.equal(resolveStreamFlag(undefined, undefined, "openai-responses"), false); + assert.equal(resolveStreamFlag(undefined, "*/*", "openai-responses"), false); + assert.equal(resolveStreamFlag(undefined, "application/json", "openai-responses"), false); + + // Explicit body stream:true still wins + assert.equal(resolveStreamFlag(true, undefined, "openai-responses"), true); + assert.equal(resolveStreamFlag(true, "*/*", "openai-responses"), true); + assert.equal(resolveStreamFlag(false, "text/event-stream", "openai-responses"), false); + + // Accept: text/event-stream is honored as an explicit SSE opt-in + assert.equal(resolveStreamFlag(undefined, "text/event-stream", "openai-responses"), true); + assert.equal( + resolveStreamFlag(undefined, "application/json, text/event-stream", "openai-responses"), + true + ); +}); diff --git a/tests/unit/t29-vertex-sa-json-executor.test.ts b/tests/unit/t29-vertex-sa-json-executor.test.ts index e2a2207ce7..f367718084 100644 --- a/tests/unit/t29-vertex-sa-json-executor.test.ts +++ b/tests/unit/t29-vertex-sa-json-executor.test.ts @@ -55,17 +55,34 @@ test("T29: Vertex executor headers include Bearer token and SSE Accept when stre assert.equal(headers.Accept, "text/event-stream"); }); -test("T29: Vertex executor rejects invalid Service Account JSON clearly", async () => { +test("T29: Vertex executor rejects incomplete Service Account JSON clearly", async () => { const executor = new VertexExecutor(); + // A JSON object (not an opaque Express key) that is missing client_email/private_key must still + // fail clearly when the executor tries to mint a JWT from it. await assert.rejects( () => executor.execute({ model: "gemini-2.5-flash", body: { contents: [] }, stream: false, - credentials: { apiKey: "not-json" }, + credentials: { apiKey: JSON.stringify({ project_id: "p" }) }, }), - /Service Account JSON/i + /missing required fields/i + ); +}); + +test("T29: Vertex executor routes a non-JSON Express API key to the project-less publisher endpoint", () => { + const executor = new VertexExecutor(); + const stream = executor.buildUrl("gemini-2.5-flash", true, 0, { apiKey: "express-key-123" }); + const nonStream = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: "express-key-123" }); + + assert.equal( + stream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse&key=express-key-123" + ); + assert.equal( + nonStream, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=express-key-123" ); }); diff --git a/tests/unit/test-all-model-status.test.ts b/tests/unit/test-all-model-status.test.ts new file mode 100644 index 0000000000..0bf1318bba --- /dev/null +++ b/tests/unit/test-all-model-status.test.ts @@ -0,0 +1,58 @@ +// Regression for the "Test all models" status-icon bug: +// individual ▶ test turns each model's icon green/red (onTestModel sets +// modelTestStatus), but "Test all models" only showed a toast and left every +// icon blank — the user could not tell which model passed or failed. +// +// Both handleTestAll implementations (ProviderDetailPageClient + PassthroughModelsSection) +// now route each model's result through evaluateTestAllEntry() and apply the returned +// status to modelTestStatus. This pure helper captures the status + auto-hide decision +// so it is testable in the gating node suite (matches the #3610 helper-extraction idiom). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateTestAllEntry } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; + +test("ok entry maps to status 'ok' and is never hidden", () => { + assert.deepEqual(evaluateTestAllEntry({ status: "ok" }, true), { + status: "ok", + shouldHide: false, + }); + assert.deepEqual(evaluateTestAllEntry({ status: "ok" }, false), { + status: "ok", + shouldHide: false, + }); +}); + +test("failed entry maps to status 'error' and hides only when autoHide is on", () => { + assert.deepEqual(evaluateTestAllEntry({ status: "error" }, true), { + status: "error", + shouldHide: true, + }); + assert.deepEqual(evaluateTestAllEntry({ status: "error" }, false), { + status: "error", + shouldHide: false, + }); +}); + +test("rate-limited / timeout failures show 'error' and ARE auto-hidden (hide every failure)", () => { + assert.deepEqual(evaluateTestAllEntry({ status: "error", rateLimited: true }, true), { + status: "error", + shouldHide: true, + }); + assert.deepEqual(evaluateTestAllEntry({ status: "error", isTimeout: true }, true), { + status: "error", + shouldHide: true, + }); + // ...but only when the toggle is on + assert.deepEqual(evaluateTestAllEntry({ status: "error", rateLimited: true }, false), { + status: "error", + shouldHide: false, + }); +}); + +test("missing / null / empty entry is treated as a failure", () => { + for (const entry of [undefined, null, {}]) { + const out = evaluateTestAllEntry(entry, true); + assert.equal(out.status, "error", `entry=${JSON.stringify(entry)} → error`); + assert.equal(out.shouldHide, true); + } +}); diff --git a/tests/unit/test-all-results-i18n.test.ts b/tests/unit/test-all-results-i18n.test.ts new file mode 100644 index 0000000000..0770598b1f --- /dev/null +++ b/tests/unit/test-all-results-i18n.test.ts @@ -0,0 +1,66 @@ +// Regression for the "Test all models" crash: +// FORMATTING_ERROR: The intl string context variable "total" was not provided +// to the string "{ok} of {total} models working" +// +// The two call sites (ProviderDetailPageClient.tsx, PassthroughModelsSection.tsx) +// passed { ok, error } while the en.json `testAllResults` template needs { ok, total }. +// The fix centralises the variable contract in testAllResultsText() so it can be +// validated against the REAL en.json template here. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { testAllResultsText } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; + +const enPath = join(dirname(fileURLToPath(import.meta.url)), "../../src/i18n/messages/en.json"); +const en = JSON.parse(readFileSync(enPath, "utf8")) as Record; + +function findMessage(obj: Record, key: string): string | null { + for (const [k, v] of Object.entries(obj)) { + if (k === key && typeof v === "string") return v; + if (v && typeof v === "object") { + const r = findMessage(v as Record, key); + if (r != null) return r; + } + } + return null; +} + +// Faithful-enough next-intl stand-in: formats the REAL en.json template and throws +// (as ICU MessageFormat / next-intl does) when a referenced {placeholder} has no value. +function makeIntlTranslator(message: string) { + const fn = (_key: string, values?: Record) => + message.replace(/\{(\w+)\}/g, (_m, name: string) => { + if (!values || !(name in values)) { + throw new Error( + `FORMATTING_ERROR: The intl string context variable "${name}" was not provided to the string "${message}"` + ); + } + return String(values[name]); + }); + return Object.assign(fn, { has: (_k: string) => true }); +} + +test("en.json defines the testAllResults message", () => { + assert.ok(findMessage(en, "testAllResults"), "testAllResults must exist in en.json"); +}); + +test("testAllResultsText satisfies every variable the en.json template references", () => { + const template = findMessage(en, "testAllResults"); + assert.ok(template); + const t = makeIntlTranslator(template); + // Must NOT throw — this is the exact crash path: providerText sees the key present + // and calls t(key, values); a missing variable raises FORMATTING_ERROR. + let out = ""; + assert.doesNotThrow(() => { + out = testAllResultsText(t, 1, 2); + }); + assert.match(out, /1/); + assert.match(out, /2/); +}); + +test("testAllResultsText falls back to a sensible English string when untranslated", () => { + const tStub = Object.assign((k: string) => k, { has: (_k: string) => false }); + assert.equal(testAllResultsText(tStub, 3, 4), "3 of 4 models working"); +}); diff --git a/tests/unit/token-health-check-circuit-breaker.test.ts b/tests/unit/token-health-check-circuit-breaker.test.ts new file mode 100644 index 0000000000..b4da27af55 --- /dev/null +++ b/tests/unit/token-health-check-circuit-breaker.test.ts @@ -0,0 +1,89 @@ +/** + * TDD — HealthCheck refresh circuit breaker. + * + * Production incident: claude/aa5dd5cf refreshed 1352× and kimi-coding 270×, + * each retrying every 60s forever because a refresh that returns `null` + * (network blip, dead proxy, or an unclassified error) leaves the connection + * `active`, so the next sweep tries again immediately — no backoff. + * + * The circuit breaker tracks consecutive refresh failures in + * providerSpecificData.refreshCircuit and computes an exponential backoff + * window. While inside the window, checkConnection must SKIP the refresh + * instead of hammering every tick. A successful refresh clears the circuit. + * + * These exercise the pure helpers (no DB/network needed). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const tokenHealthCheck = await import("../../src/lib/tokenHealthCheck.ts"); +const { buildRefreshFailureUpdate, isInRefreshBackoff, getRefreshBackoffUntil } = + tokenHealthCheck as unknown as { + buildRefreshFailureUpdate: (conn: any, now: string) => any; + isInRefreshBackoff: (conn: any, nowMs: number) => boolean; + getRefreshBackoffUntil: (streak: number, now: string) => string; + }; + +const NOW = "2026-06-11T12:00:00.000Z"; +const NOW_MS = new Date(NOW).getTime(); + +test("getRefreshBackoffUntil grows exponentially and caps", () => { + const min = (iso: string) => Math.round((new Date(iso).getTime() - NOW_MS) / 60000); + assert.equal(min(getRefreshBackoffUntil(1, NOW)), 5); // 5 * 2^0 + assert.equal(min(getRefreshBackoffUntil(2, NOW)), 10); // 5 * 2^1 + assert.equal(min(getRefreshBackoffUntil(3, NOW)), 20); + assert.equal(min(getRefreshBackoffUntil(4, NOW)), 40); + assert.ok(min(getRefreshBackoffUntil(20, NOW)) <= 240, "must cap at 4h"); +}); + +test("buildRefreshFailureUpdate starts a circuit streak of 1 on first failure", () => { + const update = buildRefreshFailureUpdate({ testStatus: "active" }, NOW); + assert.equal(update.testStatus, "active", "first failure stays routable"); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); + assert.ok( + new Date(update.providerSpecificData.refreshCircuit.until).getTime() > NOW_MS, + "must set a future backoff window" + ); +}); + +test("buildRefreshFailureUpdate increments the streak across consecutive failures", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "active", providerSpecificData: { refreshCircuit: { streak: 3 } } }, + NOW + ); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 4); +}); + +test("buildRefreshFailureUpdate preserves unrelated providerSpecificData", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "active", providerSpecificData: { projectId: "p-123", copilotToken: "x" } }, + NOW + ); + assert.equal(update.providerSpecificData.projectId, "p-123"); + assert.equal(update.providerSpecificData.copilotToken, "x"); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); +}); + +test("isInRefreshBackoff true while within the window, false after", () => { + const conn = { + providerSpecificData: { refreshCircuit: { until: getRefreshBackoffUntil(2, NOW) } }, + }; + assert.equal(isInRefreshBackoff(conn, NOW_MS), true, "10min window, now → inside"); + assert.equal(isInRefreshBackoff(conn, NOW_MS + 11 * 60000), false, "after 11min → outside"); +}); + +test("isInRefreshBackoff false when no circuit recorded", () => { + assert.equal(isInRefreshBackoff({}, NOW_MS), false); + assert.equal(isInRefreshBackoff({ providerSpecificData: {} }, NOW_MS), false); + assert.equal(isInRefreshBackoff({ providerSpecificData: { refreshCircuit: {} } }, NOW_MS), false); +}); + +test("expired connections still track expiredRetryCount AND the circuit", () => { + const update = buildRefreshFailureUpdate( + { testStatus: "expired", expiredRetryCount: 1 }, + NOW + ); + assert.equal(update.testStatus, "expired"); + assert.equal(update.expiredRetryCount, 2); + assert.equal(update.providerSpecificData.refreshCircuit.streak, 1); +}); diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 1bf5ca456a..cd819c574f 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -84,7 +84,8 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.equal(result.stream, true); assert.equal(result.max_tokens, 33); assert.equal(result.temperature, 0.25); - assert.equal(result.top_p, 0.8); + // top_p is stripped when temperature is also present (Anthropic rejects both). + assert.equal(result.top_p, undefined); assert.deepEqual(result.stop_sequences, ["DONE"]); assert.equal(result.system[0].text, "Rule A\nRule B\nRule C"); assert.equal(result.messages[0].role, "user"); @@ -94,6 +95,21 @@ test("OpenAI -> Claude maps system messages, parameters and assistant cache mark assert.deepEqual(result.messages[1].content[0].cache_control, { type: "ephemeral" }); }); +test("OpenAI -> Claude strips top_p when temperature is also present", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [{ role: "user", content: "Hello" }], + temperature: 0.25, + top_p: 0.8, + }, + false + ); + + assert.equal(result.temperature, 0.25); + assert.equal(result.top_p, undefined); +}); + test("OpenAI -> Claude converts multimodal content, tool declarations, tool calls and tool results", () => { const result = openaiToClaudeRequest( "claude-4-sonnet", diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 04bf81de7b..dcae756f0a 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -1362,11 +1362,17 @@ test("native-mode assistant tool_calls produce functionCall parts, not text labe ); }); -// Integration: registered translator (OPENAI -> GEMINI) emits native functionCall/functionResponse -test("registered OPENAI->GEMINI translator uses native functionCall/functionResponse, not text labels", () => { +// Integration: registered translator (OPENAI -> GEMINI) uses context-mode for +// signature-less tool calls (post-#3688 fix: "native" → "context" registration). +// A signature-less functionCall must be omitted from native parts and represented +// as context text so the standard Gemini API does not return HTTP 400. +// For a SIGNED call (signature in store) native parts must still be emitted — see +// the "keeps native functionCall+thoughtSignature when signature is present" test. +test("registered OPENAI->GEMINI translator uses context-mode for signature-less tool calls, not text labels or native bare functionCall", () => { const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); assert.ok(typeof translate === "function", "registered translator must be a function"); + // No signature in store (cleared by beforeEach) — context-mode fallback applies. const result = translate( "gemini-2.0-flash", { @@ -1395,15 +1401,18 @@ test("registered OPENAI->GEMINI translator uses native functionCall/functionResp ) as any; const body = JSON.stringify(result); - // Native mode: functionCall/functionResponse parts, no text labels - assert.ok( + // Context mode: signature-less functionCall must NOT appear as a native part. + assert.equal( body.includes('"functionCall"'), - "registered translator must emit native functionCall parts" + false, + "registered translator must NOT emit bare native functionCall parts for signature-less calls (would trigger HTTP 400)" ); - assert.ok( + assert.equal( body.includes('"functionResponse"'), - "registered translator must emit native functionResponse parts" + false, + "registered translator must NOT emit native functionResponse for signature-less calls" ); + // Old leaky text labels must never appear. assert.equal( body.includes("Historical tool-call record only"), false, @@ -1417,6 +1426,133 @@ test("registered OPENAI->GEMINI translator uses native functionCall/functionResp assert.equal( body.includes("[tool_history_call:"), false, - "native mode must NOT emit text labels" + "text-label format must NOT be used by registered GEMINI translator" + ); + // Context-mode: tool result must appear as block. + assert.ok( + body.includes("previous_tool_result_context"), + "signature-less tool result must be represented as context text block" + ); +}); + +// Regression for #3688: standard Gemini (AI Studio) returns HTTP 400 +// "Function call is missing a thought_signature" when a multi-turn conversation +// includes a functionCall whose signature was not captured (process restart / TTL +// expiry / never stored). The standard GEMINI registration must use "context" mode +// so signature-less tool calls are omitted from the native parts and represented as +// context text, avoiding the 400 while preserving conversational continuity. +test("registered OPENAI->GEMINI translator falls back to context mode for signatureless tool calls (#3688)", () => { + // Signature store is cleared by beforeEach — no stored signature for call_3688. + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); + assert.ok(typeof translate === "function", "registered translator must be a function"); + + const result = translate( + "gemini-2.5-pro-preview", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_3688_missing_sig", + type: "function", + function: { name: "bash", arguments: '{"cmd":"ls /tmp"}' }, + }, + ], + }, + { + role: "tool", + tool_call_id: "call_3688_missing_sig", + content: "file-a.txt\nfile-b.txt", + }, + { role: "user", content: "What files did you see?" }, + ], + }, + false, + { _signatureNamespace: "conn-3688-test" } + ) as any; + + const body = JSON.stringify(result); + + // (a) NO functionCall part lacking a thoughtSignature must appear. + // A functionCall without a thoughtSignature is the exact payload that triggers + // Gemini's HTTP 400 "Function call is missing a thought_signature". + const modelTurn = result.contents.find( + (c: any) => c.role === "model" && c.parts?.some((p: any) => p.functionCall) + ); + assert.equal( + modelTurn, + undefined, + "standard GEMINI translator must NOT emit a functionCall part when thoughtSignature is absent (would trigger HTTP 400)" + ); + + // (b) The tool call/result must be represented as context/text fallback instead. + // In "context" mode the response is wrapped in tags. + assert.ok( + body.includes("previous_tool_result_context"), + "signature-less tool result must be represented as a context text block when signature is absent" + ); + assert.ok( + body.includes("file-a.txt"), + "context text block must contain the tool response content" + ); +}); + +// Happy-path: when thoughtSignature IS present in the store, the registered +// OPENAI->GEMINI translator must still emit native functionCall + thoughtSignature +// (no regression on the signed-signature path fixed by #2504). +test("registered OPENAI->GEMINI translator keeps native functionCall+thoughtSignature when signature is present (#2504 no-regression)", async () => { + const { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature } = + await import("../../open-sse/services/geminiThoughtSignatureStore.ts"); + const ns = "conn-3688-signed-happy"; + const toolId = "call_3688_signed"; + storeGeminiThoughtSignature(buildGeminiThoughtSignatureKey(ns, toolId), "SIG_3688_HAPPY_PATH"); + + const translate = getRequestTranslator(FORMATS.OPENAI, FORMATS.GEMINI); + const result = translate( + "gemini-2.5-pro-preview", + { + messages: [ + { role: "user", content: "Run a tool" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: toolId, + type: "function", + function: { name: "bash", arguments: '{"cmd":"echo hi"}' }, + }, + ], + }, + { role: "tool", tool_call_id: toolId, content: "hi" }, + { role: "user", content: "What did it say?" }, + ], + }, + false, + { _signatureNamespace: ns } + ) as any; + + const body = JSON.stringify(result); + + // The functionCall part WITH the thoughtSignature must be present. + assert.ok( + body.includes("SIG_3688_HAPPY_PATH"), + "cached thoughtSignature must be re-attached to the functionCall (happy-path regression check)" + ); + assert.ok( + body.includes('"functionCall"'), + "signed tool call must be emitted as native functionCall (not context text)" + ); + assert.ok( + body.includes('"functionResponse"'), + "signed tool response must be emitted as native functionResponse" + ); + assert.equal( + body.includes("previous_tool_result_context"), + false, + "signed tool call must NOT fall back to context text" ); }); diff --git a/tests/unit/rtl-logical-classes.test.tsx b/tests/unit/ui/rtl-logical-classes.test.tsx similarity index 100% rename from tests/unit/rtl-logical-classes.test.tsx rename to tests/unit/ui/rtl-logical-classes.test.tsx diff --git a/tests/unit/vertex-express-apikey.test.ts b/tests/unit/vertex-express-apikey.test.ts new file mode 100644 index 0000000000..555b11c1de --- /dev/null +++ b/tests/unit/vertex-express-apikey.test.ts @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VertexExecutor, isExpressApiKey, looksLikeServiceAccountJson } = await import( + "../../open-sse/executors/vertex.ts" +); + +test("looksLikeServiceAccountJson is true only for a JSON object credential", () => { + assert.equal(looksLikeServiceAccountJson(JSON.stringify({ project_id: "p" })), true); + assert.equal(looksLikeServiceAccountJson("express-opaque-key"), false); + assert.equal(looksLikeServiceAccountJson(JSON.stringify([1, 2, 3])), false); + assert.equal(looksLikeServiceAccountJson(""), false); +}); + +test("isExpressApiKey is true for a non-empty, non-JSON credential", () => { + assert.equal(isExpressApiKey("AIzaSyExpressKey"), true); + assert.equal(isExpressApiKey(" "), false); + assert.equal(isExpressApiKey(""), false); + assert.equal(isExpressApiKey(null), false); + assert.equal(isExpressApiKey(undefined), false); + assert.equal(isExpressApiKey(JSON.stringify({ project_id: "p" })), false); +}); + +test("buildUrl Express: streaming google model uses streamGenerateContent + ?alt=sse&key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", true, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:streamGenerateContent?alt=sse&key=k-express" + ); +}); + +test("buildUrl Express: non-streaming google model uses generateContent?key=", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-3-flash-preview", false, 0, { apiKey: "k-express" }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=k-express" + ); +}); + +test("buildUrl Express: the API key is URL-encoded and trimmed", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { apiKey: " a/b+c=d " }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash:generateContent?key=a%2Fb%2Bc%3Dd" + ); +}); + +test("buildUrl Express: a present accessToken takes the Service Account path, not Express", () => { + const executor = new VertexExecutor(); + const url = executor.buildUrl("gemini-2.5-flash", false, 0, { + apiKey: "k-express", + accessToken: "ya29.token", + providerSpecificData: { region: "us-central1" }, + }); + assert.equal( + url, + "https://aiplatform.googleapis.com/v1/projects/unknown-project/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent" + ); +}); + +test("buildHeaders for an Express key (no accessToken) omits the Authorization header", () => { + const executor = new VertexExecutor(); + const headers = executor.buildHeaders({ apiKey: "k-express" }, false); + assert.equal(headers["Content-Type"], "application/json"); + assert.equal(headers.Authorization, undefined); +}); + +test("execute with an Express key calls the publisher endpoint directly (no OAuth token exchange)", async () => { + const executor = new VertexExecutor(); + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url: any) => { + calls.push(String(url)); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "gemini-3-flash-preview", + body: { contents: [{ role: "user", parts: [{ text: "hi" }] }] }, + stream: false, + credentials: { apiKey: "AIzaSyExpressKey" }, + } as any); + + assert.equal(result.response.status, 200); + // Exactly one call — straight to Vertex, no oauth2 token mint. + assert.equal(calls.length, 1); + assert.equal( + calls[0], + "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent?key=AIzaSyExpressKey" + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/vertex-spend-usage.test.ts b/tests/unit/vertex-spend-usage.test.ts new file mode 100644 index 0000000000..4a9bb5e13b --- /dev/null +++ b/tests/unit/vertex-spend-usage.test.ts @@ -0,0 +1,105 @@ +/** + * tests/unit/vertex-spend-usage.test.ts + * + * Vertex AI exposes no native usage/quota API for an API key or Service Account, so OmniRoute + * SELF-TRACKS spend: it sums the tokens it routed to the connection (usage_history) and prices + * them via the backend pricing table, surfacing a "$X used since this account was added" figure. + * These tests cover the aggregation helper + the fetcher response shape with a real temp DB. + */ + +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs"; + +// DATA_DIR must be set before any module that opens the DB is imported. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vertex-")); +process.env.DATA_DIR = TMP; + +const core = await import("../../src/lib/db/core.ts"); +const { getConnectionSpendUsdSinceAdded } = await import("../../src/lib/usage/usageStats.ts"); +const { __testing } = await import("../../open-sse/services/usage.ts"); +const { getVertexUsage } = __testing; + +function insertUsage( + connectionId: string, + provider: string, + model: string, + tokensIn: number, + tokensOut: number, + success = 1 +) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run(provider, model, connectionId, tokensIn, tokensOut, success, new Date().toISOString()); +} + +describe("vertex self-tracked spend", () => { + before(() => { + core.getDbInstance(); // trigger migrations + // conn-v: two SUCCESSFUL priced requests across two models. + insertUsage("conn-v", "vertex", "gemini-2.5-flash", 1_000_000, 500_000, 1); + insertUsage("conn-v", "vertex", "gemini-3-pro-image-preview", 200_000, 100_000, 1); + // a FAILED request on the same connection must NOT count toward spend. + insertUsage("conn-v", "vertex", "gemini-2.5-flash", 5_000_000, 5_000_000, 0); + // a row with a different provider on the same connection id must NOT bleed in. + insertUsage("conn-v", "vertex-partner", "claude-opus-4-7", 9_000_000, 9_000_000, 1); + // a different connection must not bleed in + insertUsage("conn-other", "vertex", "gemini-2.5-flash", 9_000_000, 9_000_000, 1); + }); + + after(() => { + core.resetDbInstance(); + try { + fs.rmSync(TMP, { recursive: true, force: true }); + } catch { + // best-effort temp cleanup + } + }); + + it("counts only the connection's successful, same-provider requests", async () => { + const { costUsd, requests } = await getConnectionSpendUsdSinceAdded("vertex", "conn-v"); + assert.equal( + requests, + 2, + "only the two successful vertex rows count (failed + vertex-partner + other-conn excluded)" + ); + assert.ok(Number.isFinite(costUsd) && costUsd >= 0, "cost is a finite, non-negative number"); + }); + + it("returns 0/0 for an unknown connection (no bleed)", async () => { + const { costUsd, requests } = await getConnectionSpendUsdSinceAdded("vertex", "conn-none"); + assert.equal(requests, 0); + assert.equal(costUsd, 0); + }); + + it("getVertexUsage returns a spend quota + $ message for a used connection", async () => { + const r = (await getVertexUsage("conn-v", "vertex")) as { + plan?: string; + message?: string; + quotas?: Record; + }; + assert.ok(r.quotas?.spend, "spend quota present (so the limits cache persists it)"); + assert.equal(r.quotas!.spend.quotaSource, "localUsageHistory"); + assert.ok(typeof r.quotas!.spend.used === "number" && r.quotas!.spend.used >= 0); + assert.ok(r.message && r.message.includes("$"), "message carries the dollar figure"); + assert.ok(r.message!.includes("2 requests"), "message reports the request count"); + }); + + it("getVertexUsage reports no-usage cleanly when nothing was routed", async () => { + const r = (await getVertexUsage("conn-empty", "vertex")) as { + message?: string; + quotas?: Record; + }; + assert.ok(r.message && /no usage/i.test(r.message), "informative no-usage message"); + assert.equal(r.quotas?.spend.used, 0); + }); + + it("getVertexUsage returns a message when connection id is missing", async () => { + const r = (await getVertexUsage("", "vertex")) as { message?: string; quotas?: unknown }; + assert.ok(r.message && !r.quotas, "no spend quota without a connection id"); + }); +}); diff --git a/tests/unit/web-cookie-auth.test.ts b/tests/unit/web-cookie-auth.test.ts index ba9e158ee3..cc3c043b80 100644 --- a/tests/unit/web-cookie-auth.test.ts +++ b/tests/unit/web-cookie-auth.test.ts @@ -6,6 +6,8 @@ const { normalizeSessionCookieHeader, stripCookieInputPrefix, buildGrokCookieHeader, + buildQwenCookieHeader, + extractQwenToken, } = await import("../../src/lib/providers/webCookieAuth.ts"); test("stripCookieInputPrefix removes 'cookie:' and 'bearer ' prefixes", () => { @@ -87,3 +89,29 @@ test("buildGrokCookieHeader: blob without sso returns empty string", () => { assert.equal(buildGrokCookieHeader("foo=1; sso-rw=CCC.ddd; bar=2"), ""); assert.equal(buildGrokCookieHeader(""), ""); }); + +test("buildQwenCookieHeader: passes through a full DevTools cookie blob", () => { + const blob = "cna=ABC; token=jwt.tok; ssxmod_itna=1-XYZ; ssxmod_itna2=1-QRS"; + assert.equal(buildQwenCookieHeader(blob), blob); +}); + +test("buildQwenCookieHeader: strips a leading 'Cookie:' prefix", () => { + assert.equal(buildQwenCookieHeader("Cookie: cna=ABC; token=jwt"), "cna=ABC; token=jwt"); +}); + +test("buildQwenCookieHeader: a bare token (no cookie pairs) yields no cookie header", () => { + assert.equal(buildQwenCookieHeader("eyJ0eXAi.abc.def"), ""); + assert.equal(buildQwenCookieHeader(""), ""); +}); + +test("extractQwenToken: pulls the token= value out of a cookie blob", () => { + assert.equal(extractQwenToken("cna=ABC; token=jwt.tok; ssxmod_itna=1-XYZ"), "jwt.tok"); +}); + +test("extractQwenToken: returns a bare token unchanged", () => { + assert.equal(extractQwenToken("eyJ0eXAi.abc.def"), "eyJ0eXAi.abc.def"); +}); + +test("extractQwenToken: a cookie blob without a token cookie yields empty string", () => { + assert.equal(extractQwenToken("cna=ABC; ssxmod_itna=1-XYZ"), ""); +});