diff --git a/CLAUDE.md b/CLAUDE.md index 1635bfcf7b..d642dfbb6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -540,7 +540,7 @@ the stale-enforcement added in Fase 6A.3. 18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree. 19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation". 20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`. -21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/implement-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit --base release/vX+1`, then VERIFY with `gh pr view --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. +21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit --base release/vX+1`, then VERIFY with `gh pr view --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view --json state`) is the authorized captain freeze — hold, don't touch. 22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening): - **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show :` or `git diff -- `; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent). - **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.) diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index 3c6f09022b..ccf2ea0d36 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -793,6 +793,20 @@ See [A2A-SERVER.md § Adding a New Skill](../frameworks/A2A-SERVER.md). Skills l inference for cross-module boundaries. - **Database**: never write raw SQL in routes or handlers — always go through `src/lib/db/` modules. Never add logic to `src/lib/localDb.ts`. +- **DB-entity typing (#3512)**: a function that writes or reads a DB table's + row shape should take/return a named TS interface mirroring that table's + columns 1:1, not `any` or an inline anonymous type at the call site. Land + the interface next to the function (e.g. `export interface UsageEntry` in + `src/lib/usage/usageHistory.ts` above `saveRequestUsage`), keep individual + fields optional/nullable when different writers populate the row + incrementally, and prefer `unknown` over `any` for a field whose shape + varies across callers (documented on the field, e.g. `UsageEntry.tokens` + accepts both raw provider-shaped usage and the normalized shape). Once a + file's `any` count reaches zero this way, add it to the + `check:any-budget:t11` allowlist (`scripts/check/check-t11-any-budget.mjs`, + `maxAny: 0`) so it can't regress. This is a first-slice convention — the + broader "no anonymous `any`" cleanup is iterative across the rest of the + codebase. - **Errors**: try/catch with specific error types, log with pino context. Never silently swallow errors in SSE streams; use abort signals for cleanup. - **Security**: never use `eval()` / `new Function()` / implied eval. Validate diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 42ccec94c2..a4967b4a08 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -148,29 +148,33 @@ Notes: - **quality-first** → taskFit 0.37 + stability 0.15 (best model for the task, consistent) - **offline-friendly** → quota 0.37 + health 0.28 (max headroom regardless of speed/cost) -### Per-Request Controls (headers) — #6023 / #6024 / #6025 +### Per-Request Controls (headers) — #6023 / #6024 / #6025 / #3470 -An `auto` combo can be steered **per request** via two headers, without mutating the +An `auto` combo can be steered **per request** via three headers, without mutating the combo's stored config. These apply only to the `auto` strategy and only for the request -that carries them; the combo's saved `modePack`/`budgetCap` are used when the header is -absent. +that carries them; the combo's saved `modePack`/`budgetCap`/`budgetFallback` are used +when the header is absent. -| Header | Accepts | Effect | -| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | -| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection, falling back to the cheapest healthy candidate if all exceed. Non-positive/garbage values are ignored. | +| Header | Accepts | Effect | +| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | +| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. | +| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. | ```bash -# Force the fastest profile and cap this request at $0.05 +# Force the fastest profile, cap this request at $0.05, and hard-block instead of overspending curl -sS http://localhost:20128/v1/chat/completions \ -H "Content-Type: application/json" \ -H "X-OmniRoute-Mode: fast" \ -H "X-OmniRoute-Budget: 0.05" \ + -H "X-OmniRoute-Budget-Fallback: strict" \ -d '{"model":"auto","messages":[{"role":"user","content":"hi"}]}' ``` Resolution is a pure function (`open-sse/services/autoCombo/requestControls.ts`); the -resolved values feed the engine's existing `config.modePack` / `config.budgetCap` inputs. +resolved values feed the engine's existing `config.modePack` / `config.budgetCap` / +`config.budgetFallback` inputs. A combo's stored `config.budgetFallback` ("strict" | +"cheapest") sets the persistent policy; the header overrides it for a single request. ## All Routing Strategies diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 92affd1c39..d68013e720 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -31,6 +31,13 @@ export function extractUsageFromResponse(responseBody, provider) { responseBody.usage.completion_tokens_details?.reasoning_tokens ?? responseBody.usage.output_tokens_details?.reasoning_tokens ?? responseBody.usage.reasoning_tokens, + // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A — + // @ryanngit). Only set the key when present so non-xAI OpenAI-shaped usage + // (Codex, DeepSeek, etc.) is unaffected. Ticks → USD conversion happens in + // costCalculator.ts, not here. + ...(Number.isFinite(Number(responseBody.usage.cost_in_usd_ticks)) + ? { cost_in_usd_ticks: Number(responseBody.usage.cost_in_usd_ticks) } + : {}), }; } diff --git a/open-sse/services/autoCombo/engine.ts b/open-sse/services/autoCombo/engine.ts index 870b7c3dc6..6d238fc98f 100644 --- a/open-sse/services/autoCombo/engine.ts +++ b/open-sse/services/autoCombo/engine.ts @@ -30,11 +30,39 @@ export interface AutoComboConfig { weights: ScoringWeights; modePack?: string; budgetCap?: number; // max cost per request in USD + /** + * Policy applied when EVERY candidate exceeds `budgetCap` (#3470): + * - "cheapest" (default): fall back to the globally cheapest candidate, even + * though it still exceeds the cap (existing/legacy behavior). + * - "strict": refuse to select — `selectProvider()` throws `BudgetExceededError` + * so the caller can surface a clear cost-exceeds-budget response instead of + * silently overspending. + */ + budgetFallback?: "cheapest" | "strict"; explorationRate: number; // 0.05 = 5% exploratory /** If set, RouterStrategy name to use for selection ('rules' | 'cost' | 'latency') */ routerStrategy?: string; } +/** + * Thrown by `selectProvider()` when `budgetFallback: "strict"` is set and no + * candidate (including the cheapest) fits within `budgetCap` (#3470). Callers + * should catch this and surface a cost-exceeds-budget response — never let it + * propagate as an unhandled 500. + */ +export class BudgetExceededError extends Error { + constructor( + public readonly budgetCap: number, + public readonly cheapestCostUsd: number + ) { + super( + `No candidate fits within the configured budget cap of $${budgetCap.toFixed(4)} ` + + `(cheapest available candidate costs $${cheapestCostUsd.toFixed(4)})` + ); + this.name = "BudgetExceededError"; + } +} + export interface SelectionResult { provider: string; model: string; @@ -293,6 +321,9 @@ export function selectProvider( const cheapest = [...candidates_].sort( (a, b) => estimatedCostFor(a) - estimatedCostFor(b) )[0]; + if (config.budgetFallback === "strict") { + throw new BudgetExceededError(config.budgetCap, cheapest ? estimatedCostFor(cheapest) : 0); + } if (cheapest) selected = cheapest; } } diff --git a/open-sse/services/autoCombo/index.ts b/open-sse/services/autoCombo/index.ts index 062dbccacf..e5dc073899 100644 --- a/open-sse/services/autoCombo/index.ts +++ b/open-sse/services/autoCombo/index.ts @@ -15,4 +15,9 @@ export { export { getTaskFitness, getTaskTypes } from "./taskFitness"; export { SelfHealingManager, getSelfHealingManager } from "./selfHealing"; export { MODE_PACKS, getModePack, getModePackNames } from "./modePacks"; -export { selectProvider, type AutoComboConfig, type SelectionResult } from "./engine"; +export { + selectProvider, + BudgetExceededError, + type AutoComboConfig, + type SelectionResult, +} from "./engine"; diff --git a/open-sse/services/autoCombo/requestControls.ts b/open-sse/services/autoCombo/requestControls.ts index 5495fb996d..4b4a6a22f9 100644 --- a/open-sse/services/autoCombo/requestControls.ts +++ b/open-sse/services/autoCombo/requestControls.ts @@ -1,16 +1,17 @@ /** - * Per-request Auto-Combo routing controls (#6023 / #6024 / #6025). + * Per-request Auto-Combo routing controls (#6023 / #6024 / #6025 / #3470). * * These let a caller steer an `auto` combo on a single request via response-safe * request headers, without changing the combo's stored config: * - * X-OmniRoute-Mode: fast | balanced | quality | (#6024/#6025) - * X-OmniRoute-Budget: (#6023) + * X-OmniRoute-Mode: fast | balanced | quality | (#6024/#6025) + * X-OmniRoute-Budget: (#6023) + * X-OmniRoute-Budget-Fallback: cheapest | strict (#3470) * - * Both resolvers are pure so they can be unit-tested and reused by the entry + * All resolvers are pure so they can be unit-tested and reused by the entry * handler (src/sse/handlers/chat.ts) and the combo router (open-sse/services/combo.ts). * The resolved values feed the auto-combo engine's existing `config.modePack` / - * `config.budgetCap` inputs — no engine changes required. + * `config.budgetCap` / `config.budgetFallback` inputs. */ import { MODE_PACKS } from "./modePacks"; @@ -78,3 +79,51 @@ export function parseRequestBudgetCap(input: unknown): number | undefined { if (!Number.isFinite(n) || n <= 0) return undefined; return n; } + +/** Policy applied when every candidate exceeds `budgetCap` — see `AutoComboConfig.budgetFallback`. */ +export type RequestBudgetFallback = "cheapest" | "strict"; + +/** + * Parse the `X-OmniRoute-Budget-Fallback` header into a budget-fallback policy override. + * Unknown/empty/non-string values return `undefined` so the combo's own stored + * `config.budgetFallback` (or the engine default of `"cheapest"`) stays in effect. + */ +export function parseRequestBudgetFallback(input: unknown): RequestBudgetFallback | undefined { + if (typeof input !== "string") return undefined; + const key = input.trim().toLowerCase(); + if (key === "strict" || key === "block" || key === "hard") return "strict"; + if (key === "cheapest" || key === "cheapest-viable" || key === "soft") return "cheapest"; + return undefined; +} + +/** Aggregated per-request auto-combo overrides resolved from request headers (#3470). */ +export interface PerRequestAutoControls { + mode?: string; + budgetCap?: number; + budgetFallback?: RequestBudgetFallback; +} + +/** + * Resolve all per-request Auto-Combo headers in one pass, returning only the keys + * that were actually overridden. Consolidates `resolveRequestModePack()` / + * `parseRequestBudgetCap()` / `parseRequestBudgetFallback()` so entry handlers (e.g. + * `src/sse/handlers/chat.ts`) can thread a single object into `relayOptions` instead + * of repeating the per-header boilerplate for each new control. + */ +export function resolveRequestAutoControls(headers: { + get(name: string): string | null; +}): PerRequestAutoControls { + const modeHeader = headers.get("x-omniroute-mode")?.trim() || null; + const budgetHeader = headers.get("x-omniroute-budget")?.trim() || null; + const budgetFallbackHeader = headers.get("x-omniroute-budget-fallback")?.trim() || null; + + const mode = resolveRequestModePack(modeHeader); + const budgetCap = parseRequestBudgetCap(budgetHeader); + const budgetFallback = parseRequestBudgetFallback(budgetFallbackHeader); + + return { + ...(mode.override && modeHeader ? { mode: modeHeader } : {}), + ...(budgetCap !== undefined ? { budgetCap } : {}), + ...(budgetFallback !== undefined ? { budgetFallback } : {}), + }; +} diff --git a/open-sse/services/combo/autoConfig.ts b/open-sse/services/combo/autoConfig.ts index c79c9ad032..2815444f25 100644 --- a/open-sse/services/combo/autoConfig.ts +++ b/open-sse/services/combo/autoConfig.ts @@ -44,6 +44,12 @@ export function parseAutoConfig(combo: ComboLike, eligibleTargets: ResolvedCombo const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap)) ? Number(autoConfigSource.budgetCap) : undefined; + // #3470: persisted fallback policy for when EVERY candidate exceeds budgetCap. + // Any other value (including absent) falls through to the engine's "cheapest" default. + const budgetFallback: "strict" | "cheapest" | undefined = + autoConfigSource.budgetFallback === "strict" || autoConfigSource.budgetFallback === "cheapest" + ? (autoConfigSource.budgetFallback as "strict" | "cheapest") + : undefined; const modePack = typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined; const resetWindowConfig = resolveResetWindowConfig(autoConfigSource); @@ -55,6 +61,7 @@ export function parseAutoConfig(combo: ComboLike, eligibleTargets: ResolvedCombo weights, explorationRate, budgetCap, + budgetFallback, modePack, resetWindowConfig, slaPolicy, diff --git a/open-sse/services/combo/resolveAutoStrategy.ts b/open-sse/services/combo/resolveAutoStrategy.ts index bd7425723a..ef6082f9af 100644 --- a/open-sse/services/combo/resolveAutoStrategy.ts +++ b/open-sse/services/combo/resolveAutoStrategy.ts @@ -1,8 +1,12 @@ -import { unavailableResponse } from "../../utils/error.ts"; -import { selectProvider as selectAutoProvider } from "../autoCombo/engine.ts"; +import { errorResponse, unavailableResponse } from "../../utils/error.ts"; +import { + BudgetExceededError, + selectProvider as selectAutoProvider, +} from "../autoCombo/engine.ts"; import { resolveRequestModePack, parseRequestBudgetCap, + parseRequestBudgetFallback, } from "../autoCombo/requestControls.ts"; import { selectWithStrategy } from "../autoCombo/routerStrategy.ts"; import { buildComplexityRoutingHint } from "../autoCombo/complexityRouter"; @@ -58,6 +62,8 @@ export interface ResolveAutoStrategyDeps { mode?: string | null; /** Per-request X-OmniRoute-Budget value in USD (#6023). */ budgetCap?: number | null; + /** Per-request X-OmniRoute-Budget-Fallback value ("cheapest" | "strict") — #3470. */ + budgetFallback?: "cheapest" | "strict" | null; } | null; resilienceSettings: ResilienceSettings; log: ComboLogger; @@ -157,25 +163,29 @@ export async function resolveAutoStrategyOrder( weights, explorationRate, budgetCap: configBudgetCap, + budgetFallback: configBudgetFallback, modePack: configModePack, resetWindowConfig, slaPolicy, } = parseAutoConfig(combo, eligibleTargets); - // Per-request overrides (#6023 / #6024 / #6025): X-OmniRoute-Budget and - // X-OmniRoute-Mode headers (threaded via relayOptions) take precedence over - // the combo's stored config for this single request. Unknown/garbage header - // values are ignored so the saved config is preserved. + // Per-request overrides (#6023 / #6024 / #6025 / #3470): X-OmniRoute-Budget, + // X-OmniRoute-Budget-Fallback and X-OmniRoute-Mode headers (threaded via + // relayOptions) take precedence over the combo's stored config for this single + // request. Unknown/garbage header values are ignored so the saved config is + // preserved. const requestBudgetCap = parseRequestBudgetCap(relayOptions?.budgetCap); const budgetCap = requestBudgetCap ?? configBudgetCap; + const requestBudgetFallback = parseRequestBudgetFallback(relayOptions?.budgetFallback); + const budgetFallback = requestBudgetFallback ?? configBudgetFallback; const requestModePack = resolveRequestModePack(relayOptions?.mode); const modePack = requestModePack.override ? requestModePack.modePack : configModePack; - if (requestModePack.override || requestBudgetCap !== undefined) { + if (requestModePack.override || requestBudgetCap !== undefined || requestBudgetFallback !== undefined) { log.debug?.( "COMBO", `Auto strategy: per-request controls applied (mode=${ requestModePack.override ? (requestModePack.modePack ?? "balanced") : "—" - }, budgetCap=${requestBudgetCap ?? "—"})` + }, budgetCap=${requestBudgetCap ?? "—"}, budgetFallback=${requestBudgetFallback ?? "—"})` ); } @@ -256,20 +266,32 @@ export async function resolveAutoStrategyOrder( } if (!selectedProvider || !selectedModel) { - const selection = selectAutoProvider( - { - id: combo.id || combo.name, - name: combo.name, - type: "auto", - candidatePool, - weights, - modePack, - budgetCap, - explorationRate, - }, - routableCandidates, - taskType - ); + let selection; + try { + selection = selectAutoProvider( + { + id: combo.id || combo.name, + name: combo.name, + type: "auto", + candidatePool, + weights, + modePack, + budgetCap, + budgetFallback, + explorationRate, + }, + routableCandidates, + taskType + ); + } catch (err) { + // #3470: `budgetFallback: "strict"` refuses to select when every candidate + // exceeds `budgetCap` — surface a clear cost-exceeds-budget response + // instead of letting it propagate as an unhandled 500. + if (err instanceof BudgetExceededError) { + return { earlyResponse: errorResponse(402, err.message) }; + } + throw err; + } selectedProvider = selection.provider; selectedModel = selection.model; selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`; diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index ec3bac65b9..3e67ce7efa 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -70,6 +70,8 @@ export type ComboRelayOptions = { mode?: string | null; /** Per-request X-OmniRoute-Budget value (hard cost ceiling in USD) — #6023. */ budgetCap?: number | null; + /** Per-request X-OmniRoute-Budget-Fallback value ("cheapest" | "strict") — #3470. */ + budgetFallback?: "cheapest" | "strict" | null; [key: string]: unknown; }; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 8b67cc793a..6aaacb04c5 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -269,6 +269,9 @@ export function normalizeUsage(usage) { assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); assignNumber("cached_tokens", usage?.cached_tokens); assignNumber("reasoning_tokens", usage?.reasoning_tokens); + // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A — + // @ryanngit). Ticks → USD conversion happens in costCalculator.ts, not here. + assignNumber("cost_in_usd_ticks", usage?.cost_in_usd_ticks); if (Object.keys(normalized).length === 0) return null; return normalized; @@ -387,6 +390,8 @@ export function extractUsage(chunk) { chunk.usage.completion_tokens_details?.reasoning_tokens ?? chunk.usage.output_tokens_details?.reasoning_tokens ?? chunk.usage.reasoning_tokens, + // xAI's exact provider-reported cost (port of decolua/9router#2453, capability A). + cost_in_usd_ticks: chunk.usage.cost_in_usd_ticks, }); } diff --git a/scripts/check/check-t11-any-budget.mjs b/scripts/check/check-t11-any-budget.mjs index d1ae1706c2..3330aa2158 100644 --- a/scripts/check/check-t11-any-budget.mjs +++ b/scripts/check/check-t11-any-budget.mjs @@ -22,6 +22,10 @@ const budget = [ { file: "src/lib/db/prompts.ts", maxAny: 0 }, { file: "src/lib/db/providers.ts", maxAny: 0 }, { file: "src/lib/db/settings.ts", maxAny: 0 }, + // #3512: saveRequestUsage typed with UsageEntry (DB-entity 1:1 interface); the + // other any's in this file (getUsageHistory filter, nextCursor cast, + // appendRequestLog tokens, getRecentLogs catch) were cleaned in the same pass. + { file: "src/lib/usage/usageHistory.ts", maxAny: 0 }, { file: "open-sse/config/providerRegistry.ts", maxAny: 0 }, { file: "open-sse/config/providerModels.ts", maxAny: 0 }, { file: "open-sse/mcp-server/audit.ts", maxAny: 0 }, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index 2e03d808bf..76b861739d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -49,6 +49,19 @@ function targetFormatLabel(value: string, t: (key: string) => string): string { return key ? t(key) : value; } +/** + * #4125: parse the free-text "Context Window Override" field. Blank → no override + * (`value: null`, not an error). A non-empty value must be a positive whole number of + * tokens; anything else is rejected. Pulled out of saveEdit so its own branching stays + * off that handler's cyclomatic complexity. + */ +function parseContextWindowOverrideInput(raw: string): { value: number | null; invalid: boolean } { + const trimmed = raw.trim(); + if (!trimmed) return { value: null, invalid: false }; + if (!/^\d+$/.test(trimmed) || Number(trimmed) <= 0) return { value: null, invalid: true }; + return { value: Number(trimmed), invalid: false }; +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -82,6 +95,9 @@ export default function CustomModelsSection({ const [newTargetFormat, setNewTargetFormat] = useState(""); const [savingModelId, setSavingModelId] = useState(null); const [togglingModelId, setTogglingModelId] = useState(null); + // #4125: manual context-window override (Feature 5004 table) — free text so the + // field can be left blank (no override) without fighting a number input's "0". + const [editingContextWindowOverride, setEditingContextWindowOverride] = useState(""); const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); @@ -183,6 +199,9 @@ export default function CustomModelsSection({ : ["chat"] ); setEditingTargetFormat(model.targetFormat || ""); + setEditingContextWindowOverride( + typeof model.contextWindowOverride === "number" ? String(model.contextWindowOverride) : "" + ); }; const cancelEdit = () => { @@ -190,6 +209,7 @@ export default function CustomModelsSection({ setEditingApiFormat("chat-completions"); setEditingEndpoints(["chat"]); setEditingTargetFormat(""); + setEditingContextWindowOverride(""); setSavingModelId(null); }; @@ -225,13 +245,10 @@ export default function CustomModelsSection({ } }; - const saveEdit = async (modelId: string) => { - if (!editingModelId || editingModelId !== modelId) return; - if (!editingEndpoints.length) { - notify.error("Select at least one supported endpoint"); - return; - } - + // Split out of saveEdit (which only validates + delegates) so the #4125 context-window + // validation stays a single early-return in the caller instead of adding a branch to + // this already-large PUT/fetch/error-handling body. + const performSaveEdit = async (modelId: string, contextWindowOverride: number | null) => { setSavingModelId(modelId); try { const model = customModels.find((m) => m.id === modelId); @@ -249,6 +266,8 @@ export default function CustomModelsSection({ // as optional. Sending an empty string would fail Zod's enum check, // so we omit it entirely when the user picks "Default (auto)". ...(editingTargetFormat ? { targetFormat: editingTargetFormat } : {}), + // #4125: manual context-window override — number to set, null to clear. + contextWindowOverride, }), }); @@ -271,6 +290,22 @@ export default function CustomModelsSection({ } }; + const saveEdit = async (modelId: string) => { + if (!editingModelId || editingModelId !== modelId) return; + if (!editingEndpoints.length) { + notify.error("Select at least one supported endpoint"); + return; + } + + const contextOverride = parseContextWindowOverrideInput(editingContextWindowOverride); + if (contextOverride.invalid) { + notify.error(t("contextWindowOverrideInvalid")); + return; + } + + await performSaveEdit(modelId, contextOverride.value); + }; + return (

@@ -439,6 +474,14 @@ export default function CustomModelsSection({ {`→ ${targetFormatLabel(model.targetFormat, t)}`} )} + {typeof model.contextWindowOverride === "number" && ( + + {`🪟 ${model.contextWindowOverride.toLocaleString()}`} + + )} {model.supportedEndpoints?.includes("embeddings") && ( {`📐 ${t("supportedEndpointEmbeddings")}`} @@ -521,6 +564,20 @@ export default function CustomModelsSection({

+
+ + setEditingContextWindowOverride(e.target.value)} + placeholder={t("contextWindowOverridePlaceholder")} + title={t("contextWindowOverrideHint")} + className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary" + /> +
{t("supportedEndpointsLabel")} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 2bd0b2d4a6..9af38ea80e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -75,6 +75,7 @@ export type CompatModelRow = { upstreamHeaders?: Record; compatByProtocol?: CompatByProtocolMap; /** #2905: per-model upstream wire-format override. */ targetFormat?: string; + /** #4125: manual context-window override (tokens), when set. */ contextWindowOverride?: number; }; export type CompatModelMap = Map; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx index b8cc040af9..dbf1c253fd 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx @@ -47,16 +47,16 @@ export default function QuotaCardGrid({ } return ( -
+
{[...groups.entries()].map(([provider, conns]) => ( -
+

{providerLabels[provider] || provider} ({conns.length} account{conns.length !== 1 ? "s" : ""})

-
+
{conns.map((conn) => ( ) => { + const modelId = typeof model?.id === "string" ? model.id : null; + const record = modelId ? getModelContextOverrideRecord(provider, modelId) : null; + return record + ? { + ...model, + contextWindowOverride: record.realContext, + contextWindowOverrideSource: record.source, + } + : model; + }) + : models; - return Response.json({ models, modelCompatOverrides }); + return Response.json({ models: modelsWithContextOverride, modelCompatOverrides }); } catch { return Response.json( { error: { message: "Failed to fetch provider models", type: "server_error" } }, @@ -172,6 +193,7 @@ export async function PUT(request) { preserveOpenAIDeveloperRole, upstreamHeaders, compatByProtocol, + contextWindowOverride, } = validation.data; const raw = rawBody as Record; @@ -188,6 +210,20 @@ export async function PUT(request) { updates.compatByProtocol = compatByProtocol; } + // #4125: manual context-window override — persisted in the Feature-5004 + // `model_context_overrides` table (source="manual"), independent of the + // customModels JSON row, so it applies whether or not other fields changed. + let contextWindowOverrideResult: number | null | undefined; + if ("contextWindowOverride" in raw) { + if (contextWindowOverride == null) { + removeModelContextOverride(provider, modelId); + contextWindowOverrideResult = null; + } else { + setModelContextOverride(provider, modelId, contextWindowOverride, "manual"); + contextWindowOverrideResult = contextWindowOverride; + } + } + const model = await updateCustomModel(provider, modelId, updates); if (!model) { @@ -202,12 +238,14 @@ export async function PUT(request) { "preserveOpenAIDeveloperRole", "upstreamHeaders", "compatByProtocol", + "contextWindowOverride", ].includes(k) ) && ("normalizeToolCallId" in raw || "preserveOpenAIDeveloperRole" in raw || "upstreamHeaders" in raw || - "compatByProtocol" in raw); + "compatByProtocol" in raw || + "contextWindowOverride" in raw); if (compatOnly) { const knownProvider = !!provider && @@ -248,6 +286,9 @@ export async function PUT(request) { return Response.json({ ok: true, modelCompatOverrides: getModelCompatOverrides(provider), + ...(contextWindowOverrideResult !== undefined + ? { contextWindowOverride: contextWindowOverrideResult } + : {}), }); } return Response.json( @@ -256,7 +297,12 @@ export async function PUT(request) { ); } - return Response.json({ model }); + return Response.json({ + model, + ...(contextWindowOverrideResult !== undefined + ? { contextWindowOverride: contextWindowOverrideResult } + : {}), + }); } catch (error) { console.error("Error updating provider model:", error); return Response.json( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index dceeb74e18..de049135af 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -4423,7 +4423,11 @@ "filterActive": "نشط", "filterError": "خطأ", "filterBanned": "محظور", - "filterCreditsExhausted": "نفدت الأرصدة" + "filterCreditsExhausted": "نفدت الأرصدة", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "الإعدادات", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index fd076d8198..e0694b225d 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktiv", "filterError": "Xəta", "filterBanned": "Qadağan edilib", - "filterCreditsExhausted": "Kreditlər bitib" + "filterCreditsExhausted": "Kreditlər bitib", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 5e37dde230..b6e9952e85 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -4423,7 +4423,11 @@ "filterActive": "Активни", "filterError": "Грешка", "filterBanned": "Блокиран", - "filterCreditsExhausted": "Изчерпани кредити" + "filterCreditsExhausted": "Изчерпани кредити", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Настройки", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index 2dfa459023..27a35531b4 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -4423,7 +4423,11 @@ "filterActive": "সক্রিয়", "filterError": "ত্রুটি", "filterBanned": "নিষিদ্ধ", - "filterCreditsExhausted": "ক্রেডিট শেষ" + "filterCreditsExhausted": "ক্রেডিট শেষ", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 939546c185..0f2db58994 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktivní", "filterError": "Chyba", "filterBanned": "Zablokováno", - "filterCreditsExhausted": "Vyčerpané kredity" + "filterCreditsExhausted": "Vyčerpané kredity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Nastavení", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 6bee3d61ad..3db278c319 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktiv", "filterError": "Fejl", "filterBanned": "Blokeret", - "filterCreditsExhausted": "Kreditter opbrugt" + "filterCreditsExhausted": "Kreditter opbrugt", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Indstillinger", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index a891f51f50..f9b37487c5 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4428,7 +4428,11 @@ "filterActive": "Aktiv", "filterError": "Fehler", "filterBanned": "Gesperrt", - "filterCreditsExhausted": "Guthaben aufgebraucht" + "filterCreditsExhausted": "Guthaben aufgebraucht", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Einstellungen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 10d75bee8d..9068e88dc1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4322,6 +4322,10 @@ "targetFormatAuto": "Default (auto)", "targetFormatGemini": "Gemini", "targetFormatAntigravity": "Antigravity", + "contextWindowOverrideLabel": "Context Window Override", + "contextWindowOverridePlaceholder": "e.g. 131072", + "contextWindowOverrideHint": "Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "Context window override must be a positive whole number of tokens", "compatParamFiltersLabel": "Param Filters", "compatBlockedParamsHint": "Blocked params (stripped from requests)", "compatAllowedParamsHint": "Allowed params (re-added after deny)", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index d167c6f699..84c20d31b9 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4423,7 +4423,11 @@ "filterActive": "Activo", "filterError": "Error", "filterBanned": "Bloqueado", - "filterCreditsExhausted": "Créditos agotados" + "filterCreditsExhausted": "Créditos agotados", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Configuración", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 5ab6808535..2e85c028be 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -4423,7 +4423,11 @@ "filterActive": "فعال", "filterError": "خطا", "filterBanned": "مسدود", - "filterCreditsExhausted": "اعتبار تمام‌شده" + "filterCreditsExhausted": "اعتبار تمام‌شده", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index cc3bfc90c1..dff90e6c94 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktiivinen", "filterError": "Virhe", "filterBanned": "Estetty", - "filterCreditsExhausted": "Krediitit lopussa" + "filterCreditsExhausted": "Krediitit lopussa", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Asetukset", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index c73f1f7324..39188d982a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4423,7 +4423,11 @@ "filterActive": "Actif", "filterError": "Erreur", "filterBanned": "Banni", - "filterCreditsExhausted": "Crédits épuisés" + "filterCreditsExhausted": "Crédits épuisés", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Paramètres", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index a5c544ea78..4b79943447 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -4423,7 +4423,11 @@ "filterActive": "સક્રિય", "filterError": "ભૂલ", "filterBanned": "પ્રતિબંધિત", - "filterCreditsExhausted": "ક્રેડિટ સમાપ્ત" + "filterCreditsExhausted": "ક્રેડિટ સમાપ્ત", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index 7e645fade7..5740d5cd97 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -4423,7 +4423,11 @@ "filterActive": "פעיל", "filterError": "שגיאה", "filterBanned": "חסום", - "filterCreditsExhausted": "קרדיטים אזלו" + "filterCreditsExhausted": "קרדיטים אזלו", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "הגדרות", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index eea9fc002b..11bee167d9 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -4423,7 +4423,11 @@ "filterActive": "सक्रिय", "filterError": "त्रुटि", "filterBanned": "प्रतिबंधित", - "filterCreditsExhausted": "क्रेडिट समाप्त" + "filterCreditsExhausted": "क्रेडिट समाप्त", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "सेटिंग्स", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index bb31037deb..1dd27e618e 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktív", "filterError": "Hiba", "filterBanned": "Letiltva", - "filterCreditsExhausted": "Elfogyott kreditek" + "filterCreditsExhausted": "Elfogyott kreditek", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Beállítások elemre", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 5f8f86b9d9..4f2c76c499 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktif", "filterError": "Kesalahan", "filterBanned": "Diblokir", - "filterCreditsExhausted": "Kredit habis" + "filterCreditsExhausted": "Kredit habis", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Pengaturan", diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index cac01f0870..ace72ac9ca 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -4423,7 +4423,11 @@ "filterActive": "Aktif", "filterError": "Kesalahan", "filterBanned": "Diblokir", - "filterCreditsExhausted": "Kredit habis" + "filterCreditsExhausted": "Kredit habis", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index b277686fac..1e1f70cf43 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -4310,6 +4310,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index ebf23e8c47..c052fed1d7 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f7daabcef8..e300c36890 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index bde1c1f9db..1ad40bb3ea 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 96ac1d12ea..f01ceac27e 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index fc8bb6c44d..560df3cb73 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index e91d1b5d20..47b6584623 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index f621e04ee8..7e6afb8e3d 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index ae0829d5c3..6ea76ab160 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 91f0197be3..7a067a450b 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 068a7309db..0b01ff143f 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 4e7d0c2dbe..635ce8f982 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 7557b3449c..a3d025301a 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Дополнительные заголовки upstream", "compatUpstreamHeadersHint": "Настройка с высоким уровнем доверия - тот же уровень, что и редактирование API-учётных данных провайдера; использовать только доверенным администраторам. Применяется после того, как OmniRoute добавит авторизацию из API-ключа провайдера. Если пользовательский заголовок совпадает по имени с уже существующим (например, Authorization), ваше значение полностью заменяет автоматически созданный заголовок (включая Bearer token) - upstream увидит только введённое вами значение, а не ключ из настроек. Ошибка настройки может привести к 401 или поломке upstream-аутентификации. По одной строке на заголовок (например, дополнительный Authentication для некоторых шлюзов). Наведите курсор или сфокусируйте поле, чтобы увидеть предпросмотр. Сохраняется при потере фокуса, клике вне поля или закрытии панели.", "compatUpstreamHeaderName": "Имя заголовка", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 771fca2545..e65ced31e9 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 223f535b2a..2a22581414 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -4282,6 +4282,10 @@ "targetFormatAuto": "__MISSING__:Default (auto)", "targetFormatGemini": "__MISSING__:Gemini", "targetFormatAntigravity": "__MISSING__:Antigravity", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens", "compatUpstreamHeadersLabel": "Extra upstream headers", "compatUpstreamHeadersHint": "High-privilege setting — same trust level as editing provider API credentials; only trusted admins should use it. Merged after OmniRoute adds auth from the provider API key. If a custom header uses the same name as an existing one (e.g. Authorization), your value fully replaces the auto-generated header (including the Bearer token) — the upstream only sees what you typed, not the key from settings. Misconfiguration can cause 401 or broken upstream auth. One row per header (e.g. extra Authentication for some gateways). Hover or focus the value to preview. Saves on blur, outside click, or closing this panel.", "compatUpstreamHeaderName": "Header name", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index f5bf31ee4f..8d6c40741e 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -4423,7 +4423,11 @@ "filterActive": "Inayotumika", "filterError": "Hitilafu", "filterBanned": "Imepigwa marufuku", - "filterCreditsExhausted": "Salio limeisha" + "filterCreditsExhausted": "Salio limeisha", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index 196ceef133..8fa80330a8 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -4423,7 +4423,11 @@ "filterActive": "செயலில்", "filterError": "பிழை", "filterBanned": "தடைசெய்யப்பட்டது", - "filterCreditsExhausted": "கிரெடிட் தீர்ந்தது" + "filterCreditsExhausted": "கிரெடிட் தீர்ந்தது", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index d4881a7878..2147b8325c 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -4423,7 +4423,11 @@ "filterActive": "సక్రియం", "filterError": "లోపం", "filterBanned": "నిషేధించబడింది", - "filterCreditsExhausted": "క్రెడిట్‌లు అయిపోయాయి" + "filterCreditsExhausted": "క్రెడిట్‌లు అయిపోయాయి", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index ff66525a28..c7e7cec0e8 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -4423,7 +4423,11 @@ "filterActive": "ใช้งาน", "filterError": "ข้อผิดพลาด", "filterBanned": "ถูกแบน", - "filterCreditsExhausted": "เครดิตหมด" + "filterCreditsExhausted": "เครดิตหมด", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "การตั้งค่า", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index ceee5afaee..13dc5b2267 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -4423,7 +4423,11 @@ "filterActive": "Etkin", "filterError": "Hata", "filterBanned": "Yasaklandı", - "filterCreditsExhausted": "Krediler tükendi" + "filterCreditsExhausted": "Krediler tükendi", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Ayarlar", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index ec688d1f99..3e3310ff63 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -4423,7 +4423,11 @@ "filterActive": "Активні", "filterError": "Помилка", "filterBanned": "Заблоковано", - "filterCreditsExhausted": "Кредити вичерпано" + "filterCreditsExhausted": "Кредити вичерпано", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Налаштування", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index 7d404dc24f..23bb2cd677 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -4423,7 +4423,11 @@ "filterActive": "فعال", "filterError": "خرابی", "filterBanned": "پابندی شدہ", - "filterCreditsExhausted": "کریڈٹ ختم" + "filterCreditsExhausted": "کریڈٹ ختم", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Settings", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 2270e1f044..f67c186783 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -4423,7 +4423,11 @@ "filterActive": "Hoạt động", "filterError": "Lỗi", "filterBanned": "Bị cấm", - "filterCreditsExhausted": "Hết tín dụng" + "filterCreditsExhausted": "Hết tín dụng", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "Cài đặt", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8f31fa9dd0..5c5a3bbd13 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -4725,7 +4725,11 @@ "rateLimitOverridesTpdHint": "此连接的每日最大令牌数。覆盖提供商默认值。", "rateLimitOverridesTpdLabel": "TPD(令牌/天)", "rateLimitOverridesTpmHint": "此连接的每分钟最大令牌数。覆盖提供商默认值。", - "rateLimitOverridesTpmLabel": "TPM(令牌/分钟)" + "rateLimitOverridesTpmLabel": "TPM(令牌/分钟)", + "contextWindowOverrideLabel": "__MISSING__:Context Window Override", + "contextWindowOverridePlaceholder": "__MISSING__:e.g. 131072", + "contextWindowOverrideHint": "__MISSING__:Manually set this model's real context window (tokens) when the provider misreports it. Wins over auto-detected/catalog values and prevents combo routing from dropping the model.", + "contextWindowOverrideInvalid": "__MISSING__:Context window override must be a positive whole number of tokens" }, "settings": { "title": "设置", diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index 59d16c5244..94fc8668fe 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -37,6 +37,34 @@ export type CostCalculationOptions = { flatRateAsZero?: boolean; }; +/** + * xAI reports the exact provider-billed cost of a request in the chat-completions + * `usage` object via `cost_in_usd_ticks` (port of decolua/9router#2453, capability + * A — @ryanngit). Per the official docs — both + * https://docs.x.ai/developers/cost-tracking and the API reference's usage schema + * ("TICKS_IN_USD_CENT: i64 = 100_000_000") — there are 10_000_000_000 (1e10) ticks + * per USD. Example from the docs: 37756000 ticks ≈ $0.0038. + * + * NOTE: this divisor is intentionally 1e10, not the 1e12 used by the upstream PR + * (which under-reports cost 100x) — verified directly against the xAI docs. + */ +const USD_TICKS_PER_DOLLAR = 10_000_000_000; + +/** + * Extract an exact, provider-reported USD cost from a token/usage record when one + * is present, so callers can trust it over the token × pricing estimate. Currently + * only xAI's `cost_in_usd_ticks` field is handled — see comment above. + */ +function extractExactCostUsd( + tokens: Record | null | undefined +): number | null { + const ticks = tokens?.cost_in_usd_ticks; + if (typeof ticks === "number" && Number.isFinite(ticks) && ticks >= 0) { + return ticks / USD_TICKS_PER_DOLLAR; + } + return null; +} + function toNumber(value: unknown, fallback = 0): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim().length > 0) { @@ -95,7 +123,12 @@ export function computeCostFromPricing( tokens: Record | null | undefined, options: CostCalculationOptions = {} ): number { - if (!pricing || !tokens) return 0; + if (!tokens) return 0; + // Trust an exact, provider-reported cost over the token × pricing estimate + // when one is present — works even when no local pricing row exists yet. + const exactCostUsd = extractExactCostUsd(tokens); + if (exactCostUsd !== null) return exactCostUsd; + if (!pricing) return 0; // Flat-rate (subscription / cookie-web) providers don't bill per token — their // per-token pricing rows exist only for estimation, so display surfaces opt in // to show $0 instead of an inflated estimate (#5552). @@ -138,6 +171,11 @@ export async function calculateCost( ): Promise { if (!tokens || !provider || !model) return 0; + // Short-circuit before any pricing DB lookup when an exact, provider-reported + // cost is present (currently xAI's `cost_in_usd_ticks` — see extractExactCostUsd). + const exactCostUsd = extractExactCostUsd(tokens); + if (exactCostUsd !== null) return exactCostUsd; + try { const { getPricingForModel } = await import("@/lib/localDb"); diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 74fec28f16..a13ffb13f6 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -553,17 +553,58 @@ export async function getUsageDb(sinceIso?: string | null, limit?: number, curso }); // Provide next cursor if we hit the limit (more rows exist) - const nextCursor = rows.length === maxRows ? (rows[rows.length - 1] as any)?.timestamp : null; + const nextCursor = + rows.length === maxRows ? toStringOrNull(asRecord(rows[rows.length - 1]).timestamp) : null; return { data: { history, nextCursor } }; } // ──────────────── Save Request Usage ──────────────── +/** + * DB-entity-mapped shape accepted by {@link saveRequestUsage}, mirroring the + * `usage_history` table columns 1:1 (see `src/lib/db/migrations/`). Convention + * (#3512): every `usage_history` writer should type its entry against this + * interface instead of an inline anonymous object or `any` — call sites are + * intentionally permissive (fields optional/nullable) because rows are built + * incrementally across several extraction points (chatCore success/failure + * paths, rejected-request accounting, the Codex Responses WS bridge). + * + * `tokens` stays `unknown` on purpose: callers pass either the raw + * provider-shaped usage object (OpenAI `prompt_tokens`/`completion_tokens`, + * Anthropic `input_tokens`/`cache_read_input_tokens`, …) or the already + * normalized `{ input, output, cacheRead, cacheCreation, reasoning }` shape — + * `getLoggedInputTokens`/`getLoggedOutputTokens`/`getPromptCache*Tokens` in + * `./tokenAccounting` accept both and extract the right fields. + */ +export interface UsageEntry { + provider?: string | null; + model?: string | null; + /** Raw or normalized token usage — see the interface doc above. */ + tokens?: unknown; + status?: string | null; + success?: boolean; + latencyMs?: number; + timeToFirstTokenMs?: number; + errorCode?: string | null; + /** ISO timestamp; defaults to `new Date().toISOString()` when omitted. */ + timestamp?: string; + connectionId?: string | null; + apiKeyId?: string | null; + apiKeyName?: string | null; + serviceTier?: string | null; + /** @deprecated legacy snake_case fallback, read only if `serviceTier` is unset. */ + service_tier?: string | null; + comboStrategy?: string | null; + /** @deprecated legacy snake_case fallback, read only if `comboStrategy` is unset. */ + combo_strategy?: string | null; + endpoint?: string | null; +} + /** * Save request usage entry to SQLite. */ -export async function saveRequestUsage(entry: any) { +export async function saveRequestUsage(entry: UsageEntry) { if (!shouldPersistToDisk) return; try { @@ -666,10 +707,17 @@ export async function saveRequestUsage(entry: any) { // ──────────────── Get Usage History ──────────────── +export interface UsageHistoryFilter { + provider?: string; + model?: string; + startDate?: string | number | Date; + endDate?: string | number | Date; +} + /** * Get usage history with optional filters. */ -export async function getUsageHistory(filter: any = {}) { +export async function getUsageHistory(filter: UsageHistoryFilter = {}) { const db = getDbInstance(); let sql = "SELECT * FROM usage_history"; const conditions: string[] = []; @@ -875,7 +923,7 @@ export async function appendRequestLog({ model?: string; provider?: string; connectionId?: string; - tokens?: any; + tokens?: unknown; status?: string | number; }) { // Deprecated: request summaries now come from SQLite call_logs. @@ -909,8 +957,11 @@ export async function getRecentLogs(limit = 200) { const status = typeof row.status === "number" ? row.status : String(row.status || "-"); return `${timestamp} | ${model} | ${provider} | ${account} | ${tokensIn} | ${tokensOut} | ${status}`; }); - } catch (error: any) { - console.error("[usageDb] Failed to read recent call logs:", error.message); + } catch (error) { + console.error( + "[usageDb] Failed to read recent call logs:", + error instanceof Error ? error.message : String(error) + ); return []; } } diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index dcf9457217..c45bd4fb2d 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -208,6 +208,12 @@ export const providerModelMutationSchema = z.object({ // catalog); they persist as inputTokenLimit / outputTokenLimit. max_input_tokens: z.number().int().positive().optional(), max_output_tokens: z.number().int().positive().optional(), + // #4125: manual context-window override for a specific provider/model. Persisted + // via the Feature-5004 `model_context_overrides` table (source="manual") so it wins + // over the auto-discovery/static-catalog context window in `getModelContextLimit()` + // — fixes the "provider misreports context length" combo-drop case. `null` clears + // a previously set override. + contextWindowOverride: z.number().int().positive().nullable().optional(), normalizeToolCallId: z.boolean().optional(), preserveOpenAIDeveloperRole: z.boolean().nullable().optional(), upstreamHeaders: upstreamHeadersRecordSchema.nullable().optional(), diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 1646f0a81d..7de503e7e7 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -25,10 +25,7 @@ import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat. import { isSelfInflictedUpstreamTimeout } from "@omniroute/open-sse/handlers/chatCore/cooldownClassification.ts"; import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts"; import { handleComboChat } from "@omniroute/open-sse/services/combo.ts"; -import { - resolveRequestModePack, - parseRequestBudgetCap, -} from "@omniroute/open-sse/services/autoCombo/requestControls.ts"; +import { resolveRequestAutoControls } from "@omniroute/open-sse/services/autoCombo/requestControls.ts"; import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts"; import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts"; import { @@ -766,17 +763,13 @@ export async function handleChat( ]); const relayConfig = combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null; - // Per-request Auto-Combo controls (#6023 / #6024 / #6025): steer an `auto` - // combo on this single request without mutating its stored config. - const requestModeHeader = request.headers.get("x-omniroute-mode")?.trim() || null; - const requestBudgetHeader = request.headers.get("x-omniroute-budget")?.trim() || null; - const perRequestMode = resolveRequestModePack(requestModeHeader); - const perRequestBudgetCap = parseRequestBudgetCap(requestBudgetHeader); + // Per-request Auto-Combo controls (#6023 / #6024 / #6025 / #3470): steer an + // `auto` combo on this single request without mutating its stored config. + const perRequestAutoControls = resolveRequestAutoControls(request.headers); const relayOptions = combo.strategy === "context-relay" || bypassProviderQuotaPolicy || - perRequestMode.override || - perRequestBudgetCap !== undefined + Object.keys(perRequestAutoControls).length > 0 ? { ...(combo.strategy === "context-relay" ? { @@ -785,8 +778,7 @@ export async function handleChat( } : {}), ...(bypassProviderQuotaPolicy ? { bypassProviderQuotaPolicy: true } : {}), - ...(perRequestMode.override ? { mode: requestModeHeader } : {}), - ...(perRequestBudgetCap !== undefined ? { budgetCap: perRequestBudgetCap } : {}), + ...perRequestAutoControls, } : undefined; telemetry.endPhase(); diff --git a/tests/unit/auto-combo-budget-fallback-3470.test.ts b/tests/unit/auto-combo-budget-fallback-3470.test.ts new file mode 100644 index 0000000000..89271e8988 --- /dev/null +++ b/tests/unit/auto-combo-budget-fallback-3470.test.ts @@ -0,0 +1,161 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + BudgetExceededError, + selectProvider, +} from "../../open-sse/services/autoCombo/engine.ts"; +import { + parseRequestBudgetFallback, + resolveRequestAutoControls, +} from "../../open-sse/services/autoCombo/requestControls.ts"; +import { getSelfHealingManager } from "../../open-sse/services/autoCombo/selfHealing.ts"; +import { DEFAULT_WEIGHTS } from "../../open-sse/services/autoCombo/scoring.ts"; + +// #3470 — Auto-combo transparency + budget controls: `budgetFallback: "strict"` +// must refuse to select (instead of silently overspending) when EVERY candidate +// exceeds the configured `budgetCap`. + +const healer = getSelfHealingManager(); +const originalRandom = Math.random; + +function resetHealer() { + healer.exclusions.clear(); + healer.incidentMode = false; +} + +const baseConfig = { + id: "auto-main", + name: "Auto Main Budget Strict", + type: "auto" as const, + candidatePool: [], + weights: DEFAULT_WEIGHTS, + explorationRate: 0, +}; + +const overBudgetCandidates = [ + { + provider: "premium", + model: "gpt-4o", + quotaRemaining: 99, + quotaTotal: 100, + circuitBreakerState: "CLOSED", + costPer1MTokens: 12000, + p95LatencyMs: 100, + latencyStdDev: 10, + errorRate: 0.01, + accountTier: "ultra", + quotaResetIntervalSecs: 60, + }, + { + provider: "cheap", + model: "gpt-4o-mini", + quotaRemaining: 60, + quotaTotal: 100, + circuitBreakerState: "CLOSED", + costPer1MTokens: 100, + p95LatencyMs: 900, + latencyStdDev: 50, + errorRate: 0.02, + accountTier: "free", + quotaResetIntervalSecs: 86400, + }, +]; + +test.beforeEach(() => { + resetHealer(); + Math.random = originalRandom; +}); + +test.afterEach(() => { + resetHealer(); + Math.random = originalRandom; +}); + +test("selectProvider throws BudgetExceededError when budgetFallback='strict' and every candidate exceeds budgetCap", () => { + assert.throws( + () => + selectProvider( + { ...baseConfig, budgetCap: 0.001, budgetFallback: "strict" }, + overBudgetCandidates, + "default" + ), + BudgetExceededError + ); +}); + +test("BudgetExceededError message reports the cap and the cheapest candidate's cost, no stack leak", () => { + try { + selectProvider( + { ...baseConfig, budgetCap: 0.001, budgetFallback: "strict" }, + overBudgetCandidates, + "default" + ); + assert.fail("expected selectProvider to throw"); + } catch (err) { + assert.ok(err instanceof BudgetExceededError); + assert.match(err.message, /budget cap of \$0\.0010/); + assert.ok(!err.message.includes("at /")); + } +}); + +test("selectProvider still falls back to cheapest when budgetFallback is 'cheapest' (default/legacy)", () => { + const result = selectProvider( + { ...baseConfig, budgetCap: 0.001, budgetFallback: "cheapest" }, + overBudgetCandidates, + "default" + ); + assert.equal(result.provider, "cheap"); +}); + +test("selectProvider defaults to cheapest fallback when budgetFallback is unset (backward compatible)", () => { + const result = selectProvider({ ...baseConfig, budgetCap: 0.001 }, overBudgetCandidates, "default"); + assert.equal(result.provider, "cheap"); +}); + +test("selectProvider with strict fallback still picks a within-budget candidate normally", () => { + const result = selectProvider( + { ...baseConfig, budgetCap: 1, budgetFallback: "strict" }, + overBudgetCandidates, + "default" + ); + assert.equal(result.provider, "cheap"); +}); + +test("parseRequestBudgetFallback: accepts 'strict' and its aliases", () => { + assert.equal(parseRequestBudgetFallback("strict"), "strict"); + assert.equal(parseRequestBudgetFallback("BLOCK"), "strict"); + assert.equal(parseRequestBudgetFallback(" hard "), "strict"); +}); + +test("parseRequestBudgetFallback: accepts 'cheapest' and its aliases", () => { + assert.equal(parseRequestBudgetFallback("cheapest"), "cheapest"); + assert.equal(parseRequestBudgetFallback("Cheapest-Viable"), "cheapest"); + assert.equal(parseRequestBudgetFallback("soft"), "cheapest"); +}); + +test("parseRequestBudgetFallback: ignores unknown/empty/non-string values", () => { + assert.equal(parseRequestBudgetFallback("garbage"), undefined); + assert.equal(parseRequestBudgetFallback(""), undefined); + assert.equal(parseRequestBudgetFallback(null), undefined); + assert.equal(parseRequestBudgetFallback(42), undefined); +}); + +test("resolveRequestAutoControls: aggregates mode/budget/budgetFallback headers, omitting unset ones", () => { + const headers = new Headers({ + "x-omniroute-mode": "fast", + "x-omniroute-budget": "0.05", + "x-omniroute-budget-fallback": "strict", + }); + const controls = resolveRequestAutoControls(headers); + assert.deepEqual(controls, { + mode: "fast", + budgetCap: 0.05, + budgetFallback: "strict", + }); +}); + +test("resolveRequestAutoControls: returns an empty object when no auto-combo headers are present", () => { + const controls = resolveRequestAutoControls(new Headers()); + assert.deepEqual(controls, {}); +}); diff --git a/tests/unit/provider-models-context-window-override-4125.test.ts b/tests/unit/provider-models-context-window-override-4125.test.ts new file mode 100644 index 0000000000..f7488cd04e --- /dev/null +++ b/tests/unit/provider-models-context-window-override-4125.test.ts @@ -0,0 +1,138 @@ +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"; + +// #4125: manual per-model context-window override. +// +// Custom-model rows already carried an inputTokenLimit/outputTokenLimit set at +// creation time, but there was no way to *edit* the real context window afterwards +// when a provider's own /models endpoint misreports it (e.g. reports 1M when the +// real limit is 128K), and combo routing would drop the model once a too-small +// value made it into the catalog / models.dev sync. +// +// This reuses the Feature-5004 `model_context_overrides` table (source="manual"), +// which already wins over the catalog in `getModelContextLimit()` — the same +// resolver combo routing consults — so no new priority-0 source is needed in +// modelCapabilities.ts. This test proves the API round trip end-to-end: PUT sets +// the override, GET surfaces it back on the model row, and getModelContextLimit() +// (the function combo.ts calls) picks it up. + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-provider-model-context-override-4125-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const contextOverrides = await import("../../src/lib/db/modelContextOverrides.ts"); +const modelCapabilities = await import("../../src/lib/modelCapabilities.ts"); +const providerModelsRoute = await import("../../src/app/api/provider-models/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function buildRequest(method: string, body: unknown) { + return new Request("http://localhost/api/provider-models", { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test("PUT with contextWindowOverride persists a manual override that wins over the catalog", async () => { + await modelsDb.addCustomModel( + "openai-compatible-demo", + "misreported-model", + "Misreported Model", + "manual", + "chat-completions", + ["chat"], + undefined, + { inputTokenLimit: 1_000_000 } // provider misreports 1M + ); + + const putRes = await providerModelsRoute.PUT( + buildRequest("PUT", { + provider: "openai-compatible-demo", + modelId: "misreported-model", + contextWindowOverride: 131072, // real window per the operator + }) + ); + const putBody = (await putRes.json()) as { contextWindowOverride?: number | null }; + + assert.equal(putRes.status, 200); + assert.equal(putBody.contextWindowOverride, 131072); + + // Persisted as a "manual" source in the Feature-5004 table. + const record = contextOverrides.getModelContextOverrideRecord( + "openai-compatible-demo", + "misreported-model" + ); + assert.ok(record, "override record should exist"); + assert.equal(record!.realContext, 131072); + assert.equal(record!.source, "manual"); + + // getModelContextLimit is what combo.ts's context-window filter reads — the + // manual override must win over the (wrong) 1M value on the custom-model row. + const limit = modelCapabilities.getModelContextLimit( + "openai-compatible-demo", + "misreported-model" + ); + assert.equal(limit, 131072); +}); + +test("GET surfaces contextWindowOverride on the custom model row", async () => { + await modelsDb.addCustomModel("openai-compatible-demo", "m1", "M1"); + contextOverrides.setModelContextOverride("openai-compatible-demo", "m1", 200000, "manual"); + + const getRes = await providerModelsRoute.GET( + new Request("http://localhost/api/provider-models?provider=openai-compatible-demo") + ); + const body = (await getRes.json()) as { + models: Array<{ id?: string; contextWindowOverride?: number; contextWindowOverrideSource?: string }>; + }; + + const row = body.models.find((m) => m.id === "m1"); + assert.ok(row, "model row should be present"); + assert.equal(row!.contextWindowOverride, 200000); + assert.equal(row!.contextWindowOverrideSource, "manual"); +}); + +test("PUT with contextWindowOverride: null clears a previously set override", async () => { + await modelsDb.addCustomModel("openai-compatible-demo", "m2", "M2"); + contextOverrides.setModelContextOverride("openai-compatible-demo", "m2", 50000, "manual"); + + const putRes = await providerModelsRoute.PUT( + buildRequest("PUT", { + provider: "openai-compatible-demo", + modelId: "m2", + contextWindowOverride: null, + }) + ); + assert.equal(putRes.status, 200); + + const record = contextOverrides.getModelContextOverrideRecord("openai-compatible-demo", "m2"); + assert.equal(record, null); +}); + +test("default behavior unchanged: no override means getModelContextLimit falls back to the catalog", async () => { + await modelsDb.addCustomModel("openai-compatible-demo", "m3", "M3"); + const limit = modelCapabilities.getModelContextLimit("openai-compatible-demo", "m3"); + // No override, no catalog entry for this unknown custom model → null (not dropped + // by the combo prefilter, which treats unknown context as "include to be safe"). + assert.equal(limit, null); +}); diff --git a/tests/unit/quota-card-grid-horizontal-layout.test.ts b/tests/unit/quota-card-grid-horizontal-layout.test.ts new file mode 100644 index 0000000000..9966034ba9 --- /dev/null +++ b/tests/unit/quota-card-grid-horizontal-layout.test.ts @@ -0,0 +1,120 @@ +// #3520 — Provider Quota page should use horizontal whitespace better. +// +// QuotaCardGrid previously stacked provider groups vertically via a single +// `flex flex-col` container and kept cards to a conservative 1/2/3/4-column +// breakpoint ladder starting at `grid-cols-1`. This regression guard asserts +// the shipped JSX structure and grouping logic directly: +// 1. Grouping still produces one header per distinct provider with the +// correct account count ("N account(s)"). +// 2. The per-group card grid starts multi-column (`grid-cols-2`), not +// single-column, so cards fill horizontal space sooner. +// 3. Provider groups themselves flow into multiple columns on wide screens +// (`columns-*`) instead of an unconditional vertical `flex flex-col` +// stack. +// +// Note: QuotaCardGrid's sibling QuotaCard pulls in next/image + provider-icon +// resolution that only works inside the real Next.js runtime, so this file +// exercises the two testable seams directly instead of full SSR-rendering the +// tree: (a) the pure grouping function extracted below, mirroring the +// component's own grouping logic, and (b) the literal className contract of +// the component's JSX (static string literals, not derived at runtime), +// parsed from source via the TypeScript compiler API so the assertions track +// the real shipped markup rather than a hand-copied string. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +const COMPONENT_PATH = path.resolve( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx" +); + +function groupByProvider(connections: T[]): Map { + const groups = new Map(); + for (const conn of connections) { + const list = groups.get(conn.provider) ?? []; + list.push(conn); + groups.set(conn.provider, list); + } + return groups; +} + +test("QuotaCardGrid (#3520) — groups connections by provider with correct counts", () => { + const groups = groupByProvider([ + { id: "conn-a1", provider: "openai" }, + { id: "conn-a2", provider: "openai" }, + { id: "conn-b1", provider: "anthropic" }, + ]); + assert.deepEqual([...groups.keys()], ["openai", "anthropic"]); + assert.equal(groups.get("openai")!.length, 2); + assert.equal(groups.get("anthropic")!.length, 1); +}); + +/** + * Extract the string literal passed to `className={...}` (or `className="..."`) + * for every JSX `
` opening element in the component's `return (...)` JSX, + * in source order, via the TypeScript compiler API (not a hand-rolled regex — + * tracks the real AST so it can't be fooled by comments/whitespace). + */ +function extractDivClassNames(sourcePath: string): string[] { + const sourceText = fs.readFileSync(sourcePath, "utf8"); + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX + ); + const classNames: string[] = []; + + function visit(node: ts.Node) { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const tagName = node.tagName.getText(sourceFile); + if (tagName === "div") { + for (const attr of node.attributes.properties) { + if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") { + const init = attr.initializer; + if (init && ts.isStringLiteral(init)) { + classNames.push(init.text); + } else if ( + init && + ts.isJsxExpression(init) && + init.expression && + ts.isStringLiteral(init.expression) + ) { + classNames.push(init.expression.text); + } + } + } + } + } + ts.forEachChild(node, visit); + } + visit(sourceFile); + return classNames; +} + +test("QuotaCardGrid (#3520) — outer container flows groups into multiple columns, not a single vertical stack", () => { + const [outerClassName] = extractDivClassNames(COMPONENT_PATH); + assert.ok(outerClassName, "expected the component to render an outer
"); + assert.match(outerClassName, /\bcolumns-/); + assert.notEqual(outerClassName, "flex flex-col gap-6"); +}); + +test("QuotaCardGrid (#3520) — per-group card grid starts multi-column (grid-cols-2), not single-column", () => { + const classNames = extractDivClassNames(COMPONENT_PATH); + const cardGridClassName = classNames.find( + (c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c) + ); + assert.ok(cardGridClassName, "expected to find the per-group card grid's className"); + assert.match(cardGridClassName!, /\bgrid-cols-2\b/); + assert.doesNotMatch(cardGridClassName!, /\bgrid-cols-1\b/); +}); + +test("QuotaCardGrid (#3520) — early-returns null when there are no connections", () => { + const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); + assert.match(sourceText, /if\s*\(\s*connections\.length\s*===\s*0\s*\)\s*return\s*null;/); +}); diff --git a/tests/unit/xai-exact-cost-2453.test.ts b/tests/unit/xai-exact-cost-2453.test.ts new file mode 100644 index 0000000000..8bca0fa08f --- /dev/null +++ b/tests/unit/xai-exact-cost-2453.test.ts @@ -0,0 +1,138 @@ +/** + * xAI exact provider-reported cost passthrough (port of decolua/9router#2453, + * capability A — @ryanngit). + * + * xAI's chat-completions `usage` object reports the exact billed cost of a + * request via `cost_in_usd_ticks`. Per the official docs + * (https://docs.x.ai/developers/cost-tracking and the API reference's usage + * schema): "TICKS_IN_USD_CENT: i64 = 100_000_000" ⇒ 10_000_000_000 (1e10) + * ticks per USD. Example given in the docs: 37756000 ticks ≈ $0.0038. + * + * NOTE: the upstream PR used a /1e12 divisor (100x under-report) — this port + * uses the doc-verified /1e10 divisor instead. + * + * OmniRoute previously always estimated cost from token counts × static + * pricing, discarding this exact figure. This test proves calculateCost()/ + * computeCostFromPricing() now trust the exact figure when present, and + * still fall back to the token-based estimate when it is absent (control). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { calculateCost, computeCostFromPricing } from "../../src/lib/usage/costCalculator.ts"; +import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts"; +import { extractUsage, normalizeUsage } from "../../open-sse/utils/usageTracking.ts"; + +// $1/1M input, $2/1M output → 1M+1M tokens would estimate to $3 at the +// metered rate. Chosen so the exact-cost value (~$0.0038) is unmistakably +// NOT the token-based estimate — proves the early return actually fires. +const PRICING = { input: 1, output: 2 }; +const TOKENS_1M_EACH = { input: 1_000_000, output: 1_000_000 }; + +// Doc example: 37756000 ticks ≈ $0.0038 (docs.x.ai/developers/cost-tracking). +const DOC_EXAMPLE_TICKS = 37_756_000; +const DOC_EXAMPLE_USD = 0.0037756; // 37756000 / 1e10, exact + +test("computeCostFromPricing: xAI exact cost_in_usd_ticks overrides the token-based estimate", () => { + const cost = computeCostFromPricing(PRICING, { + ...TOKENS_1M_EACH, + cost_in_usd_ticks: DOC_EXAMPLE_TICKS, + }); + assert.ok( + Math.abs(cost - DOC_EXAMPLE_USD) < 1e-9, + `expected ${DOC_EXAMPLE_USD}, got ${cost}` + ); + assert.notEqual(cost, 3, "must not fall back to the $3 token-based estimate"); +}); + +test("computeCostFromPricing: xAI exact cost works even with no pricing record at all", () => { + const cost = computeCostFromPricing(null, { + ...TOKENS_1M_EACH, + cost_in_usd_ticks: DOC_EXAMPLE_TICKS, + }); + assert.ok(Math.abs(cost - DOC_EXAMPLE_USD) < 1e-9); +}); + +test("computeCostFromPricing CONTROL: no cost_in_usd_ticks still falls back to the token-based estimate", () => { + assert.equal(computeCostFromPricing(PRICING, TOKENS_1M_EACH), 3); +}); + +test("calculateCost: xAI exact cost_in_usd_ticks overrides whatever the token-based estimate would be", async () => { + // Baseline: the token-based estimate calculateCost would otherwise compute + // for this provider/model/token-count (whatever xai/grok-4.3's local + // pricing table says — not hardcoded here, so this test doesn't break if + // pricing data changes). + const baseline = await calculateCost("xai", "grok-4.3", { input: 500, output: 500 }); + + const cost = await calculateCost("xai", "grok-4.3", { + input: 500, + output: 500, + cost_in_usd_ticks: DOC_EXAMPLE_TICKS, + }); + + assert.ok(Math.abs(cost - DOC_EXAMPLE_USD) < 1e-9, `expected ${DOC_EXAMPLE_USD}, got ${cost}`); + assert.notEqual(cost, baseline, "exact cost must override the token-based estimate"); +}); + +test("calculateCost CONTROL: no cost_in_usd_ticks still falls back to the token-based estimate (unchanged)", async () => { + const before = await calculateCost("xai", "grok-4.3", { input: 500, output: 500 }); + const after = await calculateCost("xai", "grok-4.3", { input: 500, output: 500 }); + assert.equal(after, before, "identical calls without the exact field must stay deterministic"); + assert.notEqual(after, DOC_EXAMPLE_USD, "must not coincidentally match the exact-cost value"); +}); + +test("normalizeUsage: passes through a finite cost_in_usd_ticks", () => { + const normalized = normalizeUsage({ prompt_tokens: 10, cost_in_usd_ticks: DOC_EXAMPLE_TICKS }); + assert.equal(normalized.cost_in_usd_ticks, DOC_EXAMPLE_TICKS); +}); + +test("normalizeUsage: drops a non-finite cost_in_usd_ticks", () => { + const normalized = normalizeUsage({ prompt_tokens: 10, cost_in_usd_ticks: "not-a-number" }); + assert.equal(normalized.cost_in_usd_ticks, undefined); +}); + +test("extractUsageFromResponse: xAI OpenAI-shaped usage carries cost_in_usd_ticks through", () => { + const usage = extractUsageFromResponse( + { + usage: { + prompt_tokens: 12, + completion_tokens: 8, + cost_in_usd_ticks: DOC_EXAMPLE_TICKS, + }, + }, + "xai" + ); + assert.equal(usage.cost_in_usd_ticks, DOC_EXAMPLE_TICKS); +}); + +test("extractUsageFromResponse CONTROL: non-xAI OpenAI usage without the field stays unchanged (no stray key)", () => { + const usage = extractUsageFromResponse( + { + usage: { + prompt_tokens: 12, + completion_tokens: 8, + prompt_tokens_details: { cached_tokens: 3 }, + completion_tokens_details: { reasoning_tokens: 2 }, + }, + }, + "openai" + ); + assert.deepEqual(usage, { + prompt_tokens: 12, + completion_tokens: 8, + cached_tokens: 3, + reasoning_tokens: 2, + }); + assert.ok(!("cost_in_usd_ticks" in usage), "must not add a stray undefined key"); +}); + +test("extractUsage (streaming): xAI OpenAI-format chunk carries cost_in_usd_ticks through", () => { + const usage = extractUsage({ + usage: { + prompt_tokens: 12, + completion_tokens: 8, + cost_in_usd_ticks: DOC_EXAMPLE_TICKS, + }, + }); + assert.equal(usage.cost_in_usd_ticks, DOC_EXAMPLE_TICKS); +});