mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
* chore(release): open v3.8.23 development cycle
* fix(anthropic): strip top_p when temperature is set to avoid 400 (#3691)
Integrated into release/v3.8.23
* fix(vertex): support Vertex AI Express-mode API keys (#3690)
Integrated into release/v3.8.23
* fix(stream): error on empty Claude SSE instead of synthetic success (#3689)
Integrated into release/v3.8.23
* fix(oauth): stop token-refresh invalidation loop + harden proxy resolution (#3692)
Integrated into release/v3.8.23
* docs: add FUNDING.yml and Support section to README (#3698)
Integrated into release/v3.8.23
* feat: gemini - handle known ratelimits (#3686)
Integrated into release/v3.8.23
* fix: stream combo fails over on empty content-filtered response (#3685) (#3702)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(antigravity): preserve gemini-3.1-pro high/low budget tiers (#3696) (#3703)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(auto-combo): add auto-updating model intelligence scoring (#3660)
Integrated into release/v3.8.23
* fix(gemini): context-mode fallback for signatureless tool calls (#3688) (#3704)
* chore(quality-gate): reconcile file-size baseline (27 files + providerLimits.ts) (#3705)
* feat(vertex): dynamic model discovery via Generative Language models API (#3712)
Integrated into release/v3.8.23. Vertex dynamic model discovery — surfaces image models (imagen-*, gemini-*-image), embeddings and audio from the live Generative Language catalog, with cached→static fallback and the shared parseGeminiModelsList helper. Validated: parser test 5/5, typecheck:core clean.
* fix(combo): gate reasoning token buffer (#3700)
Integrated into release/v3.8.23. Makes the #3588 reasoning token buffer safe and configurable: only inflates max_tokens when the model has a known, non-default output cap and the buffered value fits inside it; otherwise preserves/clamps the client limit. Adds the reasoningTokenBufferEnabled kill switch (default ON). Validated: combo-routing-engine 81/81, combo-config 25/25, combo-quality-validator-reasoning 12/12, phase1f 10/10, typecheck:core clean.
* refactor(#3501): god-component Phase 1g-1j — client 4062→3408 LOC (-654) (#3717)
Phase 1g-1j of #3501: client 4062→3408 LOC. Pure extraction (ProviderPlaygroundPanel, useCommandCodeAuth, useExternalLinkFlow+ExternalLinkModal, useAuthFileHandlers) + loadConnProxies ReferenceError fix + phase1f test path fix.
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* refactor(#3501): god-component Phase 1k-1m — client 3408→2553 LOC (-855) (#3721)
Phase 1k-1m of #3501: client 3408→2553 LOC. Pure extraction (useModelImportHandlers+ImportProgressModal, useModelVisibilityHandlers, ProviderModelsSection).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* docs(changelog): restore #3590 bullet lost on the v3.8.20 release branch
The fix itself reached main pre-tag via cherry-pick #3591, but its changelog
bullet (commit e33fdd4ab) only ever existed on release/v3.8.20 after the
squash-merge. Restored under [3.8.20] per the 2026-06-12 release-branch
leftover audit (_tasks/release-audit/release-leftovers-audit-2026-06-12.md).
* fix(kiro): resolve quota for IAM Identity Center accounts missing a profileArn (#3722)
Integrated into release/v3.8.23
* refactor(#3501): god-component Phase 1n-1s — client 2553→1376 LOC (-1177) (#3725)
Phase 1n-1s of #3501: client 2553→1376 LOC. Pure extraction (ConnectionsListPanel, ConnectionsHeaderToolbar, ZedImportCard, BatchTestResultsModal, AdaptaTutorialModal, useApiKeySave + helpers).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* feat(model-lockout): settings UI, backend integration, error classification, and success-decay recovery (#3629)
Integrated into release/v3.8.23
* refactor(#3501): god-component Phase 1t — client 1376→781 LOC (≤800 TARGET REACHED ✅) (#3727)
Phase 1t of #3501: client 1376→781 LOC (≤800 reached). Original god-component 12,882→781 (−94%).
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
* fix: bundle @omniroute/opencode-plugin inside omniroute + add 'setup opencode' CLI command (#3726)
Integrated into release/v3.8.23
* feat(vertex): self-tracked USD spend since account added (#3724)
Integrated into release/v3.8.23
* fix(qwen-web): migrate to v2 chat API with full cookie-jar replay (#3288) (#3723)
Integrated into release/v3.8.23
* fix(sse): make safeLogEvents async — 'await' in a sync function broke every chatHelpers import
#3692 added a lazy 'await import(proxyEgress)' for egress-IP visibility inside
safeLogEvents, which is a sync function — an ES syntax error. It went unnoticed
because typecheck:core does not cover src/sse and no test in the merge gates
loaded chatHelpers via tsx; any consumer that did (chat-context-relay and
chat-route-coverage suites, integration harnesses) failed at module load with
'await can only be used inside an async function'.
safeLogEvents is fire-and-forget logging with an outer try/catch, so making it
async (and 'void'-ing the single chat.ts call site) preserves behavior exactly.
Validation: tests/unit/chat-context-relay.test.ts + chat-route-coverage.test.ts
went from failing-at-load to green (+14 tests destravados).
* fix(sse): remove cross-provider credential leak in emergency fallback + combo/proxy audit fixes (#3699)
Integrated into release/v3.8.23
* fix(executors): inject MiMoCode anti-abuse marker so free endpoint stops 403ing (#3728)
Integrated into release/v3.8.23
* fix(dashboard): repair "Test all models" — toast crash, status icons, auto-hide (#3729)
Integrated into release/v3.8.23
* chore(deps): bump actions/upload-artifact from 4 to 7 (#3735)
Integrated into release/v3.8.23 — aligns upload-artifact to v7 (already used across ci.yml).
* chore(deps): bump actions/cache from 4 to 5 (#3734)
Integrated into release/v3.8.23 — actions/cache v4→v5.
* chore(deps): bump actions/download-artifact from 4 to 8 (#3733)
Integrated into release/v3.8.23 — download-artifact v4→v8.
* feat(fallback): add OMNIROUTE_EMERGENCY_FALLBACK env switch (#3741)
Adds an OMNIROUTE_EMERGENCY_FALLBACK env switch to disable the emergency budget-exhaustion fallback (reroute to free nvidia/gpt-oss-120b). Default unchanged (enabled). Closes #3739, related #2879.
Integrated into release/v3.8.23.
* i18n: comprehensive zh-CN translation improvements (#3736)
Aligns zh-CN to en (hundreds of entries), translates batch-action labels + settings sidebar menu, adds categoryConfig/endpointTokenSaver keys, resolves __MISSING__ stubs. Sidebar/SidebarTab hardcoded strings replaced with t(). en.json purely additive (8 new sidebar.* keys, 0 removed); cli-i18n gate green.
Integrated into release/v3.8.23.
* chore(release): v3.8.23 — 2026-06-12
- CHANGELOG: complete v3.8.23 section (28 bullets, 27 commits)
- fix(webdav): resolve promise on writeStream finish, not req end — eliminates
intermittent 500 on PUT update (writeStream may not have flushed at rename time)
- test(autoCombo): stub DB calls from PR #3660 in tieredRotation.test.ts to prevent
5s timeout in vitest (getModelIntelligenceBySource DB init path)
- chore(env-sync): add XDG_DATA_HOME + OMNIROUTE_OPENCODE_PLUGIN_DIR to IGNORE_FROM_CODE
allowlist (introduced by PR #3726 setup-open-code.mjs, not OmniRoute config vars)
- chore(cli): regenerated bin/cli/api-commands/*.mjs (7 new, 27 updated)
* fix(model-family): fallback lookup also tries bare model name with dots
getNextFamilyFallback normalized dots-to-hyphens ("gemini-3.1-pro-high" →
"gemini-3-1-pro-high") but MODEL_FAMILIES keys use the literal dot form. The
lookup always missed, returning null for any model whose dots are part of the
name rather than a version separator.
Fallback: try MODEL_FAMILIES[lookupKey] ?? MODEL_FAMILIES[bareModel] so both
naming conventions are covered. Fixes T30 test (pre-existing since v3.8.22).
* feat: expose API key cost drilldown + quota % used (#3742)
Adds all-time USD cost per API key in the API Key Manager, a per-key deep-link into the Cost Explorer (filtered + grouped by model), URL-param hydration of range/groupBy/apiKeyIds, and a '% used' quota display. Review adjustments: extracted URL-param parsers to a tested module (Rule #18), i18n'd the new strings (en + zh-CN), dropped the redundant webdav-handler entry already on release.
Integrated into release/v3.8.23.
* feat: add provider display modes — All / Configured / Compact (#3743)
Replaces the Providers page configured-only toggle with All/Configured/Compact display modes (Compact = flat deduped grid, no-auth last). Persists the preference and migrates the legacy localStorage key. Rebased onto release/v3.8.23.
Integrated into release/v3.8.23.
* fix(cache): scope semantic-cache signature to API key (#3740)
Adds the api_key_id dimension to generateSignature's SHA-256 hash so two callers with different API keys never receive each other's cached responses. Threads apiKeyId through checkSemanticCache + both write sites; migration 098 clears pre-existing key-less entries; unauthenticated requests stay isolated from keyed ones. 3 TDD tests.
Integrated into release/v3.8.23.
* fix(responses): apply OpenAI Responses API stream=false spec default (#3708)
resolveStreamFlag now applies the stream=false-when-omitted default for sourceFormat=openai-responses (same as the existing claude path), so spec-compliant /v1/responses upstreams that return JSON no longer fall through to the wildcard-Accept heuristic and trigger STREAM_EARLY_EOF / 502. Codex CLI (stream:true) and explicit text/event-stream clients unaffected.
Integrated into release/v3.8.23.
* chore(release): reconcile CI gates for v3.8.23
- file-size baseline: re-freeze 8 files grown by PRs #3742/#3743/#3740
(cost drilldown, provider display modes, cache key isolation)
- ARCHITECTURE.md: update executor count 55→60 (check:docs-counts drift)
- .env.example: add OMNIROUTE_EMERGENCY_FALLBACK (#3741, env-doc-sync)
- CHANGELOG: add formatted bullets for #3742, #3743, #3708, #3740,
model-family-fallback fix; remove duplicate raw ### Fixed section
* test: restore assert count to satisfy check:test-masking gate
Three test files had net assertion removals after behavior-changing PRs:
- chatcore-translation-paths: emergency fallback moved to routing layer
(#3699) — add body error assertion + model-name guard
- executor-vertex-extended: non-JSON is now Express API key (#3690) —
add projects/-path guard to the express-key URL test
- stream-utils: empty streams now emit error (#3685) — add code/message/
status/completePayload guards to both passthrough and translate variants
All new assertions are meaningful (code enum value, 5xx range, non-empty
message, onComplete must-not-fire contract).
* fix(ci): move rtl-logical-classes test to ui/ so vitest:ui runner collects it
---------
Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
Co-authored-by: NOXX - Commiter <artur1992123@mail.ru>
Co-authored-by: Nick Sullivan <142708+TechNickAI@users.noreply.github.com>
Co-authored-by: Markus Hartung <mail@hartmark.se>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Chewji <126886556+Chewji9875@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: Felipe Sartori <felipesartori.ti@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zois Pagoulatos <zpagoulatos@hotmail.com>
Co-authored-by: sdfsdfw2 <167810361+sdfsdfw2@users.noreply.github.com>
Co-authored-by: Witroch4 <witalo_rocha@hotmail.com>
5054 lines
183 KiB
TypeScript
5054 lines
183 KiB
TypeScript
/**
|
|
* Shared combo (model combo) handling with fallback support
|
|
* Supports: priority, weighted, round-robin, random, least-used, cost-optimized,
|
|
* reset-aware, reset-window, strict-random, auto, fill-first, p2c, lkgp,
|
|
* context-optimized, and context-relay strategies
|
|
*/
|
|
|
|
import {
|
|
checkFallbackError,
|
|
classifyErrorText,
|
|
classifyLockoutReason,
|
|
decayModelFailureCount,
|
|
formatRetryAfter,
|
|
getRuntimeProviderProfile,
|
|
isModelLocked,
|
|
recordModelLockoutFailure,
|
|
recordProviderFailure,
|
|
isProviderFailureCode,
|
|
isProviderExhaustedReason,
|
|
type ProviderProfile,
|
|
} from "./accountFallback.ts";
|
|
import { FETCH_TIMEOUT_MS, RateLimitReason } from "../config/constants.ts";
|
|
import { errorResponse, unavailableResponse } from "../utils/error.ts";
|
|
import { clamp01 } from "../utils/number.ts";
|
|
import {
|
|
recordComboIntent,
|
|
recordComboRequest,
|
|
recordComboShadowRequest,
|
|
getComboMetrics,
|
|
} from "./comboMetrics.ts";
|
|
import {
|
|
resolveComboConfig,
|
|
getDefaultComboConfig,
|
|
resolveComboTargetTimeoutMs,
|
|
PRE_SCREEN_CONCURRENCY,
|
|
} from "./comboConfig.ts";
|
|
import {
|
|
maybeGenerateHandoff,
|
|
resolveContextRelayConfig,
|
|
maybeGenerateUniversalHandoff,
|
|
injectUniversalHandoffBody,
|
|
resolveUniversalHandoffConfig,
|
|
SKIP_UNIVERSAL_HANDOFF_FLAG,
|
|
type MessageLike,
|
|
} from "./contextHandoff.ts";
|
|
import {
|
|
recordSessionModelUsage,
|
|
getLastSessionModel,
|
|
getHandoff,
|
|
} from "../../src/lib/db/contextHandoffs.ts";
|
|
import { resolveModelLockoutSettings } from "../../src/lib/resilience/modelLockoutSettings";
|
|
import { fetchCodexQuota } from "./codexQuotaFetcher.ts";
|
|
import { getQuotaFetcher } from "./quotaPreflight.ts";
|
|
import * as semaphore from "./rateLimitSemaphore.ts";
|
|
import { getCircuitBreaker } from "../../src/shared/utils/circuitBreaker";
|
|
import { fisherYatesShuffle, getNextFromDeck } from "../../src/shared/utils/shuffleDeck";
|
|
import { parseModel } from "./model.ts";
|
|
import { applyComboAgentMiddleware } from "./comboAgentMiddleware.ts";
|
|
import { checkCredentialGate, logCredentialSkip } from "./credentialGate.ts";
|
|
import { emit } from "../../src/lib/events/eventBus";
|
|
import { notifyWebhookEvent } from "../../src/lib/webhookDispatcher";
|
|
import {
|
|
classifyWithConfig,
|
|
DEFAULT_INTENT_CONFIG,
|
|
type IntentClassifierConfig,
|
|
} from "./intentClassifier.ts";
|
|
import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
|
|
import { selectWithStrategy, type SlaRoutingPolicy } from "./autoCombo/routerStrategy.ts";
|
|
import { getTaskFitness } from "./autoCombo/taskFitness.ts";
|
|
import { parseAutoPrefix } from "./autoCombo/autoPrefix.ts";
|
|
import { handlePipelineCombo, buildPipelineResponse } from "./autoCombo/pipelineRouter.ts";
|
|
import {
|
|
calculateFactors,
|
|
calculateScore,
|
|
DEFAULT_WEIGHTS,
|
|
type ProviderCandidate,
|
|
type ScoringWeights,
|
|
} from "./autoCombo/scoring.ts";
|
|
import { getResolvedModelCapabilities, supportsToolCalling } from "./modelCapabilities.ts";
|
|
import { estimateTokens } from "./contextManager.ts";
|
|
import { getReasoningTokens } from "../../src/lib/usage/tokenAccounting.ts";
|
|
import { getSessionConnection } from "./sessionManager.ts";
|
|
import { orderTargetsByEvalScores } from "./evalRouting.ts";
|
|
import { generateRoutingHints } from "./manifestAdapter";
|
|
import type { RoutingHint } from "./manifestAdapter";
|
|
import type { CompressionMode } from "./compression/types.ts";
|
|
import { getModelContextLimit } from "../../src/lib/modelCapabilities";
|
|
import { getProviderConnections } from "../../src/lib/db/providers";
|
|
import { getProviderModels } from "../config/providerModels.ts";
|
|
import {
|
|
getComboModelString,
|
|
getComboStepTarget,
|
|
getComboStepWeight,
|
|
normalizeComboStep,
|
|
} from "../../src/lib/combos/steps.ts";
|
|
import {
|
|
getConnectionRoutingTags,
|
|
matchesRoutingTags,
|
|
resolveRequestRoutingTags,
|
|
type RoutingTagMatchMode,
|
|
} from "../../src/domain/tagRouter.ts";
|
|
import { normalizeRoutingStrategy } from "../../src/shared/constants/routingStrategies.ts";
|
|
import {
|
|
isProviderInCooldown,
|
|
recordProviderCooldown,
|
|
recordProviderSuccess,
|
|
} from "./providerCooldownTracker.ts";
|
|
import {
|
|
resolveResilienceSettings,
|
|
type ResilienceSettings,
|
|
} from "../../src/lib/resilience/settings";
|
|
import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "./reasoningTokenBuffer.ts";
|
|
|
|
// Status codes that should mark round-robin target semaphores as cooling down.
|
|
const TRANSIENT_FOR_SEMAPHORE = [429, 502, 503, 504];
|
|
// Patterns that signal all accounts for a provider are rate-limited / exhausted.
|
|
// Used to detect 503 responses from handleNoCredentials so combo can fallback.
|
|
const ALL_ACCOUNTS_RATE_LIMITED_PATTERNS = [/unavailable/i, /service temporarily unavailable/i];
|
|
|
|
function isAllAccountsRateLimitedResponse(
|
|
status: number,
|
|
contentType: string | null,
|
|
errorText: string
|
|
): boolean {
|
|
if (status !== 503) return false;
|
|
if (!contentType?.includes("application/json")) return false;
|
|
return ALL_ACCOUNTS_RATE_LIMITED_PATTERNS.some((p) => p.test(errorText));
|
|
}
|
|
|
|
// #1731v2 guard: a provider circuit-breaker-open response (503 + `X-OmniRoute-Provider-Breaker`
|
|
// header / `provider_circuit_open` error code, see providerCircuitOpenResponse) is an OmniRoute
|
|
// resilience signal, NOT a per-connection upstream failure. It must keep being treated as an
|
|
// ordinary target failure (try the next target, including same-provider ones) — so it must NOT
|
|
// poison exhaustedConnections/exhaustedProviders, otherwise remaining same-provider targets get
|
|
// wrongly skipped while the breaker is open.
|
|
function isProviderCircuitOpenResult(
|
|
result: { headers?: Headers | null; status?: number },
|
|
errorText: string
|
|
): boolean {
|
|
const breakerHeader = result.headers?.get?.("x-omniroute-provider-breaker");
|
|
if (typeof breakerHeader === "string" && breakerHeader.toLowerCase() === "open") return true;
|
|
return /provider_circuit_open/i.test(errorText);
|
|
}
|
|
|
|
const MAX_COMBO_DEPTH = 3;
|
|
const MAX_FALLBACK_WAIT_MS = 5000;
|
|
const MAX_GLOBAL_ATTEMPTS = 30;
|
|
|
|
function resolveDelayMs(value: unknown, fallback: number): number {
|
|
const numericValue = Number(value);
|
|
if (!Number.isFinite(numericValue) || numericValue < 0) return fallback;
|
|
return numericValue;
|
|
}
|
|
|
|
function comboModelNotFoundResponse(message: string) {
|
|
return errorResponse(404, message);
|
|
}
|
|
|
|
// Bootstrap defaults from ClawRouter benchmark (used when no local latency history exists yet)
|
|
const DEFAULT_MODEL_P95_MS: Record<string, number> = {
|
|
"grok-4-fast-non-reasoning": 1143,
|
|
"grok-4-1-fast-non-reasoning": 1244,
|
|
"gemini-2.5-flash": 1238,
|
|
"kimi-k2.5": 1646,
|
|
"gpt-4o-mini": 2764,
|
|
"claude-sonnet-4.6": 4000,
|
|
"claude-opus-4.6": 6000,
|
|
"deepseek-chat": 2000,
|
|
};
|
|
const MIN_HISTORY_SAMPLES = 10;
|
|
// Assumed fraction of tokens that are output when blending input+output prices
|
|
// for auto-combo cost scoring. 0.4 = 40% output, 60% input.
|
|
// Matches the example in GitHub issue #1812 (e.g. o3-like model: $3 input/$15 output).
|
|
const OUTPUT_TOKEN_RATIO = 0.4;
|
|
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
const RESET_AWARE_SESSION_REMAINING_WEIGHT = 0.45;
|
|
const RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT = 0.55;
|
|
const RESET_AWARE_WEEKLY_REMAINING_WEIGHT = 0.25;
|
|
const RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT = 0.75;
|
|
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
|
|
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
|
|
const RESET_AWARE_DEFAULTS = {
|
|
sessionWeight: 0.35,
|
|
weeklyWeight: 0.65,
|
|
tieBandPercent: 5,
|
|
exhaustionGuardPercent: 10,
|
|
};
|
|
const RESET_WINDOW_DEFAULT_TIE_BAND_MS = 60_000;
|
|
|
|
// Quota Share soft-policy deprioritization factor (B17).
|
|
// When a candidate has quotaSoftPenalty === true, its auto-combo score is
|
|
// multiplied by this factor so over-quota-soft keys are de-prioritized
|
|
// without being fully blocked (that is done by "hard" policy).
|
|
// Override via QUOTA_SOFT_DEPRIORITIZE_FACTOR env var (range 0..1, default 0.7).
|
|
export const QUOTA_SOFT_DEPRIORITIZE_FACTOR = Number(
|
|
process.env.QUOTA_SOFT_DEPRIORITIZE_FACTOR ?? "0.7"
|
|
);
|
|
|
|
// G2: Module-level registry of active combo execution candidates.
|
|
// Maps executionKey → Map<stepId, candidate mutable ref>.
|
|
// Populated by buildAutoCandidates registrations; cleaned up after each execution.
|
|
// This allows chatCore.ts to mark a candidate's quotaSoftPenalty flag so that
|
|
// subsequent scoring iterations (auto-combo fallback) deprioritize it.
|
|
const _activeExecutionCandidates = new Map<string, Map<string, { quotaSoftPenalty?: boolean }>>();
|
|
|
|
/**
|
|
* Mark a specific candidate (by comboExecutionKey + stepId) with soft quota penalty.
|
|
* Called from chatCore.ts when enforceQuotaShare returns a "soft deprioritize" decision.
|
|
* The flag is read on subsequent auto-combo scoring iterations (fallback chain)
|
|
* within the same combo execution via scoreAutoTargets → QUOTA_SOFT_DEPRIORITIZE_FACTOR.
|
|
*
|
|
* Guards:
|
|
* - null executionKey or stepId → no-op (non-combo or context not available).
|
|
* - unknown executionKey → no-op (candidate not yet registered or already cleaned up).
|
|
* - Idempotent: calling twice with the same (key, stepId, true) is safe.
|
|
*/
|
|
export function setCandidateQuotaSoftPenalty(
|
|
comboExecutionKey: string | null,
|
|
comboStepId: string | null,
|
|
penalty: boolean
|
|
): void {
|
|
if (!comboExecutionKey || !comboStepId) return;
|
|
const byStep = _activeExecutionCandidates.get(comboExecutionKey);
|
|
if (!byStep) return;
|
|
const candidate = byStep.get(comboStepId);
|
|
if (candidate) {
|
|
candidate.quotaSoftPenalty = penalty;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register candidates for a combo execution so setCandidateQuotaSoftPenalty can
|
|
* locate them by (executionKey, stepId).
|
|
* Each candidate object is stored by reference — mutations via setCandidateQuotaSoftPenalty
|
|
* propagate back to the original candidate array used by scoreAutoTargets.
|
|
* @internal — not exported; only called within combo.ts by buildAutoCandidates callers.
|
|
*/
|
|
function _registerExecutionCandidates(
|
|
candidates: Array<{ executionKey: string; stepId: string; quotaSoftPenalty?: boolean }>
|
|
): void {
|
|
for (const candidate of candidates) {
|
|
if (!candidate.executionKey) continue;
|
|
let byStep = _activeExecutionCandidates.get(candidate.executionKey);
|
|
if (!byStep) {
|
|
byStep = new Map();
|
|
_activeExecutionCandidates.set(candidate.executionKey, byStep);
|
|
}
|
|
byStep.set(candidate.stepId, candidate);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregister all candidates for a given execution key once the execution completes.
|
|
* Prevents unbounded memory growth.
|
|
* @internal — not exported; called after each handleComboChat iteration.
|
|
*/
|
|
function _unregisterExecutionCandidates(executionKeys: string[]): void {
|
|
for (const key of executionKeys) {
|
|
_activeExecutionCandidates.delete(key);
|
|
}
|
|
}
|
|
|
|
const RESET_WINDOW_NAMES = ["weekly", "session", "monthly"] as const;
|
|
type ResetWindowName = (typeof RESET_WINDOW_NAMES)[number];
|
|
type QuotaFetchCacheConfig = {
|
|
quotaCacheTtlMs: number;
|
|
quotaCacheMaxStaleMs: number;
|
|
};
|
|
type ResetWindowConfig = ReturnType<typeof resolveResetWindowConfig>;
|
|
type ComboRetryAfter = string | number | Date;
|
|
type ComboErrorBody = {
|
|
error?: { code?: string | null; message?: string | null } | string;
|
|
message?: string | null;
|
|
retryAfter?: ComboRetryAfter | null;
|
|
} | null;
|
|
|
|
type ComboLike = {
|
|
id?: string;
|
|
name: string;
|
|
strategy?: string | null;
|
|
models: unknown[];
|
|
config?: Record<string, unknown> | null;
|
|
autoConfig?: Record<string, unknown> | null;
|
|
context_cache_protection?: boolean | number;
|
|
system_message?: string | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type ComboInput = ComboLike | Record<string, unknown>;
|
|
|
|
type ComboCollectionLike = ComboInput[] | { combos?: ComboInput[] } | null | undefined;
|
|
|
|
type ComboLogger = {
|
|
info: (...args: unknown[]) => void;
|
|
warn: (...args: unknown[]) => void;
|
|
error?: (...args: unknown[]) => void;
|
|
debug: (...args: unknown[]) => void;
|
|
};
|
|
|
|
export type SingleModelTarget =
|
|
| (ResolvedComboTarget & {
|
|
allowRateLimitedConnection?: boolean;
|
|
modelAbortSignal?: AbortSignal | null;
|
|
})
|
|
| { modelAbortSignal: AbortSignal };
|
|
|
|
type HandleSingleModel = (
|
|
body: Record<string, unknown>,
|
|
modelStr: string,
|
|
target?: SingleModelTarget
|
|
) => Promise<Response>;
|
|
|
|
type IsModelAvailable = (
|
|
modelStr: string,
|
|
target?: ResolvedComboTarget & { allowRateLimitedConnection?: boolean }
|
|
) => Promise<boolean> | boolean;
|
|
|
|
type ComboRelayOptions = {
|
|
sessionId?: string | null;
|
|
config?: Record<string, unknown> | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type HandleComboChatOptions = {
|
|
body: Record<string, unknown>;
|
|
combo: ComboLike;
|
|
handleSingleModel: HandleSingleModel;
|
|
isModelAvailable?: IsModelAvailable;
|
|
log: ComboLogger;
|
|
settings?: Record<string, unknown> | null;
|
|
allCombos?: ComboCollectionLike;
|
|
relayOptions?: ComboRelayOptions | null;
|
|
signal?: AbortSignal | null;
|
|
apiKeyAllowedConnections?: string[] | null;
|
|
};
|
|
|
|
type HandleRoundRobinOptions = Omit<
|
|
HandleComboChatOptions,
|
|
"relayOptions" | "apiKeyAllowedConnections"
|
|
>;
|
|
|
|
type HistoricalLatencyStatsEntry = {
|
|
totalRequests?: number;
|
|
p95LatencyMs?: number;
|
|
latencyStdDev?: number;
|
|
successRate?: number;
|
|
};
|
|
|
|
type AutoProviderCandidate = ProviderCandidate & {
|
|
stepId: string;
|
|
executionKey: string;
|
|
modelStr: string;
|
|
/**
|
|
* When true, this candidate's auto-combo score is multiplied by
|
|
* QUOTA_SOFT_DEPRIORITIZE_FACTOR (B17 soft-policy penalty).
|
|
* Set externally when enforceQuotaShare returns deprioritize=true
|
|
* for the key routed through this target's connectionId.
|
|
*/
|
|
quotaSoftPenalty?: boolean;
|
|
};
|
|
|
|
function toRetryAfterDisplayValue(value: ComboRetryAfter): string | Date {
|
|
if (typeof value !== "number") return value;
|
|
if (value > 0 && value < 1_000_000_000) {
|
|
return new Date(Date.now() + value * 1000);
|
|
}
|
|
return new Date(value);
|
|
}
|
|
|
|
export type ResolvedComboTarget = {
|
|
kind: "model";
|
|
stepId: string;
|
|
executionKey: string;
|
|
modelStr: string;
|
|
provider: string;
|
|
providerId: string | null;
|
|
connectionId: string | null;
|
|
allowedConnectionIds?: string[] | null;
|
|
weight: number;
|
|
label: string | null;
|
|
failoverBeforeRetry?: unknown;
|
|
trafficType?: "production" | "shadow";
|
|
};
|
|
|
|
type ShadowRoutingConfig = {
|
|
enabled: boolean;
|
|
targets: unknown[];
|
|
sampleRate: number;
|
|
maxTargets: number;
|
|
timeoutMs: number;
|
|
};
|
|
|
|
type ComboRuntimeStep =
|
|
| ResolvedComboTarget
|
|
| {
|
|
kind: "combo-ref";
|
|
stepId: string;
|
|
executionKey: string;
|
|
comboName: string;
|
|
weight: number;
|
|
label: string | null;
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function toTrimmedString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function toComboLike(combo: ComboInput): ComboLike {
|
|
return {
|
|
...combo,
|
|
id: toTrimmedString(combo.id) || undefined,
|
|
name: toTrimmedString(combo.name) || "",
|
|
models: Array.isArray(combo.models) ? combo.models : [],
|
|
config: isRecord(combo.config) ? combo.config : null,
|
|
autoConfig: isRecord(combo.autoConfig) ? combo.autoConfig : null,
|
|
context_cache_protection:
|
|
typeof combo.context_cache_protection === "boolean" ||
|
|
typeof combo.context_cache_protection === "number"
|
|
? combo.context_cache_protection
|
|
: undefined,
|
|
system_message: typeof combo.system_message === "string" ? combo.system_message : null,
|
|
};
|
|
}
|
|
|
|
function getCombosArray(allCombos: ComboCollectionLike): ComboLike[] {
|
|
const combos = Array.isArray(allCombos) ? allCombos : allCombos?.combos || [];
|
|
return combos.map((combo) => toComboLike(combo));
|
|
}
|
|
|
|
/**
|
|
* Validate that a successful (HTTP 200) non-streaming response actually contains
|
|
* meaningful content. Returns { valid: true } or { valid: false, reason }.
|
|
*
|
|
* Only inspects non-streaming JSON responses — streaming responses are passed through
|
|
* because buffering the full stream would defeat the purpose of streaming.
|
|
*
|
|
* Checks:
|
|
* 1. Body is valid JSON
|
|
* 2. Has at least one choice with non-empty content or tool_calls
|
|
*/
|
|
export async function validateResponseQuality(
|
|
response: Response,
|
|
isStreaming: boolean,
|
|
log: { warn?: (...args: unknown[]) => void }
|
|
): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> {
|
|
// Issue #3685: For Claude SSE streaming responses, use a BOUNDED PEEK to
|
|
// detect the empty-content-block pattern (content_filter stop_reason with
|
|
// no content_block_* events) WITHOUT de-streaming non-empty responses.
|
|
//
|
|
// Strategy:
|
|
// - Read chunks from response.body one at a time, accumulating raw bytes.
|
|
// - Parse SSE events incrementally.
|
|
// - If a content_block_* event appears → stream HAS content. Stop buffering.
|
|
// Return a clonedResponse whose body replays buffered bytes then pipes the
|
|
// remainder of the original reader. Only the chunks up to the first content
|
|
// block were held in memory — the rest stream normally.
|
|
// - If the stream ends with a complete Claude lifecycle but NO content_block
|
|
// → return invalid (combo failover). The empty lifecycle is tiny so fully
|
|
// reading it is acceptable.
|
|
// - If the stream ends without a recognisable complete Claude lifecycle →
|
|
// return valid with a clonedResponse replaying all buffered bytes (don't
|
|
// misclassify non-Claude or partial streams as empty).
|
|
//
|
|
// Non-text/event-stream streaming responses are not buffered at all.
|
|
if (isStreaming) {
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!contentType.includes("text/event-stream")) {
|
|
return { valid: true };
|
|
}
|
|
|
|
if (!response.body) {
|
|
return { valid: true };
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder("utf-8");
|
|
|
|
// Raw Uint8Array chunks accumulated so far — used to replay the prefix
|
|
// in the returned clonedResponse.
|
|
const bufferedChunks: Uint8Array[] = [];
|
|
// Decoded text accumulated across chunks for incremental SSE parsing.
|
|
// Only the tail of the most-recently-processed line window remains here
|
|
// between iterations (incomplete lines are deferred to the next chunk).
|
|
let decodedSoFar = "";
|
|
|
|
// SSE lifecycle state.
|
|
let hasMessageStart = false;
|
|
let hasContentBlock = false;
|
|
let hasLifecycleEnd = false;
|
|
// `event:` type line seen before the next `data:` line in the same event.
|
|
let pendingEventType = "";
|
|
|
|
/**
|
|
* Parse any complete SSE lines from `decodedSoFar`, updating lifecycle
|
|
* flags in the closure. The last (potentially incomplete) line is kept in
|
|
* `decodedSoFar` for the next iteration.
|
|
*
|
|
* Returns true when a content_block_* event is detected — the caller
|
|
* should stop peeking and treat the stream as non-empty.
|
|
*/
|
|
function parseAccumulatedSse(): boolean {
|
|
const lines = decodedSoFar.split(/\r?\n/);
|
|
// Retain the potentially-incomplete trailing fragment.
|
|
decodedSoFar = lines[lines.length - 1];
|
|
|
|
for (let i = 0; i < lines.length - 1; i++) {
|
|
const trimmed = lines[i].trim();
|
|
|
|
if (trimmed.startsWith("event:")) {
|
|
pendingEventType = trimmed.slice(6).trim();
|
|
continue;
|
|
}
|
|
|
|
if (!trimmed.startsWith("data:")) {
|
|
if (!trimmed) pendingEventType = "";
|
|
continue;
|
|
}
|
|
|
|
const data = trimmed.slice(5).trim();
|
|
if (!data || data === "[DONE]") continue;
|
|
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const eventType =
|
|
(typeof parsed.type === "string" ? parsed.type : null) || pendingEventType || "";
|
|
pendingEventType = "";
|
|
|
|
switch (eventType) {
|
|
case "message_start":
|
|
hasMessageStart = true;
|
|
break;
|
|
case "content_block_start":
|
|
case "content_block_delta":
|
|
case "content_block_stop":
|
|
hasContentBlock = true;
|
|
// Signal caller to stop buffering immediately.
|
|
return true;
|
|
case "message_stop":
|
|
hasLifecycleEnd = true;
|
|
break;
|
|
case "message_delta": {
|
|
const delta = parsed.delta;
|
|
if (
|
|
delta &&
|
|
typeof delta === "object" &&
|
|
(delta as Record<string, unknown>).stop_reason != null
|
|
) {
|
|
hasLifecycleEnd = true;
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build a Response whose body first replays all bytes in `bufferedChunks`,
|
|
* then forwards the remainder of `readerToForward` chunk-by-chunk.
|
|
* Preserves the original response's status, statusText, and headers.
|
|
*/
|
|
function buildReplayResponse(
|
|
readerToForward: ReadableStreamDefaultReader<Uint8Array>
|
|
): Response {
|
|
// Snapshot the prefix so mutations after this point don't affect it.
|
|
const prefix = bufferedChunks.slice();
|
|
let prefixIdx = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
// 1. Drain the buffered prefix one chunk at a time.
|
|
if (prefixIdx < prefix.length) {
|
|
controller.enqueue(prefix[prefixIdx++]);
|
|
return;
|
|
}
|
|
// 2. Forward the remainder from the original reader.
|
|
try {
|
|
const { done, value } = await readerToForward.read();
|
|
if (done) {
|
|
controller.close();
|
|
} else {
|
|
controller.enqueue(value);
|
|
}
|
|
} catch {
|
|
controller.close();
|
|
}
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
});
|
|
}
|
|
|
|
// Main bounded-peek loop.
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
|
|
if (done) {
|
|
// Stream finished — flush the TextDecoder and parse any remaining text.
|
|
const tail = decoder.decode(undefined, { stream: false });
|
|
if (tail) decodedSoFar += tail;
|
|
parseAccumulatedSse();
|
|
|
|
if (hasMessageStart && hasLifecycleEnd && !hasContentBlock) {
|
|
// Complete Claude lifecycle with zero content blocks → failover.
|
|
log.warn?.(
|
|
"COMBO",
|
|
"Streaming Claude response has complete lifecycle but zero content blocks (content_filter?) — marking as invalid for combo failover"
|
|
);
|
|
return { valid: false, reason: "streaming empty content block" };
|
|
}
|
|
|
|
// Incomplete lifecycle or non-Claude stream — replay all buffered
|
|
// bytes. The reader is exhausted so the forwarding reader will
|
|
// immediately signal done.
|
|
const clonedResponse = buildReplayResponse(reader);
|
|
return { valid: true, clonedResponse };
|
|
}
|
|
|
|
// Accumulate raw bytes for potential replay.
|
|
bufferedChunks.push(value);
|
|
|
|
// Decode incrementally (stream:true keeps multi-byte char state).
|
|
decodedSoFar += decoder.decode(value, { stream: true });
|
|
const foundContent = parseAccumulatedSse();
|
|
|
|
if (foundContent) {
|
|
// A content_block_* event was found — stop peeking. Return a
|
|
// clonedResponse that replays all buffered bytes (the current chunk
|
|
// is already in bufferedChunks) and then forwards the remainder of
|
|
// the original reader unchanged.
|
|
const clonedResponse = buildReplayResponse(reader);
|
|
return { valid: true, clonedResponse };
|
|
}
|
|
}
|
|
} catch {
|
|
// If reading the stream fails, pass through — other mechanisms
|
|
// (stream readiness timeout) will catch truly broken streams.
|
|
return { valid: true };
|
|
}
|
|
}
|
|
|
|
const contentType = response.headers.get("content-type") || "";
|
|
if (!contentType.includes("application/json") && !contentType.includes("text/")) {
|
|
return { valid: true };
|
|
}
|
|
|
|
let cloned: Response;
|
|
try {
|
|
cloned = response.clone();
|
|
} catch {
|
|
return { valid: true };
|
|
}
|
|
|
|
let text: string;
|
|
try {
|
|
text = await cloned.text();
|
|
} catch {
|
|
return { valid: true };
|
|
}
|
|
|
|
if (!text || text.trim().length === 0) {
|
|
return { valid: false, reason: "empty response body" };
|
|
}
|
|
|
|
let json: Record<string, unknown>;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
if (text.startsWith("data:") || text.startsWith("event:")) return { valid: true };
|
|
return { valid: false, reason: "response is not valid JSON" };
|
|
}
|
|
|
|
const choices = json?.choices;
|
|
if (!Array.isArray(choices) || choices.length === 0) {
|
|
if (json?.output || json?.result || json?.data || json?.response) return { valid: true };
|
|
if (json?.error) {
|
|
const err = json.error as Record<string, unknown>;
|
|
return {
|
|
valid: false,
|
|
reason: `upstream error in 200 body: ${err?.message || JSON.stringify(json.error).substring(0, 200)}`,
|
|
};
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
const firstChoice = choices[0];
|
|
const message = firstChoice?.message || firstChoice?.delta;
|
|
if (!message) {
|
|
return { valid: false, reason: "choice has no message object" };
|
|
}
|
|
|
|
const content = message.content;
|
|
const toolCalls = message.tool_calls;
|
|
// Issue #2341: Reasoning models (Kimi-K2.5-TEE, GLM-5-TEE, etc.) emit their
|
|
// output in `reasoning_content` (or `reasoning`) with `content: null`. The
|
|
// validator used to flag those as empty and trigger a false-positive 502
|
|
// fallback. Count a non-empty reasoning_content as valid output too.
|
|
const reasoningContent = message.reasoning_content ?? message.reasoning;
|
|
const hasReasoningContent =
|
|
typeof reasoningContent === "string" && reasoningContent.trim().length > 0;
|
|
const hasContent =
|
|
(content !== null && content !== undefined && content !== "") || hasReasoningContent;
|
|
const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
|
|
|
|
if (!hasContent && !hasToolCalls) {
|
|
return { valid: false, reason: "empty content and no tool_calls in response" };
|
|
}
|
|
|
|
// Issue #3587: Reasoning models (deepseek-v4-flash, nemotron, etc.) may consume
|
|
// ALL max_tokens for reasoning_tokens, leaving content empty. When content is
|
|
// empty but reasoning_content exists, and usage shows reasoning consumed nearly
|
|
// all completion tokens, treat as invalid so the combo loop retries with more
|
|
// tokens or falls back to a non-reasoning model.
|
|
const contentIsEmpty = content === null || content === undefined || content === "";
|
|
if (contentIsEmpty && hasReasoningContent && !hasToolCalls) {
|
|
const usage = json?.usage as Record<string, unknown> | undefined;
|
|
if (usage) {
|
|
const completionTokens = Number(usage.completion_tokens) || 0;
|
|
const reasoningTokens = getReasoningTokens(usage);
|
|
// If reasoning consumed 90%+ of completion tokens, the model ran out of
|
|
// budget before producing any content output.
|
|
if (completionTokens > 0 && reasoningTokens >= completionTokens * 0.9) {
|
|
return {
|
|
valid: false,
|
|
reason: `reasoning consumed ${reasoningTokens}/${completionTokens} tokens — no content output`,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
clonedResponse: new Response(text, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// In-memory atomic counter per combo for round-robin distribution
|
|
// Resets on server restart (by design — no stale state)
|
|
// Eviction limits to prevent unbounded memory growth
|
|
const MAX_RR_COUNTERS = 500;
|
|
const MAX_RESET_AWARE_CACHE = 200;
|
|
|
|
const rrCounters = new Map<string, number>();
|
|
|
|
const resetAwareConnectionCache = new Map<
|
|
string,
|
|
{ fetchedAt: number; connections: Array<Record<string, unknown>> }
|
|
>();
|
|
const resetAwareQuotaCache = new Map<
|
|
string,
|
|
{ fetchedAt: number; quota: unknown; refreshPromise: Promise<unknown> | null }
|
|
>();
|
|
|
|
/**
|
|
* Normalize a model entry to { model, weight }
|
|
* Supports both legacy string format and new object format
|
|
*/
|
|
function normalizeModelEntry(entry: unknown): { model: string; weight: number } {
|
|
return {
|
|
model: getComboStepTarget(entry) || "",
|
|
weight: getComboStepWeight(entry),
|
|
};
|
|
}
|
|
|
|
function getTargetProvider(modelStr: string, providerId?: string | null): string {
|
|
const parsed = parseModel(modelStr);
|
|
return providerId || parsed.provider || parsed.providerAlias || "unknown";
|
|
}
|
|
|
|
function isStreamReadinessFailureErrorBody(errorBody: unknown): boolean {
|
|
if (!errorBody || typeof errorBody !== "object") return false;
|
|
const error = (errorBody as Record<string, unknown>).error;
|
|
if (!error || typeof error !== "object") return false;
|
|
const code = (error as Record<string, unknown>).code;
|
|
return code === "STREAM_READINESS_TIMEOUT" || code === "STREAM_EARLY_EOF";
|
|
}
|
|
|
|
/**
|
|
* A local per-API-key token-limit breach surfaces as a 429 tagged with
|
|
* errorCode "TOKEN_LIMIT_EXCEEDED" (see chatCore.ts Tier 2 early return). This
|
|
* is NOT an upstream rate limit, so the combo loop must not cool the shared
|
|
* account/provider, must not add it to transientRateLimitedProviders, and must
|
|
* not retry it transiently — it propagates to the client as a terminal 429.
|
|
*/
|
|
function isTokenLimitBreachErrorBody(errorBody: unknown): boolean {
|
|
if (!errorBody || typeof errorBody !== "object") return false;
|
|
const error = (errorBody as Record<string, unknown>).error;
|
|
if (!error || typeof error !== "object") return false;
|
|
return (error as Record<string, unknown>).code === "TOKEN_LIMIT_EXCEEDED";
|
|
}
|
|
|
|
function toRecordedTarget(target: ResolvedComboTarget) {
|
|
return {
|
|
executionKey: target.executionKey,
|
|
stepId: target.stepId,
|
|
provider: target.provider,
|
|
providerId: target.providerId,
|
|
connectionId: target.connectionId,
|
|
label: target.label,
|
|
};
|
|
}
|
|
|
|
function normalizeShadowRoutingConfig(config: Record<string, unknown>): ShadowRoutingConfig {
|
|
const raw = isRecord(config.shadowRouting) ? config.shadowRouting : {};
|
|
const sampleRate = Number(raw.sampleRate ?? 1);
|
|
const maxTargets = Number(raw.maxTargets ?? 2);
|
|
const timeoutMs = Number(raw.timeoutMs ?? 30000);
|
|
return {
|
|
enabled: raw.enabled === true,
|
|
targets: Array.isArray(raw.targets) ? raw.targets : [],
|
|
sampleRate: Number.isFinite(sampleRate) ? Math.max(0, Math.min(1, sampleRate)) : 1,
|
|
maxTargets: Number.isFinite(maxTargets) ? Math.max(1, Math.min(10, Math.floor(maxTargets))) : 2,
|
|
timeoutMs: Number.isFinite(timeoutMs)
|
|
? Math.max(1000, Math.min(120000, Math.floor(timeoutMs)))
|
|
: 30000,
|
|
};
|
|
}
|
|
|
|
function resolveShadowTargets(
|
|
combo: ComboLike,
|
|
config: Record<string, unknown>,
|
|
allCombos: ComboCollectionLike
|
|
): ResolvedComboTarget[] {
|
|
const shadowConfig = normalizeShadowRoutingConfig(config);
|
|
if (!shadowConfig.enabled || shadowConfig.targets.length === 0) return [];
|
|
if (shadowConfig.sampleRate <= 0 || Math.random() > shadowConfig.sampleRate) return [];
|
|
|
|
const shadowCombo: ComboLike = {
|
|
...combo,
|
|
name: `${combo.name}:shadow`,
|
|
models: shadowConfig.targets,
|
|
};
|
|
return resolveNestedComboTargets(shadowCombo, allCombos, new Set([combo.name]), 0, ["shadow"])
|
|
.slice(0, shadowConfig.maxTargets)
|
|
.map((target) => ({
|
|
...target,
|
|
trafficType: "shadow" as const,
|
|
}));
|
|
}
|
|
|
|
async function drainShadowResponse(response: Response): Promise<void> {
|
|
try {
|
|
if (!response.body) return;
|
|
await response.arrayBuffer();
|
|
} catch {
|
|
// Shadow draining is best-effort and must never affect the production response.
|
|
}
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error("Shadow route timed out")), timeoutMs);
|
|
promise.then(
|
|
(value) => {
|
|
clearTimeout(timer);
|
|
resolve(value);
|
|
},
|
|
(error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
function cloneRequestBodyForShadowRouting(body: Record<string, unknown>): Record<string, unknown> {
|
|
if (typeof structuredClone === "function") {
|
|
return structuredClone(body) as Record<string, unknown>;
|
|
}
|
|
|
|
return JSON.parse(JSON.stringify(body)) as Record<string, unknown>;
|
|
}
|
|
|
|
function scheduleShadowRouting(
|
|
combo: ComboLike,
|
|
config: Record<string, unknown>,
|
|
body: Record<string, unknown>,
|
|
targets: ResolvedComboTarget[],
|
|
handleSingleModel: HandleSingleModel,
|
|
isModelAvailable: IsModelAvailable | undefined,
|
|
strategy: string,
|
|
log: ComboLogger
|
|
): void {
|
|
if (targets.length === 0) return;
|
|
const shadowConfig = normalizeShadowRoutingConfig(config);
|
|
let shadowBaseBody: Record<string, unknown>;
|
|
try {
|
|
shadowBaseBody = cloneRequestBodyForShadowRouting(body);
|
|
} catch (error) {
|
|
log.warn("COMBO", "Shadow routing skipped: failed to clone request body", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return;
|
|
}
|
|
const run = async () => {
|
|
await Promise.all(
|
|
targets.map(async (target) => {
|
|
const startedAt = Date.now();
|
|
try {
|
|
const shadowBody = {
|
|
...cloneRequestBodyForShadowRouting(shadowBaseBody),
|
|
model: target.modelStr,
|
|
stream: false,
|
|
};
|
|
if (isModelAvailable) {
|
|
const available = await isModelAvailable(target.modelStr, target);
|
|
if (!available) {
|
|
recordComboShadowRequest(combo.name, target.modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startedAt,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
log.info("COMBO", `Shadow target skipped (unavailable): ${target.modelStr}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const response = await withTimeout(
|
|
handleSingleModel(shadowBody, target.modelStr, {
|
|
...target,
|
|
failoverBeforeRetry: true,
|
|
trafficType: "shadow",
|
|
}),
|
|
shadowConfig.timeoutMs
|
|
);
|
|
await drainShadowResponse(response.clone());
|
|
recordComboShadowRequest(combo.name, target.modelStr, {
|
|
success: response.ok,
|
|
latencyMs: Date.now() - startedAt,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
log.info(
|
|
"COMBO",
|
|
`Shadow target ${target.modelStr} completed with status ${response.status} (${strategy})`
|
|
);
|
|
} catch (error) {
|
|
recordComboShadowRequest(combo.name, target.modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startedAt,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
log.warn("COMBO", `Shadow target ${target.modelStr} failed`, {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
})
|
|
);
|
|
};
|
|
|
|
setTimeout(() => void run(), 0);
|
|
}
|
|
|
|
function buildExecutionKey(path: string[], stepId: string): string {
|
|
return [...path, stepId].join(">");
|
|
}
|
|
|
|
function normalizeRuntimeStep(
|
|
entry: unknown,
|
|
comboName: string,
|
|
index: number,
|
|
allCombos: ComboCollectionLike,
|
|
path: string[] = []
|
|
): ComboRuntimeStep | null {
|
|
const step = normalizeComboStep(entry, {
|
|
comboName,
|
|
index,
|
|
allCombos,
|
|
});
|
|
if (!step) return null;
|
|
|
|
const executionKey = buildExecutionKey(path, step.id);
|
|
const label = typeof step.label === "string" ? step.label : null;
|
|
const weight = step.weight || 0;
|
|
|
|
if (step.kind === "combo-ref") {
|
|
return {
|
|
kind: "combo-ref",
|
|
stepId: step.id,
|
|
executionKey,
|
|
comboName: step.comboName,
|
|
weight,
|
|
label,
|
|
};
|
|
}
|
|
|
|
const modelStr = getComboModelString(step);
|
|
if (!modelStr) return null;
|
|
|
|
return {
|
|
kind: "model",
|
|
stepId: step.id,
|
|
executionKey,
|
|
modelStr,
|
|
provider: getTargetProvider(modelStr, step.providerId),
|
|
providerId: step.providerId || null,
|
|
connectionId: step.connectionId || null,
|
|
weight,
|
|
label,
|
|
} satisfies ResolvedComboTarget;
|
|
}
|
|
|
|
function getDirectComboTargets(combo: ComboLike): ResolvedComboTarget[] {
|
|
return getOrderedTopLevelRuntimeSteps(combo, null).filter(
|
|
(entry): entry is ResolvedComboTarget => entry?.kind === "model"
|
|
);
|
|
}
|
|
|
|
function getTopLevelRuntimeSteps(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike,
|
|
path: string[] = []
|
|
): ComboRuntimeStep[] {
|
|
return (combo.models || [])
|
|
.map((entry, index) => normalizeRuntimeStep(entry, combo.name, index, allCombos, path))
|
|
.filter((entry): entry is ComboRuntimeStep => entry !== null);
|
|
}
|
|
|
|
function getCompositeTierStepOrder(combo: ComboLike): string[] {
|
|
const compositeTiers = isRecord(combo?.config) ? combo.config.compositeTiers : null;
|
|
if (!isRecord(compositeTiers)) return [];
|
|
|
|
const defaultTier = toTrimmedString(compositeTiers.defaultTier);
|
|
const tiers = isRecord(compositeTiers.tiers) ? compositeTiers.tiers : null;
|
|
if (!defaultTier || !tiers) return [];
|
|
|
|
const orderedStepIds: string[] = [];
|
|
const visitedTiers = new Set<string>();
|
|
const seenStepIds = new Set<string>();
|
|
type CompositeTierEntry = readonly [
|
|
string,
|
|
{ readonly stepId: string; readonly fallbackTier: string | null },
|
|
];
|
|
const tierEntries = new Map(
|
|
Object.entries(tiers)
|
|
.map(([tierName, rawTier]) => {
|
|
if (!isRecord(rawTier)) return null;
|
|
const normalizedTierName = toTrimmedString(tierName);
|
|
const stepId = toTrimmedString(rawTier.stepId);
|
|
const fallbackTier = toTrimmedString(rawTier.fallbackTier);
|
|
if (!normalizedTierName || !stepId) return null;
|
|
return [normalizedTierName, { stepId, fallbackTier }] as const;
|
|
})
|
|
.filter((entry): entry is CompositeTierEntry => entry !== null)
|
|
);
|
|
|
|
let currentTier: string | null = defaultTier;
|
|
while (currentTier && tierEntries.has(currentTier) && !visitedTiers.has(currentTier)) {
|
|
visitedTiers.add(currentTier);
|
|
const entry = tierEntries.get(currentTier);
|
|
if (!entry) break;
|
|
if (!seenStepIds.has(entry.stepId)) {
|
|
orderedStepIds.push(entry.stepId);
|
|
seenStepIds.add(entry.stepId);
|
|
}
|
|
currentTier = entry.fallbackTier;
|
|
}
|
|
|
|
for (const entry of tierEntries.values()) {
|
|
if (!seenStepIds.has(entry.stepId)) {
|
|
orderedStepIds.push(entry.stepId);
|
|
seenStepIds.add(entry.stepId);
|
|
}
|
|
}
|
|
|
|
return orderedStepIds;
|
|
}
|
|
|
|
function hasCompositeTierRuntimeOrder(combo: ComboLike): boolean {
|
|
return getCompositeTierStepOrder(combo).length > 0;
|
|
}
|
|
|
|
function orderRuntimeStepsByCompositeTiers(
|
|
steps: ComboRuntimeStep[],
|
|
combo: ComboLike
|
|
): ComboRuntimeStep[] {
|
|
const orderedStepIds = getCompositeTierStepOrder(combo);
|
|
if (orderedStepIds.length === 0) return steps;
|
|
|
|
const byStepId = new Map(steps.map((step) => [step.stepId, step]));
|
|
const seen = new Set<string>();
|
|
const ordered: ComboRuntimeStep[] = [];
|
|
|
|
for (const stepId of orderedStepIds) {
|
|
const step = byStepId.get(stepId);
|
|
if (!step || seen.has(step.stepId)) continue;
|
|
ordered.push(step);
|
|
seen.add(step.stepId);
|
|
}
|
|
|
|
for (const step of steps) {
|
|
if (seen.has(step.stepId)) continue;
|
|
ordered.push(step);
|
|
seen.add(step.stepId);
|
|
}
|
|
|
|
return ordered;
|
|
}
|
|
|
|
function getOrderedTopLevelRuntimeSteps(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike,
|
|
path: string[] = []
|
|
): ComboRuntimeStep[] {
|
|
return orderRuntimeStepsByCompositeTiers(getTopLevelRuntimeSteps(combo, allCombos, path), combo);
|
|
}
|
|
|
|
function expandRuntimeStep(
|
|
step: ComboRuntimeStep,
|
|
allCombos: ComboCollectionLike,
|
|
visited = new Set<string>(),
|
|
depth = 0,
|
|
path: string[] = []
|
|
): ResolvedComboTarget[] {
|
|
if (step.kind === "model") return [step];
|
|
if (depth > MAX_COMBO_DEPTH) return [];
|
|
|
|
const combos = getCombosArray(allCombos);
|
|
const nestedCombo = combos.find((combo) => combo.name === step.comboName);
|
|
if (!nestedCombo || visited.has(step.comboName)) return [];
|
|
|
|
return resolveNestedComboTargets(nestedCombo, combos, new Set(visited), depth + 1, [
|
|
...path,
|
|
step.stepId,
|
|
]);
|
|
}
|
|
|
|
export function resolveNestedComboTargets(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike,
|
|
visited = new Set<string>(),
|
|
depth = 0,
|
|
path: string[] = []
|
|
): ResolvedComboTarget[] {
|
|
const directTargets = (combo.models || [])
|
|
.map((entry, index) => normalizeRuntimeStep(entry, combo.name, index, null, path))
|
|
.filter((entry): entry is ResolvedComboTarget => entry?.kind === "model");
|
|
|
|
if (depth > MAX_COMBO_DEPTH) return directTargets;
|
|
if (visited.has(combo.name)) return [];
|
|
visited.add(combo.name);
|
|
|
|
const runtimeSteps = getOrderedTopLevelRuntimeSteps(combo, allCombos, path);
|
|
const resolved: ResolvedComboTarget[] = [];
|
|
|
|
for (const step of runtimeSteps) {
|
|
if (step.kind === "combo-ref") {
|
|
resolved.push(...expandRuntimeStep(step, allCombos, new Set(visited), depth, path));
|
|
continue;
|
|
}
|
|
resolved.push(step);
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
/**
|
|
* Get combo models from combos data (for open-sse standalone use)
|
|
* @param {string} modelStr - Model string to check
|
|
* @param {Array|Object} combosData - Array of combos or object with combos
|
|
* @returns {Object|null} Full combo object or null if not a combo
|
|
*/
|
|
export function getComboFromData(
|
|
modelStr: string,
|
|
combosData: ComboCollectionLike
|
|
): ComboLike | null {
|
|
const combos = getCombosArray(combosData);
|
|
const combo = combos.find((c) => c.name === modelStr);
|
|
if (combo?.models && combo.models.length > 0) {
|
|
return combo;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Legacy: Get combo models as string array (backward compat)
|
|
*/
|
|
export function getComboModelsFromData(
|
|
modelStr: string,
|
|
combosData: ComboCollectionLike
|
|
): string[] | null {
|
|
const combo = getComboFromData(modelStr, combosData);
|
|
if (!combo) return null;
|
|
return combo.models.map((m) => normalizeModelEntry(m).model);
|
|
}
|
|
|
|
/**
|
|
* Validate combo DAG — detect circular references and enforce max depth
|
|
* @param {string} comboName - Name of the combo to validate
|
|
* @param {Array} allCombos - All combos in the system
|
|
* @param {Set} [visited] - Set of already visited combo names (for cycle detection)
|
|
* @param {number} [depth] - Current depth level
|
|
* @throws {Error} If circular reference or max depth exceeded
|
|
*/
|
|
export function validateComboDAG(
|
|
comboName: string,
|
|
allCombos: ComboCollectionLike,
|
|
visited = new Set<string>(),
|
|
depth = 0
|
|
): void {
|
|
if (depth > MAX_COMBO_DEPTH) {
|
|
throw new Error(`Max combo nesting depth (${MAX_COMBO_DEPTH}) exceeded at "${comboName}"`);
|
|
}
|
|
if (visited.has(comboName)) {
|
|
throw new Error(`Circular combo reference detected: ${comboName}`);
|
|
}
|
|
visited.add(comboName);
|
|
|
|
const combos = getCombosArray(allCombos);
|
|
const combo = combos.find((c) => c.name === comboName);
|
|
if (!combo?.models) return;
|
|
|
|
for (const entry of combo.models) {
|
|
const modelName = normalizeModelEntry(entry).model;
|
|
// Check if this model name is itself a combo (not a provider/model pattern)
|
|
const nestedCombo = combos.find((c) => c.name === modelName);
|
|
if (nestedCombo) {
|
|
validateComboDAG(modelName, combos, new Set(visited), depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve nested combos by expanding inline to a flat model list
|
|
* Respects max depth and detects cycles
|
|
* @param {Object} combo - The combo object
|
|
* @param {Array} allCombos - All combos in the system
|
|
* @param {Set} [visited] - For cycle detection
|
|
* @param {number} [depth] - Current depth
|
|
* @returns {Array} Flat array of model strings
|
|
*/
|
|
export function resolveNestedComboModels(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike,
|
|
visited = new Set<string>(),
|
|
depth = 0
|
|
): string[] {
|
|
if (depth > MAX_COMBO_DEPTH) return combo.models.map((m) => normalizeModelEntry(m).model);
|
|
if (visited.has(combo.name)) return []; // cycle safety
|
|
visited.add(combo.name);
|
|
|
|
const combos = getCombosArray(allCombos);
|
|
const resolved: string[] = [];
|
|
|
|
for (const entry of combo.models || []) {
|
|
const modelName = normalizeModelEntry(entry).model;
|
|
const nestedCombo = combos.find((c) => c.name === modelName);
|
|
|
|
if (nestedCombo) {
|
|
// Recursively expand the nested combo
|
|
const nested = resolveNestedComboModels(nestedCombo, combos, new Set(visited), depth + 1);
|
|
resolved.push(...nested);
|
|
} else {
|
|
resolved.push(modelName);
|
|
}
|
|
}
|
|
|
|
return resolved;
|
|
}
|
|
|
|
function selectWeightedTarget<T extends { weight?: number }>(targets: T[]) {
|
|
if (targets.length === 0) return null;
|
|
|
|
const totalWeight = targets.reduce((sum, target) => sum + (target.weight || 0), 0);
|
|
if (totalWeight <= 0) {
|
|
return targets[Math.floor(Math.random() * targets.length)];
|
|
}
|
|
|
|
let random = Math.random() * totalWeight;
|
|
for (const target of targets) {
|
|
random -= target.weight || 0;
|
|
if (random <= 0) return target;
|
|
}
|
|
|
|
return targets.at(-1);
|
|
}
|
|
|
|
function orderTargetsForWeightedFallback<T extends { executionKey: string; weight: number }>(
|
|
targets: T[],
|
|
selectedExecutionKey: string,
|
|
preserveExistingOrder = false
|
|
): T[] {
|
|
const selected = targets.find((target) => target.executionKey === selectedExecutionKey);
|
|
const rest = targets.filter((target) => target.executionKey !== selectedExecutionKey);
|
|
if (!preserveExistingOrder) {
|
|
rest.sort((a, b) => b.weight - a.weight);
|
|
}
|
|
return selected ? [selected, ...rest] : rest;
|
|
}
|
|
|
|
// shuffleArray and getNextModelFromDeck moved to src/shared/utils/shuffleDeck.ts
|
|
// combo.ts now uses the shared, mutex-protected getNextFromDeck with "combo:" namespace.
|
|
|
|
/**
|
|
* Sort models by pricing (cheapest first) for cost-optimized strategy
|
|
* @param {Array<string>} models - Model strings in "provider/model" format
|
|
* @returns {Promise<Array<string>>} Sorted model strings
|
|
*/
|
|
async function sortModelsByCost(models: string[]): Promise<string[]> {
|
|
try {
|
|
const { getPricingForModel } = await import("../../src/lib/localDb");
|
|
const withCost = await Promise.all(
|
|
models.map(async (modelStr) => {
|
|
const parsed = parseModel(modelStr);
|
|
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
|
const model = parsed.model || modelStr;
|
|
try {
|
|
const pricing = await getPricingForModel(provider, model);
|
|
const cost = Number(pricing?.input);
|
|
return { modelStr, cost: Number.isFinite(cost) ? cost : Infinity };
|
|
} catch {
|
|
return { modelStr, cost: Infinity };
|
|
}
|
|
})
|
|
);
|
|
withCost.sort((a, b) => a.cost - b.cost);
|
|
return withCost.map((e) => e.modelStr);
|
|
} catch {
|
|
// If pricing lookup fails entirely, return original order
|
|
return models;
|
|
}
|
|
}
|
|
|
|
async function sortTargetsByCost(targets: ResolvedComboTarget[]) {
|
|
const orderedModels = await sortModelsByCost(targets.map((target) => target.modelStr));
|
|
const byModel = new Map<string, ResolvedComboTarget[]>();
|
|
for (const target of targets) {
|
|
const queue = byModel.get(target.modelStr) || [];
|
|
queue.push(target);
|
|
byModel.set(target.modelStr, queue);
|
|
}
|
|
return orderedModels
|
|
.map((modelStr) => {
|
|
const queue = byModel.get(modelStr);
|
|
return queue?.shift() || null;
|
|
})
|
|
.filter((target): target is ResolvedComboTarget => target !== null);
|
|
}
|
|
|
|
/**
|
|
* Sort models by usage count (least-used first) for least-used strategy
|
|
* @param {Array<string>} models - Model strings
|
|
* @param {string} comboName - Combo name for metrics lookup
|
|
* @returns {Array<string>} Sorted model strings
|
|
*/
|
|
function sortModelsByUsage(models: string[], comboName: string): string[] {
|
|
const metrics = getComboMetrics(comboName);
|
|
if (!metrics?.byModel) return models;
|
|
|
|
const withUsage = models.map((modelStr) => ({
|
|
modelStr,
|
|
requests: metrics.byModel[modelStr]?.requests ?? 0,
|
|
}));
|
|
withUsage.sort((a, b) => a.requests - b.requests);
|
|
return withUsage.map((e) => e.modelStr);
|
|
}
|
|
|
|
function sortTargetsByUsage(targets: ResolvedComboTarget[], comboName: string) {
|
|
const orderedModels = sortModelsByUsage(
|
|
targets.map((target) => target.modelStr),
|
|
comboName
|
|
);
|
|
const byModel = new Map<string, ResolvedComboTarget[]>();
|
|
for (const target of targets) {
|
|
const queue = byModel.get(target.modelStr) || [];
|
|
queue.push(target);
|
|
byModel.set(target.modelStr, queue);
|
|
}
|
|
return orderedModels
|
|
.map((modelStr) => {
|
|
const queue = byModel.get(modelStr);
|
|
return queue?.shift() || null;
|
|
})
|
|
.filter((target): target is ResolvedComboTarget => target !== null);
|
|
}
|
|
|
|
/**
|
|
* Sort models by context window size (largest first) for context-optimized strategy.
|
|
* Uses models.dev synced capabilities to get context limits.
|
|
* @param {Array<string>} models - Model strings in "provider/model" format
|
|
* @returns {Array<string>} Sorted model strings (largest context first)
|
|
*/
|
|
function sortModelsByContextSize(models: string[]): string[] {
|
|
const withContext = models.map((modelStr) => {
|
|
return { modelStr, context: getModelContextLimitForModelString(modelStr) ?? 0 };
|
|
});
|
|
withContext.sort((a, b) => b.context - a.context);
|
|
return withContext.map((e) => e.modelStr);
|
|
}
|
|
|
|
function getModelContextLimitForModelString(modelStr: string) {
|
|
const parsed = parseModel(modelStr);
|
|
const provider = parsed.provider || parsed.providerAlias || "unknown";
|
|
const model = parsed.model || modelStr;
|
|
return getModelContextLimit(provider, model);
|
|
}
|
|
|
|
type RequestCompatibilityRequirements = {
|
|
requiresTools: boolean;
|
|
requiresVision: boolean;
|
|
requiresStructuredOutput: boolean;
|
|
estimatedInputTokens: number;
|
|
requestedOutputTokens: number;
|
|
requiredContextTokens: number;
|
|
};
|
|
|
|
function getPositiveTokenCount(value: unknown): number {
|
|
const count = Number(value);
|
|
return Number.isFinite(count) && count > 0 ? Math.ceil(count) : 0;
|
|
}
|
|
|
|
function requestRequiresTools(body: Record<string, unknown>): boolean {
|
|
if (Array.isArray(body.tools) && body.tools.length > 0) return true;
|
|
if (Array.isArray(body.functions) && body.functions.length > 0) return true;
|
|
return false;
|
|
}
|
|
|
|
function requestRequiresStructuredOutput(body: Record<string, unknown>): boolean {
|
|
const responseFormat = isRecord(body.response_format) ? body.response_format : null;
|
|
const type = typeof responseFormat?.type === "string" ? responseFormat.type : null;
|
|
return type === "json_object" || type === "json_schema";
|
|
}
|
|
|
|
function estimateRequestInputTokens(body: Record<string, unknown>): number {
|
|
const estimatePayload: Record<string, unknown> = {};
|
|
for (const key of ["messages", "input", "tools", "functions", "response_format"]) {
|
|
if (body[key] !== undefined) estimatePayload[key] = body[key];
|
|
}
|
|
return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0;
|
|
}
|
|
|
|
function valueContainsImagePart(value: unknown, depth = 0): boolean {
|
|
if (depth > 8 || value === null || value === undefined) return false;
|
|
if (typeof value === "string") return value.startsWith("data:image/");
|
|
if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1));
|
|
if (!isRecord(value)) return false;
|
|
|
|
const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
|
|
if (type === "image" || type === "image_url" || type === "input_image") return true;
|
|
if ("image_url" in value || "input_image" in value) return true;
|
|
|
|
const source = isRecord(value.source) ? value.source : null;
|
|
const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : "";
|
|
if (mediaType.startsWith("image/")) return true;
|
|
|
|
return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1));
|
|
}
|
|
|
|
function deriveRequestCompatibilityRequirements(
|
|
body: Record<string, unknown>
|
|
): RequestCompatibilityRequirements {
|
|
const estimatedInputTokens = estimateRequestInputTokens(body);
|
|
const requestedOutputTokens = Math.max(
|
|
getPositiveTokenCount(body.max_tokens),
|
|
getPositiveTokenCount(body.max_completion_tokens)
|
|
);
|
|
return {
|
|
requiresTools: requestRequiresTools(body),
|
|
requiresVision: valueContainsImagePart(body.messages) || valueContainsImagePart(body.input),
|
|
requiresStructuredOutput: requestRequiresStructuredOutput(body),
|
|
estimatedInputTokens,
|
|
requestedOutputTokens,
|
|
requiredContextTokens: estimatedInputTokens + requestedOutputTokens,
|
|
};
|
|
}
|
|
|
|
function getTargetCompatibilityFailures(
|
|
target: ResolvedComboTarget,
|
|
requirements: RequestCompatibilityRequirements
|
|
): string[] {
|
|
const capabilities = getResolvedModelCapabilities(target.modelStr);
|
|
const failures: string[] = [];
|
|
|
|
if (
|
|
requirements.requiresTools &&
|
|
(capabilities.supportsTools === false || !capabilities.toolCalling)
|
|
) {
|
|
failures.push("tools");
|
|
}
|
|
|
|
if (requirements.requiresVision && capabilities.supportsVision === false) {
|
|
failures.push("vision");
|
|
}
|
|
|
|
if (requirements.requiresStructuredOutput && capabilities.structuredOutput === false) {
|
|
failures.push("structured_output");
|
|
}
|
|
|
|
if (
|
|
requirements.requestedOutputTokens > 0 &&
|
|
Number.isFinite(capabilities.maxOutputTokens) &&
|
|
capabilities.maxOutputTokens < requirements.requestedOutputTokens
|
|
) {
|
|
failures.push("output_tokens");
|
|
}
|
|
|
|
const contextLimit = capabilities.maxInputTokens ?? capabilities.contextWindow ?? null;
|
|
if (
|
|
requirements.requiredContextTokens > 0 &&
|
|
contextLimit !== null &&
|
|
contextLimit !== undefined &&
|
|
contextLimit < requirements.requiredContextTokens
|
|
) {
|
|
failures.push("context_window");
|
|
}
|
|
|
|
return failures;
|
|
}
|
|
|
|
function filterTargetsByRequestCompatibility(
|
|
targets: ResolvedComboTarget[],
|
|
body: Record<string, unknown>,
|
|
log: ComboLogger,
|
|
label = "Context-aware fallback"
|
|
): ResolvedComboTarget[] {
|
|
if (targets.length === 0) return targets;
|
|
const requirements = deriveRequestCompatibilityRequirements(body);
|
|
const needsFiltering =
|
|
requirements.requiresTools ||
|
|
requirements.requiresVision ||
|
|
requirements.requiresStructuredOutput ||
|
|
requirements.requiredContextTokens > 0;
|
|
if (!needsFiltering) return targets;
|
|
|
|
const rejected: Array<{ target: ResolvedComboTarget; reasons: string[] }> = [];
|
|
const compatible = targets.filter((target) => {
|
|
const reasons = getTargetCompatibilityFailures(target, requirements);
|
|
if (reasons.length === 0) return true;
|
|
rejected.push({ target, reasons });
|
|
return false;
|
|
});
|
|
|
|
if (compatible.length === targets.length) return targets;
|
|
if (compatible.length === 0) {
|
|
log.warn(
|
|
"COMBO",
|
|
`${label}: all ${targets.length} targets were filtered by request requirements; preserving strategy order`
|
|
);
|
|
log.debug?.(
|
|
"COMBO",
|
|
`${label}: rejected targets ${rejected
|
|
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
|
|
.join(", ")}`
|
|
);
|
|
return targets;
|
|
}
|
|
|
|
log.info(
|
|
"COMBO",
|
|
`${label}: kept ${compatible.length}/${targets.length} targets for request requirements`
|
|
);
|
|
log.debug?.(
|
|
"COMBO",
|
|
`${label}: rejected targets ${rejected
|
|
.map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`)
|
|
.join(", ")}`
|
|
);
|
|
return compatible;
|
|
}
|
|
|
|
function sortTargetsByContextSize(targets: ResolvedComboTarget[]) {
|
|
const hasKnownContext = targets.some(
|
|
(target) => getModelContextLimitForModelString(target.modelStr) != null
|
|
);
|
|
if (!hasKnownContext) return targets;
|
|
|
|
const orderedModels = sortModelsByContextSize(targets.map((target) => target.modelStr));
|
|
const byModel = new Map<string, ResolvedComboTarget[]>();
|
|
for (const target of targets) {
|
|
const queue = byModel.get(target.modelStr) || [];
|
|
queue.push(target);
|
|
byModel.set(target.modelStr, queue);
|
|
}
|
|
return orderedModels
|
|
.map((modelStr) => {
|
|
const queue = byModel.get(modelStr);
|
|
return queue?.shift() || null;
|
|
})
|
|
.filter((target): target is ResolvedComboTarget => target !== null);
|
|
}
|
|
|
|
function getP2CTargetScore(
|
|
target: ResolvedComboTarget,
|
|
metrics: ReturnType<typeof getComboMetrics>
|
|
): number {
|
|
const breakerState = getCircuitBreaker(target.provider)?.getStatus?.()?.state;
|
|
if (breakerState === "OPEN") return -Infinity;
|
|
const modelMetric = metrics?.byModel?.[target.modelStr] || null;
|
|
const successRate = Number(modelMetric?.successRate);
|
|
const avgLatency = Number(modelMetric?.avgLatencyMs);
|
|
const successScore = Number.isFinite(successRate) ? successRate / 100 : 0.5;
|
|
const latencyScore =
|
|
Number.isFinite(avgLatency) && avgLatency > 0 ? 1 / Math.log10(avgLatency + 10) : 0.25;
|
|
const breakerPenalty = breakerState === "HALF_OPEN" ? 0.25 : 0;
|
|
return successScore + latencyScore - breakerPenalty;
|
|
}
|
|
|
|
function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboName: string) {
|
|
if (targets.length <= 1) return targets;
|
|
const metrics = getComboMetrics(comboName);
|
|
const firstIndex = Math.floor(Math.random() * targets.length);
|
|
let secondIndex = Math.floor(Math.random() * (targets.length - 1));
|
|
if (secondIndex >= firstIndex) secondIndex++;
|
|
|
|
const first = targets[firstIndex];
|
|
const second = targets[secondIndex];
|
|
const selectedIndex =
|
|
getP2CTargetScore(second, metrics) > getP2CTargetScore(first, metrics)
|
|
? secondIndex
|
|
: firstIndex;
|
|
return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)];
|
|
}
|
|
|
|
function finiteNumberOrNull(value: unknown): number | null {
|
|
const numericValue = Number(value);
|
|
return Number.isFinite(numericValue) ? numericValue : null;
|
|
}
|
|
|
|
function getPercentConfig(value: unknown, fallback: number): number {
|
|
const numericValue = finiteNumberOrNull(value);
|
|
if (numericValue === null) return fallback;
|
|
return Math.max(0, Math.min(100, numericValue));
|
|
}
|
|
|
|
function getWeightConfig(value: unknown, fallback: number): number {
|
|
const numericValue = finiteNumberOrNull(value);
|
|
if (numericValue === null || numericValue < 0) return fallback;
|
|
return numericValue;
|
|
}
|
|
|
|
function getDurationConfig(value: unknown, fallback: number, max: number): number {
|
|
const numericValue = finiteNumberOrNull(value);
|
|
if (numericValue === null || numericValue < 0) return fallback;
|
|
return Math.min(max, Math.floor(numericValue));
|
|
}
|
|
|
|
function resolveResetAwareConfig(config: Record<string, unknown> | null | undefined) {
|
|
const sessionWeight = getWeightConfig(
|
|
config?.resetAwareSessionWeight,
|
|
RESET_AWARE_DEFAULTS.sessionWeight
|
|
);
|
|
const weeklyWeight = getWeightConfig(
|
|
config?.resetAwareWeeklyWeight,
|
|
RESET_AWARE_DEFAULTS.weeklyWeight
|
|
);
|
|
const totalWeight = sessionWeight + weeklyWeight;
|
|
const normalizedSessionWeight =
|
|
totalWeight > 0 ? sessionWeight / totalWeight : RESET_AWARE_DEFAULTS.sessionWeight;
|
|
|
|
return {
|
|
sessionWeight: normalizedSessionWeight,
|
|
weeklyWeight: 1 - normalizedSessionWeight,
|
|
tieBand:
|
|
getPercentConfig(config?.resetAwareTieBandPercent, RESET_AWARE_DEFAULTS.tieBandPercent) / 100,
|
|
exhaustionGuard:
|
|
getPercentConfig(
|
|
config?.resetAwareExhaustionGuardPercent,
|
|
RESET_AWARE_DEFAULTS.exhaustionGuardPercent
|
|
) / 100,
|
|
quotaCacheTtlMs: getDurationConfig(config?.resetAwareQuotaCacheTtlMs, 0, 300_000),
|
|
quotaCacheMaxStaleMs: getDurationConfig(config?.resetAwareQuotaCacheMaxStaleMs, 0, 3_600_000),
|
|
};
|
|
}
|
|
|
|
function resolveResetWindowConfig(config: Record<string, unknown> | null | undefined) {
|
|
const rawWindows = Array.isArray(config?.resetWindowWindows) ? config.resetWindowWindows : null;
|
|
const windows = rawWindows
|
|
?.filter((windowName): windowName is ResetWindowName =>
|
|
(RESET_WINDOW_NAMES as readonly string[]).includes(String(windowName))
|
|
)
|
|
.filter((windowName, index, array) => array.indexOf(windowName) === index);
|
|
|
|
const effectiveWindows =
|
|
windows && windows.length > 0
|
|
? windows
|
|
: config?.resetWindowIncludeSession === true
|
|
? (["weekly", "session"] as ResetWindowName[])
|
|
: (["weekly"] as ResetWindowName[]);
|
|
|
|
return {
|
|
windows: effectiveWindows,
|
|
tieBandMs: Math.max(
|
|
0,
|
|
finiteNumberOrNull(config?.resetWindowTieBandMs) ?? RESET_WINDOW_DEFAULT_TIE_BAND_MS
|
|
),
|
|
quotaCacheTtlMs: getDurationConfig(config?.resetWindowQuotaCacheTtlMs, 0, 300_000),
|
|
quotaCacheMaxStaleMs: getDurationConfig(config?.resetWindowQuotaCacheMaxStaleMs, 0, 3_600_000),
|
|
};
|
|
}
|
|
|
|
function resolveSlaRoutingPolicy(
|
|
config: Record<string, unknown> | null | undefined
|
|
): SlaRoutingPolicy | undefined {
|
|
if (!config) return undefined;
|
|
const nestedSla = isRecord(config.sla) ? config.sla : {};
|
|
const targetP95Ms = finiteNumberOrNull(config.slaTargetP95Ms ?? nestedSla.targetP95Ms);
|
|
const maxErrorRate = finiteNumberOrNull(config.slaMaxErrorRate ?? nestedSla.maxErrorRate);
|
|
const maxCostPer1MTokens = finiteNumberOrNull(
|
|
config.slaMaxCostPer1MTokens ?? nestedSla.maxCostPer1MTokens
|
|
);
|
|
const hardConstraints = config.slaHardConstraints ?? nestedSla.hardConstraints;
|
|
|
|
const policy: SlaRoutingPolicy = {};
|
|
if (targetP95Ms !== null && targetP95Ms > 0) policy.targetP95Ms = targetP95Ms;
|
|
if (maxErrorRate !== null && maxErrorRate >= 0) policy.maxErrorRate = clamp01(maxErrorRate);
|
|
if (maxCostPer1MTokens !== null && maxCostPer1MTokens > 0) {
|
|
policy.maxCostPer1MTokens = maxCostPer1MTokens;
|
|
}
|
|
if (typeof hardConstraints === "boolean") policy.hardConstraints = hardConstraints;
|
|
|
|
return Object.keys(policy).length > 0 ? policy : undefined;
|
|
}
|
|
|
|
function getResetAwareProvider(target: ResolvedComboTarget): string | null {
|
|
const provider = (target.providerId || target.provider || "").toLowerCase();
|
|
return provider || null;
|
|
}
|
|
|
|
function normalizeResetAt(value: unknown): string | null {
|
|
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
return null;
|
|
}
|
|
|
|
function parseResetTimeMs(resetAt: string | null | undefined): number {
|
|
if (!resetAt) return NaN;
|
|
const resetTime = Date.parse(resetAt);
|
|
if (Number.isFinite(resetTime)) return resetTime;
|
|
|
|
if (!/^\d+(?:\.\d+)?$/.test(resetAt)) return NaN;
|
|
const numericResetAt = Number(resetAt);
|
|
if (!Number.isFinite(numericResetAt)) return NaN;
|
|
return numericResetAt < 10_000_000_000 ? numericResetAt * 1000 : numericResetAt;
|
|
}
|
|
|
|
function getQuotaWindow(
|
|
quota: unknown,
|
|
key: "window5h" | "window7d" | "windowWeekly" | "windowMonthly"
|
|
): { percentUsed: number | null; resetAt: string | null } | null {
|
|
if (!isRecord(quota)) return null;
|
|
const window = quota[key];
|
|
if (!isRecord(window)) return null;
|
|
const percentUsed = finiteNumberOrNull(window.percentUsed);
|
|
const resetAt = normalizeResetAt(window.resetAt);
|
|
return { percentUsed, resetAt };
|
|
}
|
|
|
|
function normalizeWindowPercentUsed(value: unknown): number | null {
|
|
const numericValue = finiteNumberOrNull(value);
|
|
if (numericValue === null) return null;
|
|
if (numericValue > 1) return clamp01(numericValue / 100);
|
|
return clamp01(numericValue);
|
|
}
|
|
|
|
function getNamedQuotaWindow(
|
|
quota: unknown,
|
|
windowName: ResetWindowName
|
|
): { percentUsed: number | null; resetAt: string | null } | null {
|
|
if (!quota || !isRecord(quota)) return null;
|
|
|
|
if (windowName === "session") return getQuotaWindow(quota, "window5h");
|
|
if (windowName === "weekly") {
|
|
return getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
|
}
|
|
if (windowName === "monthly") return getQuotaWindow(quota, "windowMonthly");
|
|
|
|
return null;
|
|
}
|
|
|
|
function getWindowsMapQuotaWindow(
|
|
quota: unknown,
|
|
windowName: ResetWindowName
|
|
): { percentUsed: number | null; resetAt: string | null } | null {
|
|
if (!quota || !isRecord(quota) || !isRecord(quota.windows)) return null;
|
|
const candidates = Object.entries(quota.windows)
|
|
.map(([key, value]) => ({ key: key.toLowerCase(), value }))
|
|
.filter(({ key }) => key === windowName || key.startsWith(`${windowName} `));
|
|
|
|
if (candidates.length === 0) return null;
|
|
candidates.sort((a, b) => a.key.localeCompare(b.key));
|
|
const window = candidates[0].value;
|
|
if (!isRecord(window)) return null;
|
|
|
|
return {
|
|
percentUsed: normalizeWindowPercentUsed(window.percentUsed),
|
|
resetAt: normalizeResetAt(window.resetAt),
|
|
};
|
|
}
|
|
|
|
function resolveQuotaWindowByName(
|
|
quota: unknown,
|
|
windowName: ResetWindowName
|
|
): { percentUsed: number | null; resetAt: string | null } | null {
|
|
return getNamedQuotaWindow(quota, windowName) || getWindowsMapQuotaWindow(quota, windowName);
|
|
}
|
|
|
|
function getResetUrgency(resetAt: string | null | undefined, windowMs: number): number {
|
|
if (!resetAt) return 0.5;
|
|
const resetTime = parseResetTimeMs(resetAt);
|
|
if (!Number.isFinite(resetTime)) return 0.5;
|
|
const msUntilReset = resetTime - Date.now();
|
|
if (msUntilReset <= 0) return 1;
|
|
return clamp01(1 - msUntilReset / windowMs);
|
|
}
|
|
|
|
function scoreQuotaWindow(
|
|
remaining: number,
|
|
resetAt: string | null | undefined,
|
|
windowMs: number,
|
|
remainingWeight: number,
|
|
resetPressureWeight: number
|
|
): number {
|
|
const normalizedRemaining = clamp01(remaining);
|
|
const resetUrgency = getResetUrgency(resetAt, windowMs);
|
|
const resetPressure = resetUrgency * (1 - normalizedRemaining);
|
|
return remainingWeight * normalizedRemaining + resetPressureWeight * resetPressure;
|
|
}
|
|
|
|
function scoreResetAwareQuota(quota: unknown, config: ReturnType<typeof resolveResetAwareConfig>) {
|
|
if (!quota || !isRecord(quota)) return { score: 0.5 };
|
|
if (quota.limitReached === true) return { score: -Infinity };
|
|
|
|
const overallPercentUsed = clamp01(finiteNumberOrNull(quota.percentUsed) ?? 0.5);
|
|
const sessionWindow = getQuotaWindow(quota, "window5h");
|
|
const weeklyWindow = getQuotaWindow(quota, "window7d") || getQuotaWindow(quota, "windowWeekly");
|
|
const sessionRemaining = clamp01(1 - (sessionWindow?.percentUsed ?? overallPercentUsed));
|
|
const weeklyRemaining = clamp01(1 - (weeklyWindow?.percentUsed ?? overallPercentUsed));
|
|
const sessionScore = scoreQuotaWindow(
|
|
sessionRemaining,
|
|
sessionWindow?.resetAt,
|
|
RESET_AWARE_SESSION_WINDOW_MS,
|
|
RESET_AWARE_SESSION_REMAINING_WEIGHT,
|
|
RESET_AWARE_SESSION_RESET_PRESSURE_WEIGHT
|
|
);
|
|
const weeklyScore = scoreQuotaWindow(
|
|
weeklyRemaining,
|
|
weeklyWindow?.resetAt ?? normalizeResetAt(quota.resetAt),
|
|
RESET_AWARE_WEEKLY_WINDOW_MS,
|
|
RESET_AWARE_WEEKLY_REMAINING_WEIGHT,
|
|
RESET_AWARE_WEEKLY_RESET_PRESSURE_WEIGHT
|
|
);
|
|
let score = config.sessionWeight * sessionScore + config.weeklyWeight * weeklyScore;
|
|
|
|
if (config.exhaustionGuard > 0 && sessionRemaining < config.exhaustionGuard) {
|
|
score *= Math.max(0.05, sessionRemaining / config.exhaustionGuard);
|
|
}
|
|
|
|
return { score };
|
|
}
|
|
|
|
async function getQuotaAwareConnectionsForTarget(
|
|
target: ResolvedComboTarget,
|
|
connectionCache: Map<string, Array<Record<string, unknown>>>,
|
|
connectionLoadPromises: Map<string, Promise<Array<Record<string, unknown>>>>,
|
|
comboName: string,
|
|
log: { warn?: (...args: unknown[]) => void }
|
|
) {
|
|
const provider = getResetAwareProvider(target);
|
|
if (!provider || !getQuotaFetcher(provider)) return [];
|
|
if (!connectionCache.has(provider)) {
|
|
const cached = resetAwareConnectionCache.get(provider);
|
|
if (cached && Date.now() - cached.fetchedAt < RESET_AWARE_CONNECTION_CACHE_TTL_MS) {
|
|
connectionCache.set(provider, cached.connections);
|
|
return cached.connections;
|
|
}
|
|
|
|
if (!connectionLoadPromises.has(provider)) {
|
|
connectionLoadPromises.set(
|
|
provider,
|
|
(async () => {
|
|
try {
|
|
const connections = await getProviderConnections({ provider, isActive: true });
|
|
const activeConnections = Array.isArray(connections)
|
|
? (connections as Array<Record<string, unknown>>)
|
|
: [];
|
|
if (
|
|
!resetAwareConnectionCache.has(provider) &&
|
|
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
|
|
) {
|
|
const oldest = resetAwareConnectionCache.keys().next().value;
|
|
if (oldest !== undefined) resetAwareConnectionCache.delete(oldest);
|
|
}
|
|
resetAwareConnectionCache.set(provider, {
|
|
connections: activeConnections,
|
|
fetchedAt: Date.now(),
|
|
});
|
|
return activeConnections;
|
|
} catch (error) {
|
|
log.warn?.("COMBO", "Reset-aware failed to load quota-aware connections.", {
|
|
comboName,
|
|
err: error,
|
|
operation: "getProviderConnections",
|
|
provider,
|
|
});
|
|
return [];
|
|
}
|
|
})()
|
|
);
|
|
}
|
|
|
|
const connections = await connectionLoadPromises.get(provider)!;
|
|
connectionCache.set(provider, connections);
|
|
}
|
|
return connectionCache.get(provider) || [];
|
|
}
|
|
|
|
function normalizeConnectionIds(value: unknown): string[] | null {
|
|
if (!Array.isArray(value)) return null;
|
|
const ids = value.filter(
|
|
(connectionId): connectionId is string =>
|
|
typeof connectionId === "string" && connectionId.trim().length > 0
|
|
);
|
|
return ids.length > 0 ? ids : null;
|
|
}
|
|
|
|
function filterAllowedConnectionIds(
|
|
connectionIds: string[],
|
|
apiKeyAllowedConnectionIds: string[] | null | undefined
|
|
): string[] {
|
|
const allowedIds = normalizeConnectionIds(apiKeyAllowedConnectionIds);
|
|
if (!allowedIds) return connectionIds;
|
|
const allowedSet = new Set(allowedIds);
|
|
return connectionIds.filter((connectionId) => allowedSet.has(connectionId));
|
|
}
|
|
|
|
function getTargetConnectionIds(
|
|
target: ResolvedComboTarget,
|
|
connections: Array<Record<string, unknown>>
|
|
): string[] {
|
|
let connectionIds: string[];
|
|
if (target.connectionId) {
|
|
return [target.connectionId];
|
|
}
|
|
|
|
if (Array.isArray(target.allowedConnectionIds) && target.allowedConnectionIds.length > 0) {
|
|
return target.allowedConnectionIds.filter(
|
|
(connectionId): connectionId is string =>
|
|
typeof connectionId === "string" && connectionId.trim().length > 0
|
|
);
|
|
}
|
|
|
|
connectionIds = connections
|
|
.map((connection) => (typeof connection.id === "string" ? connection.id : null))
|
|
.filter((connectionId): connectionId is string => !!connectionId);
|
|
return connectionIds;
|
|
}
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
items: T[],
|
|
concurrency: number,
|
|
mapper: (item: T, index: number) => Promise<R>
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(items.length);
|
|
let nextIndex = 0;
|
|
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
|
|
await Promise.all(
|
|
Array.from({ length: workerCount }, async () => {
|
|
while (nextIndex < items.length) {
|
|
const currentIndex = nextIndex++;
|
|
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
}
|
|
})
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
async function fetchResetAwareQuotaWithCache({
|
|
provider,
|
|
connectionId,
|
|
connection,
|
|
fetcher,
|
|
config,
|
|
log,
|
|
comboName,
|
|
}: {
|
|
provider: string;
|
|
connectionId: string;
|
|
connection?: Record<string, unknown>;
|
|
fetcher: (connectionId: string, connection?: Record<string, unknown>) => Promise<unknown>;
|
|
config: QuotaFetchCacheConfig;
|
|
log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void };
|
|
comboName: string;
|
|
}): Promise<unknown> {
|
|
const cacheKey = `${provider}:${connectionId}`;
|
|
const ttlMs = config.quotaCacheTtlMs;
|
|
const maxStaleMs = config.quotaCacheMaxStaleMs;
|
|
const now = Date.now();
|
|
const cached = resetAwareQuotaCache.get(cacheKey);
|
|
|
|
if (ttlMs <= 0 && maxStaleMs <= 0) {
|
|
try {
|
|
return await fetcher(connectionId, connection);
|
|
} catch (error) {
|
|
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
|
comboName,
|
|
connectionId,
|
|
err: error,
|
|
operation: "quotaFetch",
|
|
provider,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const refresh = () => {
|
|
const existing = resetAwareQuotaCache.get(cacheKey);
|
|
if (existing?.refreshPromise != null) return existing.refreshPromise;
|
|
|
|
const refreshPromise = fetcher(connectionId, connection)
|
|
.then((quota) => {
|
|
if (quota) {
|
|
if (
|
|
!resetAwareQuotaCache.has(cacheKey) &&
|
|
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
|
) {
|
|
const oldest = resetAwareQuotaCache.keys().next().value;
|
|
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
|
}
|
|
resetAwareQuotaCache.set(cacheKey, {
|
|
quota,
|
|
fetchedAt: Date.now(),
|
|
refreshPromise: null,
|
|
});
|
|
} else {
|
|
resetAwareQuotaCache.delete(cacheKey);
|
|
}
|
|
return quota;
|
|
})
|
|
.catch((error) => {
|
|
const previous = resetAwareQuotaCache.get(cacheKey);
|
|
if (previous) {
|
|
if (
|
|
!resetAwareQuotaCache.has(cacheKey) &&
|
|
resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE
|
|
) {
|
|
const oldest = resetAwareQuotaCache.keys().next().value;
|
|
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
|
}
|
|
resetAwareQuotaCache.set(cacheKey, { ...previous, refreshPromise: null });
|
|
}
|
|
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
|
comboName,
|
|
connectionId,
|
|
err: error,
|
|
operation: "quotaFetch",
|
|
provider,
|
|
});
|
|
return null;
|
|
});
|
|
|
|
if (!resetAwareQuotaCache.has(cacheKey) && resetAwareQuotaCache.size >= MAX_RESET_AWARE_CACHE) {
|
|
const oldest = resetAwareQuotaCache.keys().next().value;
|
|
if (oldest !== undefined) resetAwareQuotaCache.delete(oldest);
|
|
}
|
|
resetAwareQuotaCache.set(cacheKey, {
|
|
quota: existing?.quota ?? cached?.quota ?? null,
|
|
fetchedAt: existing?.fetchedAt ?? cached?.fetchedAt ?? 0,
|
|
refreshPromise,
|
|
});
|
|
return refreshPromise;
|
|
};
|
|
|
|
if (ttlMs > 0 && cached) {
|
|
const age = now - cached.fetchedAt;
|
|
if (age <= ttlMs) return cached.quota;
|
|
if (maxStaleMs > 0 && age <= ttlMs + maxStaleMs) {
|
|
void refresh();
|
|
return cached.quota;
|
|
}
|
|
}
|
|
|
|
return refresh();
|
|
}
|
|
|
|
type PreScreenResult = { profile: ProviderProfile | null; available: boolean };
|
|
|
|
export async function preScreenTargets(
|
|
targets: ResolvedComboTarget[],
|
|
isModelAvailable?: IsModelAvailable | null
|
|
): Promise<Map<string, PreScreenResult>> {
|
|
if (targets.length === 0) {
|
|
return new Map();
|
|
}
|
|
|
|
const results = await mapWithConcurrency(
|
|
targets,
|
|
PRE_SCREEN_CONCURRENCY,
|
|
async (target): Promise<{ key: string; result: PreScreenResult }> => {
|
|
const profile = await getRuntimeProviderProfile(target.provider).catch(() => null);
|
|
|
|
const breaker = getCircuitBreaker(target.provider);
|
|
if (breaker.getStatus().state === "OPEN") {
|
|
return { key: target.executionKey, result: { profile, available: false } };
|
|
}
|
|
|
|
let available = true;
|
|
if (isModelAvailable) {
|
|
// IsModelAvailable may return a sync boolean or a Promise; Promise.resolve
|
|
// normalizes both so the .catch() never runs against a bare boolean.
|
|
available = await Promise.resolve(isModelAvailable(target.modelStr, target)).catch(
|
|
() => true
|
|
);
|
|
}
|
|
return { key: target.executionKey, result: { profile, available } };
|
|
}
|
|
);
|
|
|
|
const map = new Map<string, PreScreenResult>();
|
|
for (const { key, result } of results) {
|
|
map.set(key, result);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
async function orderTargetsByResetAwareQuota(
|
|
targets: ResolvedComboTarget[],
|
|
comboName: string,
|
|
configSource: Record<string, unknown> | null | undefined,
|
|
log: { warn?: (...args: unknown[]) => void },
|
|
apiKeyAllowedConnectionIds?: string[] | null
|
|
) {
|
|
if (targets.length === 0) return targets;
|
|
|
|
const config = resolveResetAwareConfig(configSource);
|
|
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
|
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
|
const quotaPromises = new Map<string, Promise<unknown>>();
|
|
const connectionById = new Map<string, Record<string, unknown>>();
|
|
const expandedTargets: ResolvedComboTarget[] = [];
|
|
|
|
const targetsWithConnections = await Promise.all(
|
|
targets.map(async (target) => ({
|
|
connections: await getQuotaAwareConnectionsForTarget(
|
|
target,
|
|
connectionCache,
|
|
connectionLoadPromises,
|
|
comboName,
|
|
log
|
|
),
|
|
target,
|
|
}))
|
|
);
|
|
|
|
for (const { target, connections } of targetsWithConnections) {
|
|
for (const connection of connections) {
|
|
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
|
}
|
|
|
|
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
|
const connectionIds = filterAllowedConnectionIds(
|
|
unrestrictedConnectionIds,
|
|
apiKeyAllowedConnectionIds
|
|
);
|
|
if (connectionIds.length === 0) {
|
|
if (
|
|
unrestrictedConnectionIds.length > 0 &&
|
|
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
|
) {
|
|
continue;
|
|
}
|
|
expandedTargets.push(target);
|
|
continue;
|
|
}
|
|
|
|
for (const connectionId of connectionIds) {
|
|
expandedTargets.push({
|
|
...target,
|
|
connectionId,
|
|
executionKey:
|
|
target.connectionId === connectionId
|
|
? target.executionKey
|
|
: `${target.executionKey}@${connectionId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const scoredTargets = await mapWithConcurrency(
|
|
expandedTargets,
|
|
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
|
async (target, index) => {
|
|
let quota: unknown = null;
|
|
const provider = getResetAwareProvider(target);
|
|
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
|
if (fetcher && provider && target.connectionId) {
|
|
const quotaKey = `${provider}:${target.connectionId}`;
|
|
if (!quotaPromises.has(quotaKey)) {
|
|
quotaPromises.set(
|
|
quotaKey,
|
|
fetchResetAwareQuotaWithCache({
|
|
provider,
|
|
connectionId: target.connectionId,
|
|
connection: connectionById.get(target.connectionId),
|
|
fetcher,
|
|
config,
|
|
log,
|
|
comboName,
|
|
})
|
|
);
|
|
}
|
|
quota = await quotaPromises.get(quotaKey)!;
|
|
}
|
|
const { score } = scoreResetAwareQuota(quota, config);
|
|
return { target, score, index };
|
|
}
|
|
);
|
|
|
|
scoredTargets.sort((a, b) => {
|
|
if (b.score !== a.score) return b.score - a.score;
|
|
return a.index - b.index;
|
|
});
|
|
|
|
const bestScore = scoredTargets[0]?.score ?? 0;
|
|
const tiedTargets = scoredTargets.filter((entry) => bestScore - entry.score <= config.tieBand);
|
|
let orderedTiedTargets = tiedTargets;
|
|
if (tiedTargets.length > 1) {
|
|
const key = `reset-aware:${comboName}`;
|
|
const counter = rrCounters.get(key) || 0;
|
|
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
|
const oldest = rrCounters.keys().next().value;
|
|
if (oldest !== undefined) rrCounters.delete(oldest);
|
|
}
|
|
rrCounters.set(key, counter + 1);
|
|
const startIndex = counter % tiedTargets.length;
|
|
orderedTiedTargets = [...tiedTargets.slice(startIndex), ...tiedTargets.slice(0, startIndex)];
|
|
}
|
|
|
|
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
|
return [
|
|
...orderedTiedTargets,
|
|
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
|
].map((entry) => entry.target);
|
|
}
|
|
|
|
function getResetWindowTimestampMs(quota: unknown, windows: ResetWindowName[]): number {
|
|
if (!quota || !isRecord(quota) || quota.limitReached === true) return Infinity;
|
|
|
|
let selectedResetMs = Infinity;
|
|
for (const windowName of windows) {
|
|
const window = resolveQuotaWindowByName(quota, windowName);
|
|
const resetMs = parseResetTimeMs(window?.resetAt ?? null);
|
|
if (Number.isFinite(resetMs)) {
|
|
selectedResetMs = Math.min(selectedResetMs, resetMs);
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(selectedResetMs)) {
|
|
selectedResetMs = parseResetTimeMs(normalizeResetAt(quota.resetAt));
|
|
}
|
|
|
|
return Number.isFinite(selectedResetMs) ? selectedResetMs : Infinity;
|
|
}
|
|
|
|
function getResetWindowHorizonMs(windows: ResetWindowName[]): number {
|
|
if (windows.includes("monthly")) return 30 * 24 * 60 * 60 * 1000;
|
|
if (windows.includes("weekly")) return RESET_AWARE_WEEKLY_WINDOW_MS;
|
|
return RESET_AWARE_SESSION_WINDOW_MS;
|
|
}
|
|
|
|
function calculateResetWindowAffinity(quota: unknown, config: ResetWindowConfig): number {
|
|
const resetMs = getResetWindowTimestampMs(quota, config.windows);
|
|
if (!Number.isFinite(resetMs)) return 0.5;
|
|
|
|
const msUntilReset = resetMs - Date.now();
|
|
if (msUntilReset <= 0) return 1;
|
|
return clamp01(1 - msUntilReset / getResetWindowHorizonMs(config.windows));
|
|
}
|
|
|
|
async function orderTargetsByResetWindow(
|
|
targets: ResolvedComboTarget[],
|
|
comboName: string,
|
|
configSource: Record<string, unknown> | null | undefined,
|
|
log: { warn?: (...args: unknown[]) => void },
|
|
apiKeyAllowedConnectionIds?: string[] | null
|
|
) {
|
|
if (targets.length === 0) return targets;
|
|
|
|
const config = resolveResetWindowConfig(configSource);
|
|
const connectionCache = new Map<string, Array<Record<string, unknown>>>();
|
|
const connectionLoadPromises = new Map<string, Promise<Array<Record<string, unknown>>>>();
|
|
const quotaPromises = new Map<string, Promise<unknown>>();
|
|
const connectionById = new Map<string, Record<string, unknown>>();
|
|
const expandedTargets: ResolvedComboTarget[] = [];
|
|
|
|
const targetsWithConnections = await Promise.all(
|
|
targets.map(async (target) => ({
|
|
connections: await getQuotaAwareConnectionsForTarget(
|
|
target,
|
|
connectionCache,
|
|
connectionLoadPromises,
|
|
comboName,
|
|
log
|
|
),
|
|
target,
|
|
}))
|
|
);
|
|
|
|
for (const { target, connections } of targetsWithConnections) {
|
|
for (const connection of connections) {
|
|
if (typeof connection.id === "string") connectionById.set(connection.id, connection);
|
|
}
|
|
|
|
const unrestrictedConnectionIds = getTargetConnectionIds(target, connections);
|
|
const connectionIds = filterAllowedConnectionIds(
|
|
unrestrictedConnectionIds,
|
|
apiKeyAllowedConnectionIds
|
|
);
|
|
if (connectionIds.length === 0) {
|
|
if (
|
|
unrestrictedConnectionIds.length > 0 &&
|
|
normalizeConnectionIds(apiKeyAllowedConnectionIds)
|
|
) {
|
|
continue;
|
|
}
|
|
expandedTargets.push(target);
|
|
continue;
|
|
}
|
|
|
|
for (const connectionId of connectionIds) {
|
|
expandedTargets.push({
|
|
...target,
|
|
connectionId,
|
|
executionKey:
|
|
target.connectionId === connectionId
|
|
? target.executionKey
|
|
: `${target.executionKey}@${connectionId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const scoredTargets = await mapWithConcurrency(
|
|
expandedTargets,
|
|
RESET_AWARE_QUOTA_FETCH_CONCURRENCY,
|
|
async (target, index) => {
|
|
let quota: unknown = null;
|
|
const provider = getResetAwareProvider(target);
|
|
const fetcher = provider ? getQuotaFetcher(provider) : null;
|
|
if (fetcher && provider && target.connectionId) {
|
|
const quotaKey = `${provider}:${target.connectionId}`;
|
|
if (!quotaPromises.has(quotaKey)) {
|
|
quotaPromises.set(
|
|
quotaKey,
|
|
fetchResetAwareQuotaWithCache({
|
|
provider,
|
|
connectionId: target.connectionId,
|
|
connection: connectionById.get(target.connectionId),
|
|
fetcher,
|
|
config,
|
|
log,
|
|
comboName,
|
|
})
|
|
);
|
|
}
|
|
quota = await quotaPromises.get(quotaKey)!;
|
|
}
|
|
|
|
return {
|
|
target,
|
|
resetMs: getResetWindowTimestampMs(quota, config.windows),
|
|
index,
|
|
};
|
|
}
|
|
);
|
|
|
|
scoredTargets.sort((a, b) => {
|
|
if (a.resetMs !== b.resetMs) return a.resetMs - b.resetMs;
|
|
return a.index - b.index;
|
|
});
|
|
|
|
const bestResetMs = scoredTargets[0]?.resetMs ?? Infinity;
|
|
if (!Number.isFinite(bestResetMs) || config.tieBandMs <= 0) {
|
|
return scoredTargets.map((entry) => entry.target);
|
|
}
|
|
|
|
const tiedTargets = scoredTargets.filter(
|
|
(entry) => entry.resetMs - bestResetMs <= config.tieBandMs
|
|
);
|
|
if (tiedTargets.length <= 1) return scoredTargets.map((entry) => entry.target);
|
|
|
|
const key = `reset-window:${comboName}`;
|
|
const counter = rrCounters.get(key) || 0;
|
|
if (!rrCounters.has(key) && rrCounters.size >= MAX_RR_COUNTERS) {
|
|
const oldest = rrCounters.keys().next().value;
|
|
if (oldest !== undefined) rrCounters.delete(oldest);
|
|
}
|
|
rrCounters.set(key, counter + 1);
|
|
const startIndex = counter % tiedTargets.length;
|
|
const orderedTiedTargets = [
|
|
...tiedTargets.slice(startIndex),
|
|
...tiedTargets.slice(0, startIndex),
|
|
];
|
|
const tiedExecutionKeys = new Set(orderedTiedTargets.map((entry) => entry.target.executionKey));
|
|
|
|
return [
|
|
...orderedTiedTargets,
|
|
...scoredTargets.filter((entry) => !tiedExecutionKeys.has(entry.target.executionKey)),
|
|
].map((entry) => entry.target);
|
|
}
|
|
|
|
function toTextContent(content: unknown): string {
|
|
if (typeof content === "string") return content;
|
|
if (!Array.isArray(content)) return "";
|
|
return content
|
|
.map((part) => {
|
|
if (!isRecord(part)) return "";
|
|
if (typeof part.text === "string") return part.text;
|
|
return "";
|
|
})
|
|
.join("\n");
|
|
}
|
|
|
|
function extractPromptForIntent(body: Record<string, unknown> | null | undefined): string {
|
|
if (!body || typeof body !== "object") return "";
|
|
|
|
const fromMessages = Array.isArray(body.messages)
|
|
? [...body.messages].reverse().find((m) => isRecord(m) && m.role === "user")
|
|
: null;
|
|
if (isRecord(fromMessages)) return toTextContent(fromMessages.content);
|
|
|
|
if (typeof body.input === "string") return body.input;
|
|
if (Array.isArray(body.input)) {
|
|
const text = body.input
|
|
.map((item) => {
|
|
if (!isRecord(item)) return "";
|
|
if (typeof item.content === "string") return item.content;
|
|
if (typeof item.text === "string") return item.text;
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (text) return text;
|
|
}
|
|
|
|
if (typeof body.prompt === "string") return body.prompt;
|
|
return "";
|
|
}
|
|
|
|
function mapIntentToTaskType(intent: string): "coding" | "analysis" | "default" {
|
|
switch (intent) {
|
|
case "code":
|
|
return "coding";
|
|
case "reasoning":
|
|
return "analysis";
|
|
case "simple":
|
|
return "default";
|
|
case "medium":
|
|
default:
|
|
return "default";
|
|
}
|
|
}
|
|
|
|
function calculateTargetContextAffinity(
|
|
target: ResolvedComboTarget,
|
|
sessionId: string | null | undefined
|
|
): number {
|
|
const sessionConnectionId = getSessionConnection(sessionId || null);
|
|
if (!sessionConnectionId) return 0.5;
|
|
if (target.connectionId === sessionConnectionId) return 1;
|
|
if (!target.connectionId) return 0.5;
|
|
return 0.1;
|
|
}
|
|
|
|
function toStringArray(input: unknown): string[] {
|
|
if (Array.isArray(input)) {
|
|
return input.map((v) => (typeof v === "string" ? v.trim() : "")).filter(Boolean);
|
|
}
|
|
if (typeof input === "string") {
|
|
return input
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function getIntentConfig(
|
|
settings: Record<string, unknown> | null | undefined,
|
|
combo: ComboLike
|
|
): IntentClassifierConfig {
|
|
const resolvedSettings = settings || {};
|
|
const comboAutoConfig = combo?.autoConfig || {};
|
|
const comboConfigAuto = isRecord(combo?.config?.auto) ? combo.config.auto : {};
|
|
const comboIntentConfig =
|
|
(isRecord(comboAutoConfig.intentConfig) && comboAutoConfig.intentConfig) ||
|
|
(isRecord(comboConfigAuto.intentConfig) && comboConfigAuto.intentConfig) ||
|
|
(isRecord(combo?.config?.intentConfig) && combo.config.intentConfig) ||
|
|
{};
|
|
|
|
return {
|
|
...DEFAULT_INTENT_CONFIG,
|
|
...comboIntentConfig,
|
|
...(typeof resolvedSettings.intentDetectionEnabled === "boolean"
|
|
? { enabled: resolvedSettings.intentDetectionEnabled }
|
|
: {}),
|
|
...(Number.isFinite(Number(resolvedSettings.intentSimpleMaxWords))
|
|
? { simpleMaxWords: Number(resolvedSettings.intentSimpleMaxWords) }
|
|
: {}),
|
|
...(toStringArray(resolvedSettings.intentExtraCodeKeywords).length > 0
|
|
? { extraCodeKeywords: toStringArray(resolvedSettings.intentExtraCodeKeywords) }
|
|
: {}),
|
|
...(toStringArray(resolvedSettings.intentExtraReasoningKeywords).length > 0
|
|
? { extraReasoningKeywords: toStringArray(resolvedSettings.intentExtraReasoningKeywords) }
|
|
: {}),
|
|
...(toStringArray(resolvedSettings.intentExtraSimpleKeywords).length > 0
|
|
? { extraSimpleKeywords: toStringArray(resolvedSettings.intentExtraSimpleKeywords) }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function getBootstrapLatencyMs(modelId: string): number {
|
|
const normalized = String(modelId || "").toLowerCase();
|
|
return DEFAULT_MODEL_P95_MS[normalized] ?? 1500;
|
|
}
|
|
|
|
async function buildAutoCandidates(
|
|
targets: ResolvedComboTarget[],
|
|
comboName: string,
|
|
sessionId: string | null | undefined = null,
|
|
resetWindowConfig: ResetWindowConfig = resolveResetWindowConfig(null)
|
|
): Promise<AutoProviderCandidate[]> {
|
|
const metrics = getComboMetrics(comboName);
|
|
const { getPricingForModel } = await import("../../src/lib/localDb");
|
|
const quotaPromises = new Map<string, Promise<unknown>>();
|
|
let historicalLatencyStats: Record<string, HistoricalLatencyStatsEntry> = {};
|
|
try {
|
|
const { getModelLatencyStats } = await import("../../src/lib/usageDb");
|
|
historicalLatencyStats = await getModelLatencyStats({
|
|
windowHours: 24,
|
|
minSamples: 3,
|
|
maxRows: 10000,
|
|
});
|
|
} catch {
|
|
// keep empty stats — auto-combo will use runtime + bootstrap signals
|
|
}
|
|
|
|
const uniqueProviders = Array.from(
|
|
new Set(
|
|
targets.map((target) => target.provider || parseModel(target.modelStr).provider || "unknown")
|
|
)
|
|
);
|
|
const connectionPoolCounts = new Map<string, number>();
|
|
const connectionsByProvider = new Map<string, Array<Record<string, unknown>>>();
|
|
await Promise.all(
|
|
uniqueProviders.map(async (provider) => {
|
|
try {
|
|
const connections = await getProviderConnections({ provider, isActive: true });
|
|
const active = Array.isArray(connections) ? connections : [];
|
|
connectionPoolCounts.set(provider, active.length);
|
|
connectionsByProvider.set(provider, active);
|
|
} catch {
|
|
connectionPoolCounts.set(provider, 0);
|
|
connectionsByProvider.set(provider, []);
|
|
}
|
|
})
|
|
);
|
|
|
|
const expandedTargets: ResolvedComboTarget[] = [];
|
|
for (const target of targets) {
|
|
const provider = target.provider || parseModel(target.modelStr).provider || "unknown";
|
|
const providerConnections = connectionsByProvider.get(provider) || [];
|
|
if (target.connectionId) {
|
|
expandedTargets.push(target);
|
|
continue;
|
|
}
|
|
const connectionIds = providerConnections
|
|
.map((c) => (c && typeof c === "object" && typeof c.id === "string" ? c.id : null))
|
|
.filter((id): id is string => id !== null);
|
|
if (connectionIds.length === 0) {
|
|
expandedTargets.push(target);
|
|
continue;
|
|
}
|
|
for (const connectionId of connectionIds) {
|
|
expandedTargets.push({
|
|
...target,
|
|
connectionId,
|
|
executionKey: `${target.executionKey}@${connectionId}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
const candidates = await Promise.all(
|
|
expandedTargets.map(async (target) => {
|
|
const modelStr = target.modelStr;
|
|
const parsed = parseModel(modelStr);
|
|
const provider = target.provider || parsed.provider || parsed.providerAlias || "unknown";
|
|
const model = parsed.model || modelStr;
|
|
const historicalKey = `${provider}/${model}`;
|
|
const historicalModelMetric = historicalLatencyStats[historicalKey] || null;
|
|
const historicalTotal = Number(historicalModelMetric?.totalRequests);
|
|
const hasHistoricalSignal =
|
|
Number.isFinite(historicalTotal) && historicalTotal >= MIN_HISTORY_SAMPLES;
|
|
|
|
let costPer1MTokens = 1;
|
|
try {
|
|
const pricing = await getPricingForModel(provider, model);
|
|
const inputPrice = Number(pricing?.input);
|
|
const outputPrice = Number(pricing?.output);
|
|
if (Number.isFinite(inputPrice) && inputPrice >= 0) {
|
|
if (Number.isFinite(outputPrice) && outputPrice >= 0) {
|
|
costPer1MTokens =
|
|
inputPrice * (1 - OUTPUT_TOKEN_RATIO) + outputPrice * OUTPUT_TOKEN_RATIO;
|
|
} else {
|
|
costPer1MTokens = inputPrice;
|
|
}
|
|
}
|
|
} catch {
|
|
// keep default cost
|
|
}
|
|
|
|
const modelMetric = metrics?.byModel?.[modelStr] || null;
|
|
const avgLatency = Number(modelMetric?.avgLatencyMs);
|
|
const successRate = Number(modelMetric?.successRate);
|
|
const historicalP95Latency = Number(historicalModelMetric?.p95LatencyMs);
|
|
const historicalStdDev = Number(historicalModelMetric?.latencyStdDev);
|
|
const historicalSuccessRate = Number(historicalModelMetric?.successRate); // 0..1
|
|
|
|
const p95LatencyMs = hasHistoricalSignal
|
|
? Number.isFinite(historicalP95Latency) && historicalP95Latency > 0
|
|
? historicalP95Latency
|
|
: getBootstrapLatencyMs(model)
|
|
: Number.isFinite(avgLatency) && avgLatency > 0
|
|
? avgLatency
|
|
: getBootstrapLatencyMs(model);
|
|
|
|
const errorRate = hasHistoricalSignal
|
|
? Number.isFinite(historicalSuccessRate) &&
|
|
historicalSuccessRate >= 0 &&
|
|
historicalSuccessRate <= 1
|
|
? 1 - historicalSuccessRate
|
|
: 0.05
|
|
: Number.isFinite(successRate) && successRate >= 0 && successRate <= 100
|
|
? 1 - successRate / 100
|
|
: 0.05;
|
|
const latencyStdDev =
|
|
hasHistoricalSignal && Number.isFinite(historicalStdDev) && historicalStdDev > 0
|
|
? Math.max(10, historicalStdDev)
|
|
: Math.max(10, p95LatencyMs * 0.1);
|
|
|
|
const breakerStateRaw = getCircuitBreaker(provider)?.getStatus?.()?.state;
|
|
const circuitBreakerState: ProviderCandidate["circuitBreakerState"] =
|
|
breakerStateRaw === "OPEN" || breakerStateRaw === "HALF_OPEN" ? breakerStateRaw : "CLOSED";
|
|
const contextAffinity = calculateTargetContextAffinity(target, sessionId);
|
|
let resetWindowAffinity = 0.5;
|
|
const fetcher = getQuotaFetcher(provider);
|
|
if (fetcher && target.connectionId) {
|
|
const quotaKey = `${provider}:${target.connectionId}`;
|
|
if (!quotaPromises.has(quotaKey)) {
|
|
quotaPromises.set(
|
|
quotaKey,
|
|
fetchResetAwareQuotaWithCache({
|
|
provider,
|
|
connectionId: target.connectionId,
|
|
fetcher,
|
|
config: resetWindowConfig,
|
|
log: {},
|
|
comboName,
|
|
})
|
|
);
|
|
}
|
|
const quota = await quotaPromises.get(quotaKey)!;
|
|
resetWindowAffinity = calculateResetWindowAffinity(quota, resetWindowConfig);
|
|
}
|
|
|
|
return {
|
|
stepId: target.stepId,
|
|
executionKey: target.executionKey,
|
|
modelStr,
|
|
provider,
|
|
model,
|
|
quotaRemaining: 100,
|
|
quotaTotal: 100,
|
|
circuitBreakerState,
|
|
costPer1MTokens,
|
|
p95LatencyMs,
|
|
latencyStdDev,
|
|
errorRate,
|
|
accountTier: "standard" as const,
|
|
quotaResetIntervalSecs: 86400,
|
|
contextAffinity,
|
|
resetWindowAffinity,
|
|
connectionPoolSize: connectionPoolCounts.get(provider) ?? 1,
|
|
connectionId: target.connectionId ?? undefined,
|
|
};
|
|
})
|
|
);
|
|
|
|
return candidates;
|
|
}
|
|
|
|
function dedupeTargetsByExecutionKey(targets: ResolvedComboTarget[]) {
|
|
const seen = new Set<string>();
|
|
return targets.filter((target) => {
|
|
if (seen.has(target.executionKey)) return false;
|
|
seen.add(target.executionKey);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async function applyRequestTagRouting(
|
|
targets: ResolvedComboTarget[],
|
|
body: Record<string, unknown> | null | undefined,
|
|
log: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }
|
|
): Promise<ResolvedComboTarget[]> {
|
|
const { tags, matchMode } = resolveRequestRoutingTags(body);
|
|
if (tags.length === 0 || targets.length === 0) {
|
|
return targets;
|
|
}
|
|
|
|
const providerIds = Array.from(
|
|
new Set(targets.map((target) => target.providerId || target.provider))
|
|
).filter(
|
|
(providerId): providerId is string => typeof providerId === "string" && providerId.length > 0
|
|
);
|
|
const providerConnections = new Map<string, Array<Record<string, unknown>>>();
|
|
|
|
await Promise.all(
|
|
providerIds.map(async (providerId) => {
|
|
try {
|
|
const connections = await getProviderConnections({ provider: providerId, isActive: true });
|
|
providerConnections.set(
|
|
providerId,
|
|
Array.isArray(connections) ? (connections as Array<Record<string, unknown>>) : []
|
|
);
|
|
} catch (error) {
|
|
log.warn?.(
|
|
"COMBO",
|
|
`Tag routing failed to load connections for provider=${providerId}: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
providerConnections.set(providerId, []);
|
|
}
|
|
})
|
|
);
|
|
|
|
const filteredTargets = targets.reduce<ResolvedComboTarget[]>((acc, target) => {
|
|
const providerKey = target.providerId || target.provider;
|
|
const candidateConnections =
|
|
providerConnections.get(providerKey)?.filter((connection) => {
|
|
const connectionId =
|
|
typeof connection.id === "string" && connection.id.trim().length > 0
|
|
? connection.id
|
|
: null;
|
|
if (!connectionId) return false;
|
|
if (target.connectionId) {
|
|
return connectionId === target.connectionId;
|
|
}
|
|
return true;
|
|
}) || [];
|
|
|
|
const matchedConnectionIds = candidateConnections
|
|
.filter((connection) =>
|
|
matchesRoutingTags(
|
|
getConnectionRoutingTags(connection.providerSpecificData),
|
|
tags,
|
|
matchMode
|
|
)
|
|
)
|
|
.map((connection) => connection.id)
|
|
.filter((connectionId): connectionId is string => typeof connectionId === "string");
|
|
|
|
if (matchedConnectionIds.length === 0) {
|
|
return acc;
|
|
}
|
|
|
|
if (target.connectionId) {
|
|
acc.push(target);
|
|
return acc;
|
|
}
|
|
|
|
acc.push({
|
|
...target,
|
|
allowedConnectionIds: Array.from(new Set(matchedConnectionIds)),
|
|
});
|
|
return acc;
|
|
}, []);
|
|
|
|
if (filteredTargets.length === 0) {
|
|
log.info?.(
|
|
"COMBO",
|
|
`Tag routing matched 0/${targets.length} targets for [${tags.join(", ")}] (${matchMode}); falling back to the full target set`
|
|
);
|
|
return targets;
|
|
}
|
|
|
|
log.info?.(
|
|
"COMBO",
|
|
`Tag routing matched ${filteredTargets.length}/${targets.length} targets for [${tags.join(", ")}] (${matchMode})`
|
|
);
|
|
return filteredTargets;
|
|
}
|
|
|
|
export function resolveComboTargets(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike
|
|
): ResolvedComboTarget[] {
|
|
return allCombos ? resolveNestedComboTargets(combo, allCombos) : getDirectComboTargets(combo);
|
|
}
|
|
|
|
function resolveWeightedTargets(
|
|
combo: ComboLike,
|
|
allCombos: ComboCollectionLike
|
|
): {
|
|
orderedTargets: ResolvedComboTarget[];
|
|
selectedStep: ComboRuntimeStep | null;
|
|
} {
|
|
const topLevelSteps = getOrderedTopLevelRuntimeSteps(combo, allCombos);
|
|
if (topLevelSteps.length === 0) {
|
|
return { orderedTargets: [], selectedStep: null };
|
|
}
|
|
|
|
const selectedStep = selectWeightedTarget(topLevelSteps);
|
|
if (!selectedStep) {
|
|
return { orderedTargets: [], selectedStep: null };
|
|
}
|
|
|
|
const orderedSteps = orderTargetsForWeightedFallback(
|
|
topLevelSteps,
|
|
selectedStep.executionKey,
|
|
hasCompositeTierRuntimeOrder(combo)
|
|
);
|
|
const expandedTargets = orderedSteps.flatMap((step) => {
|
|
if (!step) return [];
|
|
if (!allCombos) {
|
|
return step.kind === "model" ? [step] : [];
|
|
}
|
|
return expandRuntimeStep(step, allCombos, new Set([combo.name]));
|
|
});
|
|
|
|
return {
|
|
orderedTargets: dedupeTargetsByExecutionKey(expandedTargets),
|
|
selectedStep,
|
|
};
|
|
}
|
|
|
|
function scoreAutoTargets(
|
|
targets: ResolvedComboTarget[],
|
|
candidates: AutoProviderCandidate[],
|
|
taskType: string | null,
|
|
weights: ScoringWeights
|
|
) {
|
|
const candidateByExecutionKey = new Map(
|
|
candidates.map((candidate: ProviderCandidate & { executionKey: string }) => [
|
|
candidate.executionKey,
|
|
candidate,
|
|
])
|
|
);
|
|
return targets
|
|
.map((target) => {
|
|
const candidate = candidateByExecutionKey.get(target.executionKey);
|
|
if (!candidate) return null;
|
|
const factors = calculateFactors(
|
|
candidate as ProviderCandidate,
|
|
candidates,
|
|
taskType ?? "general",
|
|
getTaskFitness
|
|
);
|
|
let score = calculateScore(factors, weights);
|
|
// B17: Quota Share soft-policy deprioritization
|
|
if ("quotaSoftPenalty" in candidate && candidate.quotaSoftPenalty === true) {
|
|
score *= QUOTA_SOFT_DEPRIORITIZE_FACTOR;
|
|
}
|
|
return {
|
|
target,
|
|
score,
|
|
};
|
|
})
|
|
.filter((entry): entry is { target: ResolvedComboTarget; score: number } => entry !== null)
|
|
.sort((a, b) => b.score - a.score);
|
|
}
|
|
|
|
/**
|
|
* For an auto-combo WITHOUT an explicit `candidatePool`, broaden the eligible
|
|
* targets to every model of every active provider connection so the router has
|
|
* the full pool to score over. Already-present `modelStr`s are not duplicated.
|
|
*
|
|
* Best-effort: if loading active connections or provider models throws, the
|
|
* explicitly-resolved targets are returned unchanged (the combo still runs).
|
|
* Exported for unit testing. Mutates and returns `eligibleTargets`.
|
|
*/
|
|
export async function expandAutoComboCandidatePool(
|
|
eligibleTargets: ResolvedComboTarget[],
|
|
combo: { autoConfig?: unknown; config?: unknown } | null | undefined
|
|
): Promise<ResolvedComboTarget[]> {
|
|
const localAutoConfig =
|
|
(combo?.autoConfig as Record<string, unknown> | undefined) ||
|
|
(isRecord((combo?.config as Record<string, unknown>)?.auto)
|
|
? ((combo?.config as Record<string, unknown>).auto as Record<string, unknown>)
|
|
: null) ||
|
|
(combo?.config as Record<string, unknown> | undefined) ||
|
|
{};
|
|
|
|
if (Array.isArray(localAutoConfig?.candidatePool)) return eligibleTargets;
|
|
|
|
try {
|
|
const allConnections = await getProviderConnections({ isActive: true });
|
|
const providerIds = [
|
|
...new Set(
|
|
(allConnections as Array<{ provider?: unknown }>)
|
|
.map((c) => c.provider)
|
|
.filter((p): p is string => typeof p === "string" && p.length > 0)
|
|
),
|
|
];
|
|
for (const providerId of providerIds) {
|
|
const providerModels = getProviderModels(providerId);
|
|
for (const model of providerModels) {
|
|
const modelStr = `${providerId}/${model.id}`;
|
|
if (!eligibleTargets.some((t) => t.modelStr === modelStr)) {
|
|
eligibleTargets.push({
|
|
kind: "model",
|
|
stepId: modelStr,
|
|
executionKey: modelStr,
|
|
provider: providerId,
|
|
providerId: providerId,
|
|
modelStr,
|
|
weight: 1,
|
|
connectionId: null,
|
|
label: null,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort candidate expansion only: if loading active connections or
|
|
// provider models fails, fall back to the explicitly-resolved targets
|
|
// rather than aborting the combo. The push above is the only mutation,
|
|
// so a throw leaves eligibleTargets exactly as explicit resolution built it.
|
|
}
|
|
|
|
return eligibleTargets;
|
|
}
|
|
|
|
/**
|
|
* Handle combo chat with fallback.
|
|
* @param {Object} options
|
|
* @param {Object} options.body - Request body
|
|
* @param {Object} options.combo - Full combo object { name, models, strategy, config }
|
|
* @param {Function} options.handleSingleModel - Function: (body, modelStr) => Promise<Response>
|
|
* @param {Function} [options.isModelAvailable] - Optional pre-check: (modelStr) => Promise<boolean>
|
|
* @param {Object} options.log - Logger object
|
|
* @returns {Promise<Response>}
|
|
*/
|
|
/** @param {object} options */
|
|
export async function handleComboChat({
|
|
body,
|
|
combo,
|
|
handleSingleModel,
|
|
isModelAvailable,
|
|
log,
|
|
settings,
|
|
allCombos,
|
|
relayOptions,
|
|
signal,
|
|
apiKeyAllowedConnections = null,
|
|
}: HandleComboChatOptions): Promise<Response> {
|
|
const strategy = normalizeRoutingStrategy(combo.strategy || "priority");
|
|
const relayConfig =
|
|
strategy === "context-relay" ? resolveContextRelayConfig(relayOptions?.config || null) : null;
|
|
|
|
const resilienceSettings: ResilienceSettings = settings
|
|
? resolveResilienceSettings(settings)
|
|
: resolveResilienceSettings(null);
|
|
|
|
const universalHandoffConfig = resolveUniversalHandoffConfig(
|
|
(combo.universal_handoff || combo.universalHandoff) as
|
|
| Record<string, unknown>
|
|
| null
|
|
| undefined,
|
|
relayOptions?.universalHandoffConfig as Record<string, unknown> | null | undefined
|
|
);
|
|
// ── Server-side context cache pinning (replaces <omniModel> tag roundtrip) ─
|
|
// Uses session_model_history — no client-side tag injection, no visible output pollution.
|
|
let pinnedModel: string | null = null;
|
|
if (
|
|
combo.context_cache_protection &&
|
|
relayOptions?.sessionId &&
|
|
!(body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
|
) {
|
|
const pinned = getLastSessionModel(relayOptions.sessionId, combo.name);
|
|
if (pinned) {
|
|
body = { ...body, model: pinned };
|
|
pinnedModel = pinned;
|
|
log.info("COMBO", `[#401] Context cache: pinned model=${pinned} (server-side)`);
|
|
}
|
|
}
|
|
|
|
// ── Combo Agent Middleware (#399 + #401) ────────────────────────────────
|
|
// Apply system_message override, tool_filter_regex.
|
|
// Context cache pinning is handled above via session_model_history.
|
|
const { body: agentBody } = applyComboAgentMiddleware(
|
|
body,
|
|
combo,
|
|
"" // provider/model not yet known — resolved per-model in loop
|
|
);
|
|
body = agentBody;
|
|
const clientRequestedStream = body?.stream === true;
|
|
// Context cache pinning is handled above via server-side session_model_history.
|
|
// No tag injection on response — use handleSingleModel directly.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// Use config cascade before dispatch so all strategies, pinned context routes,
|
|
// and round-robin targets share the same timeout policy.
|
|
const config = settings
|
|
? resolveComboConfig(combo, settings)
|
|
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
|
|
const comboTargetTimeoutMs = resolveComboTargetTimeoutMs(config, FETCH_TIMEOUT_MS);
|
|
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
|
|
|
|
// ── Per-model timeout wrapper ────────────────────────────────────────────
|
|
// Combo target timeouts inherit FETCH_TIMEOUT_MS by default. Operators can
|
|
// configure targetTimeoutMs to shorten fallback latency, but never to extend
|
|
// beyond the current upstream request timeout.
|
|
//
|
|
// The timeoutController is forwarded to the inner caller via target.modelAbortSignal.
|
|
// When the timeout fires we (a) resolve the race with a synthetic 524 and
|
|
// (b) abort the inner request so its upstream fetch is cancelled and downstream
|
|
// cooldown/breaker/usage mutations stop — preventing "ghost" state mutations
|
|
// that diverge from the routing decision the operator sees.
|
|
const handleSingleModelWithTimeout = async (
|
|
b: Record<string, unknown>,
|
|
modelStr: string,
|
|
target?: SingleModelTarget
|
|
): Promise<Response> => {
|
|
if (comboTargetTimeoutMs <= 0) {
|
|
return handleSingleModel(b, modelStr, target).catch((err) =>
|
|
errorResponse(502, err?.message ?? "Upstream model error")
|
|
);
|
|
}
|
|
|
|
const timeoutController = new AbortController();
|
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
let timedOut = false;
|
|
const timeoutPromise = new Promise<Response>((resolve) => {
|
|
timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
log.warn(
|
|
"COMBO",
|
|
`Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back`
|
|
);
|
|
// Abort the inner request so its upstream fetch is cancelled and
|
|
// downstream cooldown/breaker/usage mutations don't continue mutating
|
|
// state behind the routing decision's back.
|
|
timeoutController.abort(new Error("combo-per-model-timeout"));
|
|
resolve(
|
|
new Response(JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), {
|
|
status: 524,
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
}, comboTargetTimeoutMs);
|
|
});
|
|
const targetWithSignal = {
|
|
...(target ?? {}),
|
|
modelAbortSignal: timeoutController.signal,
|
|
};
|
|
if (target?.modelAbortSignal) {
|
|
if (target.modelAbortSignal.aborted) {
|
|
timeoutController.abort(new Error("hedge-cancelled"));
|
|
} else {
|
|
target.modelAbortSignal.addEventListener("abort", () => {
|
|
timeoutController.abort(new Error("hedge-cancelled"));
|
|
});
|
|
}
|
|
}
|
|
try {
|
|
return await Promise.race([
|
|
handleSingleModel(b, modelStr, targetWithSignal).catch((err) => {
|
|
if (timedOut) {
|
|
// Inner call rejected because we aborted it. The synthetic 524 from
|
|
// timeoutPromise already wins the race; return an empty response so
|
|
// the loser branch resolves cleanly without leaking err.message.
|
|
return new Response(null, { status: 599 });
|
|
}
|
|
return errorResponse(502, err?.message ?? "Upstream model error");
|
|
}),
|
|
timeoutPromise,
|
|
]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
|
|
// Route to pinned model if context caching specifies one (Fix #679)
|
|
if (pinnedModel) {
|
|
log.info(
|
|
"COMBO",
|
|
`Bypassing strategy — routing directly to pinned context model: ${pinnedModel}`
|
|
);
|
|
return handleSingleModelWithTimeout(body, pinnedModel);
|
|
}
|
|
|
|
// Route to round-robin handler if strategy matches
|
|
if (strategy === "round-robin") {
|
|
return handleRoundRobinCombo({
|
|
body,
|
|
combo,
|
|
handleSingleModel: handleSingleModelWithTimeout,
|
|
isModelAvailable,
|
|
log,
|
|
settings,
|
|
allCombos,
|
|
signal,
|
|
});
|
|
}
|
|
|
|
const maxRetries = config.maxRetries ?? 1;
|
|
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
|
|
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
|
|
const maxSetRetries = config.maxSetRetries ?? 0;
|
|
const setRetryDelayMs = resolveDelayMs(config.setRetryDelayMs, 2000);
|
|
|
|
let orderedTargets =
|
|
strategy === "weighted"
|
|
? resolveWeightedTargets(combo, allCombos)?.orderedTargets || []
|
|
: resolveComboTargets(combo, allCombos);
|
|
|
|
orderedTargets = await applyRequestTagRouting(orderedTargets, body, log);
|
|
|
|
if (strategy === "weighted") {
|
|
log.info(
|
|
"COMBO",
|
|
`Weighted selection${allCombos ? " with nested resolution" : ""}: ${orderedTargets.length} total targets`
|
|
);
|
|
} else if (allCombos) {
|
|
log.info("COMBO", `${strategy} with nested resolution: ${orderedTargets.length} total targets`);
|
|
}
|
|
|
|
// Pipeline dispatch: route smart/pipeline-enabled combos through the multi-stage pipeline
|
|
if (strategy === "auto") {
|
|
const autoParsed = parseAutoPrefix(combo.name);
|
|
const autoVariant = autoParsed.valid ? autoParsed.variant : undefined;
|
|
if (autoVariant === "smart" || config.pipeline_enabled) {
|
|
try {
|
|
const pipelineRaw = await handlePipelineCombo({
|
|
body,
|
|
combo,
|
|
handleChatCore: handleSingleModelWithTimeout,
|
|
log: {
|
|
info: log.info,
|
|
warn: log.warn,
|
|
error: log.error ?? log.warn,
|
|
},
|
|
settings: settings ?? {},
|
|
signal: signal ?? undefined,
|
|
});
|
|
// handlePipelineCombo resolves to a PipelineResult (buffered text) or,
|
|
// in the streaming-final-stage case, a Response. Callers downstream
|
|
// (chat.ts → withSessionHeader) require a Response, so adapt the
|
|
// PipelineResult here instead of leaking the raw object.
|
|
return pipelineRaw instanceof Response
|
|
? pipelineRaw
|
|
: buildPipelineResponse(pipelineRaw, body);
|
|
} catch (pipelineErr) {
|
|
const pipelineMsg = pipelineErr instanceof Error ? pipelineErr.message : "";
|
|
if (pipelineMsg === "PIPELINE_DISABLED") {
|
|
log.info("COMBO", "Pipeline disabled, falling through to standard auto routing");
|
|
} else if (pipelineMsg === "PIPELINE_TOKEN_THRESHOLD") {
|
|
log.info(
|
|
"COMBO",
|
|
"Pipeline skipped (prompt below token threshold), falling through to standard auto routing"
|
|
);
|
|
} else {
|
|
log.warn("COMBO", "Pipeline dispatch failed, falling through to standard auto routing", {
|
|
err: pipelineErr,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (strategy === "auto") {
|
|
const requestHasTools = Array.isArray(body?.tools) && body.tools.length > 0;
|
|
let eligibleTargets = [...orderedTargets];
|
|
|
|
if (requestHasTools) {
|
|
const filtered = eligibleTargets.filter((target) => supportsToolCalling(target.modelStr));
|
|
if (filtered.length > 0) {
|
|
eligibleTargets = filtered;
|
|
} else {
|
|
log.warn(
|
|
"COMBO",
|
|
"Auto strategy: all candidates filtered by tool-calling policy, falling back to full pool"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Context-window pre-filter (#1808)
|
|
// Estimate input tokens once; exclude candidates whose known context limit is too small.
|
|
// Uses the same 4-chars-per-token heuristic as contextManager.ts::compressContext().
|
|
// Null/unknown limits are treated as "include" to avoid incorrectly dropping valid targets.
|
|
const requestMessages = body.messages;
|
|
const estimatedInputTokens = estimateTokens(
|
|
typeof requestMessages === "string" ||
|
|
(requestMessages !== null && typeof requestMessages === "object")
|
|
? requestMessages
|
|
: []
|
|
);
|
|
if (estimatedInputTokens > 0) {
|
|
const filteredByContext = eligibleTargets.filter((target) => {
|
|
const limit = getModelContextLimitForModelString(target.modelStr);
|
|
if (limit === null || limit === undefined) return true; // unknown — include to be safe
|
|
return limit >= estimatedInputTokens;
|
|
});
|
|
if (filteredByContext.length > 0) {
|
|
log.debug?.(
|
|
"COMBO",
|
|
`Auto strategy: context-window filter kept ${filteredByContext.length}/${eligibleTargets.length} candidates (est. ${estimatedInputTokens} tokens)`
|
|
);
|
|
eligibleTargets = filteredByContext;
|
|
} else {
|
|
log.warn(
|
|
"COMBO",
|
|
`Auto strategy: all candidates filtered by context-window policy (est. ${estimatedInputTokens} tokens), falling back to full pool`
|
|
);
|
|
// eligibleTargets intentionally unchanged — same fallback contract as tool-calling filter
|
|
}
|
|
|
|
eligibleTargets = await expandAutoComboCandidatePool(eligibleTargets, combo);
|
|
}
|
|
|
|
const prompt = extractPromptForIntent(body);
|
|
const systemPrompt =
|
|
typeof combo?.system_message === "string" ? combo.system_message : undefined;
|
|
const intentConfig = getIntentConfig(settings, combo);
|
|
const intent = classifyWithConfig(prompt, intentConfig, systemPrompt);
|
|
recordComboIntent(combo.name, intent);
|
|
const taskType = mapIntentToTaskType(intent);
|
|
|
|
const rawAutoConfigSource =
|
|
combo?.autoConfig ||
|
|
(isRecord(combo?.config?.auto) ? combo.config.auto : null) ||
|
|
combo?.config ||
|
|
{};
|
|
const autoConfigSource: Record<string, unknown> = isRecord(rawAutoConfigSource)
|
|
? rawAutoConfigSource
|
|
: {};
|
|
const routingStrategy =
|
|
typeof autoConfigSource.routerStrategy === "string"
|
|
? autoConfigSource.routerStrategy
|
|
: typeof autoConfigSource.routingStrategy === "string"
|
|
? autoConfigSource.routingStrategy
|
|
: typeof autoConfigSource.strategyName === "string"
|
|
? autoConfigSource.strategyName
|
|
: "rules";
|
|
|
|
const candidatePool = Array.isArray(autoConfigSource.candidatePool)
|
|
? autoConfigSource.candidatePool
|
|
: [...new Set(eligibleTargets.map((target) => target.provider))];
|
|
|
|
const weights =
|
|
autoConfigSource.weights && typeof autoConfigSource.weights === "object"
|
|
? (autoConfigSource.weights as ScoringWeights)
|
|
: DEFAULT_WEIGHTS;
|
|
const explorationRate = Number.isFinite(Number(autoConfigSource.explorationRate))
|
|
? Number(autoConfigSource.explorationRate)
|
|
: 0.05;
|
|
const budgetCap = Number.isFinite(Number(autoConfigSource.budgetCap))
|
|
? Number(autoConfigSource.budgetCap)
|
|
: undefined;
|
|
const modePack =
|
|
typeof autoConfigSource.modePack === "string" ? autoConfigSource.modePack : undefined;
|
|
const resetWindowConfig = resolveResetWindowConfig(autoConfigSource);
|
|
const slaPolicy = resolveSlaRoutingPolicy(autoConfigSource);
|
|
|
|
let lastKnownGoodProvider: string | undefined;
|
|
try {
|
|
const { getLKGP } = await import("../../src/lib/localDb");
|
|
const lkgp = await getLKGP(combo.name, combo.id || combo.name);
|
|
if (lkgp) lastKnownGoodProvider = lkgp.provider;
|
|
} catch (err) {
|
|
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
|
}
|
|
|
|
const candidates = await buildAutoCandidates(
|
|
eligibleTargets,
|
|
combo.name,
|
|
relayOptions?.sessionId,
|
|
resetWindowConfig
|
|
);
|
|
// G2: Register candidates so chatCore can mark quotaSoftPenalty via setCandidateQuotaSoftPenalty.
|
|
_registerExecutionCandidates(candidates);
|
|
if (candidates.length > 0) {
|
|
let selectedProvider: string | null = null;
|
|
let selectedModel: string | null = null;
|
|
let selectionReason = "";
|
|
|
|
if (routingStrategy !== "rules") {
|
|
try {
|
|
const decision = selectWithStrategy(
|
|
candidates,
|
|
{
|
|
taskType,
|
|
requestHasTools,
|
|
lastKnownGoodProvider,
|
|
estimatedInputTokens,
|
|
sla: slaPolicy,
|
|
},
|
|
routingStrategy
|
|
);
|
|
selectedProvider = decision.provider;
|
|
selectedModel = decision.model;
|
|
selectionReason = decision.reason;
|
|
} catch (err) {
|
|
log.warn(
|
|
"COMBO",
|
|
`Auto strategy '${routingStrategy}' failed (${err?.message || "unknown"}), falling back to rules`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!selectedProvider || !selectedModel) {
|
|
const selection = selectAutoProvider(
|
|
{
|
|
id: combo.id || combo.name,
|
|
name: combo.name,
|
|
type: "auto",
|
|
candidatePool,
|
|
weights,
|
|
modePack,
|
|
budgetCap,
|
|
explorationRate,
|
|
},
|
|
candidates,
|
|
taskType
|
|
);
|
|
selectedProvider = selection.provider;
|
|
selectedModel = selection.model;
|
|
selectionReason = `score=${selection.score.toFixed(3)}${selection.isExploration ? " (exploration)" : ""}`;
|
|
}
|
|
|
|
const scoredTargets = scoreAutoTargets(eligibleTargets, candidates, taskType, weights);
|
|
const rankedTargets = scoredTargets.map((entry) => entry.target);
|
|
const selectedTarget =
|
|
scoredTargets.find((entry) => {
|
|
const parsed = parseModel(entry.target.modelStr);
|
|
const modelId = parsed.model || entry.target.modelStr;
|
|
return entry.target.provider === selectedProvider && modelId === selectedModel;
|
|
})?.target ||
|
|
rankedTargets[0] ||
|
|
eligibleTargets[0];
|
|
|
|
orderedTargets = dedupeTargetsByExecutionKey(
|
|
[selectedTarget, ...rankedTargets, ...eligibleTargets].filter(
|
|
(entry): entry is ResolvedComboTarget => entry !== undefined && entry !== null
|
|
)
|
|
);
|
|
|
|
log.info(
|
|
"COMBO",
|
|
`Auto selection: ${selectedTarget?.modelStr || `${selectedProvider}/${selectedModel}`} | intent=${intent} task=${taskType} | strategy=${routingStrategy} | ${selectionReason}`
|
|
);
|
|
} else {
|
|
log.warn("COMBO", "Auto strategy has no candidates, keeping default ordering");
|
|
}
|
|
} else if (strategy === "lkgp") {
|
|
try {
|
|
const { getLKGP } = await import("../../src/lib/localDb");
|
|
const lkgpProvider = await getLKGP(combo.name, combo.id || combo.name);
|
|
|
|
if (lkgpProvider) {
|
|
const lkgpRecord = lkgpProvider;
|
|
const providerName = lkgpRecord.provider;
|
|
const connId = lkgpRecord.connectionId;
|
|
|
|
let lkgpIndex = -1;
|
|
if (connId) {
|
|
lkgpIndex = orderedTargets.findIndex(
|
|
(target) => target.provider === providerName && target.connectionId === connId
|
|
);
|
|
}
|
|
if (lkgpIndex < 0) {
|
|
lkgpIndex = orderedTargets.findIndex(
|
|
(target) =>
|
|
target.provider === providerName ||
|
|
// Issue #2359: Defensive guard. The `target.modelStr` type
|
|
// annotation is `string`, but malformed combo entries (e.g.,
|
|
// local-provider rows whose `modelStr` failed to resolve when
|
|
// the executor catalogue was being rebuilt) have leaked
|
|
// through and surfaced as `e.startsWith is not a function`
|
|
// 500s on combo test/dispatch. The fast path stays
|
|
// unchanged for the common case; this only avoids the
|
|
// crash when the field is unexpectedly non-string.
|
|
(typeof target.modelStr === "string" &&
|
|
target.modelStr.startsWith(`${providerName}/`))
|
|
);
|
|
}
|
|
|
|
if (lkgpIndex > 0) {
|
|
const [lkgpTarget] = orderedTargets.splice(lkgpIndex, 1);
|
|
orderedTargets.unshift(lkgpTarget);
|
|
log.info(
|
|
"COMBO",
|
|
`[LKGP] Prioritizing last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} for combo "${combo.name}"`
|
|
);
|
|
} else if (lkgpIndex === 0) {
|
|
log.debug?.(
|
|
"COMBO",
|
|
`[LKGP] Last known good provider ${providerName}${connId ? ` (account ${connId})` : ""} already first for combo "${combo.name}"`
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log.warn("COMBO", "Failed to retrieve Last Known Good Provider. This is non-fatal.", { err });
|
|
}
|
|
} else if (strategy === "strict-random") {
|
|
const selectedExecutionKey = await getNextFromDeck(
|
|
`combo:${combo.name}`,
|
|
orderedTargets.map((target) => target.executionKey)
|
|
);
|
|
const selectedTarget =
|
|
orderedTargets.find((target) => target.executionKey === selectedExecutionKey) || null;
|
|
const rest = orderedTargets.filter((target) => target.executionKey !== selectedExecutionKey);
|
|
orderedTargets = [selectedTarget, ...rest].filter(
|
|
(target): target is ResolvedComboTarget => target !== null
|
|
);
|
|
log.info(
|
|
"COMBO",
|
|
`Strict-random deck: ${selectedExecutionKey} selected (${orderedTargets.length} targets)`
|
|
);
|
|
} else if (strategy === "random") {
|
|
orderedTargets = fisherYatesShuffle([...orderedTargets]);
|
|
log.info("COMBO", `Random shuffle: ${orderedTargets.length} targets`);
|
|
} else if (strategy === "fill-first") {
|
|
log.info(
|
|
"COMBO",
|
|
`Fill-first ordering: preserving priority order (${orderedTargets.length} targets)`
|
|
);
|
|
} else if (strategy === "p2c") {
|
|
orderedTargets = orderTargetsByPowerOfTwoChoices(orderedTargets, combo.name);
|
|
log.info("COMBO", `Power-of-two-choices ordering: selected ${orderedTargets[0]?.modelStr}`);
|
|
} else if (strategy === "least-used") {
|
|
orderedTargets = sortTargetsByUsage(orderedTargets, combo.name);
|
|
log.info("COMBO", `Least-used ordering: ${orderedTargets[0]?.modelStr} has fewest requests`);
|
|
} else if (strategy === "cost-optimized") {
|
|
orderedTargets = await sortTargetsByCost(orderedTargets);
|
|
if (config.manifestRouting === true) {
|
|
try {
|
|
const manifestHint = generateRoutingHints(
|
|
orderedTargets.filter((t) => t.kind === "model"),
|
|
{
|
|
messages: Array.isArray(body?.messages)
|
|
? (body.messages as Array<{ role?: string; content?: string | unknown }>)
|
|
: [],
|
|
tools: Array.isArray(body?.tools)
|
|
? (body.tools as Array<{
|
|
function?: { name: string; description?: string; parameters?: unknown };
|
|
}>)
|
|
: undefined,
|
|
model: typeof body?.model === "string" ? body.model : undefined,
|
|
}
|
|
);
|
|
if (manifestHint.strategyModifier === "require-premium") {
|
|
const eligible = orderedTargets.filter(
|
|
(t) =>
|
|
t.kind !== "model" ||
|
|
manifestHint.eligibleTargets.some(
|
|
(e) => e.provider === t.provider && e.modelStr === t.modelStr
|
|
)
|
|
);
|
|
if (eligible.length > 0) orderedTargets = eligible;
|
|
}
|
|
log.debug?.(
|
|
{
|
|
strategyModifier: manifestHint.strategyModifier,
|
|
specificityLevel: manifestHint.specificityLevel,
|
|
score: manifestHint.specificity.score,
|
|
},
|
|
"manifest routing applied"
|
|
);
|
|
} catch (err) {
|
|
log.warn({ err }, "manifest routing failed, falling back to standard strategy");
|
|
}
|
|
}
|
|
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedTargets[0]?.modelStr})`);
|
|
} else if (strategy === "reset-aware") {
|
|
orderedTargets = await orderTargetsByResetAwareQuota(
|
|
orderedTargets,
|
|
combo.name,
|
|
config,
|
|
log,
|
|
apiKeyAllowedConnections
|
|
);
|
|
log.info(
|
|
"COMBO",
|
|
`Reset-aware ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
|
);
|
|
} else if (strategy === "reset-window") {
|
|
orderedTargets = await orderTargetsByResetWindow(
|
|
orderedTargets,
|
|
combo.name,
|
|
config,
|
|
log,
|
|
apiKeyAllowedConnections
|
|
);
|
|
log.info(
|
|
"COMBO",
|
|
`Reset-window ordering: ${orderedTargets[0]?.modelStr}${orderedTargets[0]?.connectionId ? ` (${orderedTargets[0].connectionId})` : ""} first`
|
|
);
|
|
} else if (strategy === "context-optimized") {
|
|
orderedTargets = sortTargetsByContextSize(orderedTargets);
|
|
log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`);
|
|
}
|
|
|
|
orderedTargets = orderTargetsByEvalScores(orderedTargets, config.evalRouting, log);
|
|
orderedTargets = filterTargetsByRequestCompatibility(orderedTargets, body, log);
|
|
|
|
// Parallel pre-screen: check provider profiles and model availability for all targets
|
|
// Only runs for priority strategy where sequential checking causes latency
|
|
const preScreenMap =
|
|
strategy === "priority"
|
|
? await preScreenTargets(orderedTargets, isModelAvailable).catch(
|
|
() => new Map<string, PreScreenResult>()
|
|
)
|
|
: new Map<string, PreScreenResult>();
|
|
|
|
if (orderedTargets.length === 0) {
|
|
return comboModelNotFoundResponse("Combo has no executable targets");
|
|
}
|
|
|
|
scheduleShadowRouting(
|
|
combo,
|
|
config,
|
|
body,
|
|
resolveShadowTargets(combo, config, allCombos),
|
|
handleSingleModel,
|
|
isModelAvailable,
|
|
strategy,
|
|
log
|
|
);
|
|
|
|
// G2: Collect execution keys registered by _registerExecutionCandidates above (auto strategy).
|
|
// We snapshot them now so cleanup can happen after the attempt loop finishes.
|
|
const _registeredExecutionKeys = orderedTargets.map((t) => t.executionKey).filter(Boolean);
|
|
|
|
let globalAttempts = 0;
|
|
|
|
try {
|
|
for (let setTry = 0; setTry <= maxSetRetries; setTry++) {
|
|
// #1731: Per-set-iteration set of providers whose quota is fully exhausted.
|
|
// Reset each retry so providers excluded in a previous attempt get another chance.
|
|
const exhaustedProviders = new Set<string>();
|
|
const exhaustedConnections = new Set<string>();
|
|
const transientRateLimitedProviders = new Set<string>();
|
|
if (setTry > 0) {
|
|
log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`);
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, setRetryDelayMs);
|
|
signal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve(undefined);
|
|
},
|
|
{ once: true }
|
|
);
|
|
});
|
|
if (signal?.aborted) {
|
|
log.info("COMBO", "Client disconnected during set retry delay — aborting");
|
|
return errorResponse(499, "Client disconnected");
|
|
}
|
|
}
|
|
|
|
let lastError: string | null = null;
|
|
let earliestRetryAfter: ComboRetryAfter | null = null;
|
|
let lastStatus: number | null = null;
|
|
const startTime = Date.now();
|
|
let fallbackCount = 0;
|
|
let recordedAttempts = 0;
|
|
|
|
let globalResolve: ((res: Response) => void) | null = null;
|
|
const globalPromise = new Promise<Response>((res) => {
|
|
globalResolve = res;
|
|
});
|
|
const runningTasks = new Set<Promise<void>>();
|
|
let anySuccess = false;
|
|
const abortControllers = new Map<number, AbortController>();
|
|
const zeroLatencyOptimizationsEnabled = config.zeroLatencyOptimizationsEnabled === true;
|
|
|
|
const executeTarget = async (
|
|
i: number
|
|
): Promise<{ ok: boolean; response?: Response } | null> => {
|
|
const target = orderedTargets[i];
|
|
const modelStr = target.modelStr;
|
|
const rawModel = parseModel(modelStr).model || modelStr;
|
|
const provider = target.provider;
|
|
|
|
const cb = getCircuitBreaker(provider);
|
|
if (cb.getStatus().state === "OPEN") {
|
|
log.info("COMBO", `Skipping ${modelStr} — circuit breaker OPEN for ${provider}`);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
resilienceSettings.providerCooldown.enabled &&
|
|
Boolean(provider && provider !== "unknown") &&
|
|
isProviderInCooldown(provider, target.connectionId ?? undefined, resilienceSettings)
|
|
) {
|
|
log.info("COMBO", `Skipping ${modelStr} — provider ${provider} in global cooldown`);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
|
|
// Use pre-screened profile if available, otherwise fetch on demand
|
|
const preScreenEntry = preScreenMap.get(target.executionKey);
|
|
const profile = preScreenEntry?.profile ?? (await getRuntimeProviderProfile(provider));
|
|
|
|
const allowRateLimitedConnection =
|
|
Boolean(provider && provider !== "unknown") &&
|
|
transientRateLimitedProviders.has(provider);
|
|
const targetForAttempt = allowRateLimitedConnection
|
|
? {
|
|
...target,
|
|
allowRateLimitedConnection: true,
|
|
modelAbortSignal: abortControllers.get(i)!.signal,
|
|
}
|
|
: { ...target, modelAbortSignal: abortControllers.get(i)!.signal };
|
|
|
|
// #1731v2: Skip targets whose provider:connection pair had a connection-level error.
|
|
if (provider && target.connectionId) {
|
|
const connKey = `${provider}:${target.connectionId}`;
|
|
if (exhaustedConnections.has(connKey)) {
|
|
log.info(
|
|
"COMBO",
|
|
`Skipping ${modelStr} — connection ${target.connectionId} for provider ${provider} had connection error (#1731v2)`
|
|
);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
}
|
|
// #1731: Skip targets from a provider that already signaled full quota exhaustion this request.
|
|
if (provider && exhaustedProviders.has(provider)) {
|
|
log.info(
|
|
"COMBO",
|
|
`Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`
|
|
);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
|
|
// Pre-check: skip models locked by the resilience system (model-level lockout)
|
|
if (provider && rawModel && isModelLocked(provider, target.connectionId || "", rawModel)) {
|
|
log.info("COMBO", `Skipping ${modelStr} — model locked by resilience (cooldown active)`);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
|
|
// Pre-screen may have already determined this target unavailable (e.g.
|
|
// circuit-breaker OPEN at resolve time). Skip immediately in that case.
|
|
// For targets pre-screened as "available" we still call isModelAvailable
|
|
// below because connection cooldowns (rateLimitedUntil) can change
|
|
// mid-request after a same-provider failure — the pre-screen snapshot is
|
|
// stale by the time we reach the 2nd/3rd same-provider target.
|
|
const preCheckedAvailable = preScreenEntry?.available ?? null;
|
|
if (preCheckedAvailable === false) {
|
|
log.info("COMBO", `Skipping ${modelStr} — pre-screen marked unavailable`);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
if (isModelAvailable) {
|
|
const available = await isModelAvailable(modelStr, targetForAttempt);
|
|
if (!available) {
|
|
log.debug?.(
|
|
"COMBO",
|
|
`Skipping ${modelStr} — no credentials available or model excluded`
|
|
);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Credential gate: skip targets with known-bad credentials (fail-fast)
|
|
const connectionId = target.connectionId as string | undefined;
|
|
if (connectionId) {
|
|
const gateResult = checkCredentialGate(connectionId, provider, modelStr);
|
|
if (gateResult.allowed === false) {
|
|
logCredentialSkip(log, modelStr, gateResult.reason || "Credential gate blocked");
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Retry loop for transient errors
|
|
for (let retry = 0; retry <= maxRetries; retry++) {
|
|
// Fix #1681: Bail out immediately if the client has disconnected
|
|
if (signal?.aborted) {
|
|
log.info("COMBO", `Client disconnected — aborting combo loop before model ${modelStr}`);
|
|
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
|
}
|
|
globalAttempts++;
|
|
if (globalAttempts > MAX_GLOBAL_ATTEMPTS) {
|
|
log.warn(
|
|
"COMBO",
|
|
`Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded across all targets and fallbacks. Terminating loop to prevent runaway background requests.`
|
|
);
|
|
return { ok: false, response: errorResponse(503, "Maximum combo retry limit reached") };
|
|
}
|
|
|
|
// Predictive TTFT Circuit Breaker (skip slow models)
|
|
if (
|
|
zeroLatencyOptimizationsEnabled &&
|
|
config.predictiveTtftMs &&
|
|
config.predictiveTtftMs > 0 &&
|
|
retry === 0
|
|
) {
|
|
const cMetrics = getComboMetrics(combo.name);
|
|
if (cMetrics) {
|
|
const targetKey = orderedTargets[i].executionKey || modelStr;
|
|
const m = cMetrics.byTarget[targetKey] || cMetrics.byModel[modelStr];
|
|
if (m && m.requests >= 5 && m.avgLatencyMs > config.predictiveTtftMs) {
|
|
log.warn(
|
|
"COMBO",
|
|
`Predictive TTFT Circuit Breaker: skipping ${modelStr} (avg ${m.avgLatencyMs}ms > max ${config.predictiveTtftMs}ms)`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (retry > 0) {
|
|
log.info(
|
|
"COMBO",
|
|
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
|
|
);
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, retryDelayMs);
|
|
signal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve(undefined);
|
|
},
|
|
{ once: true }
|
|
);
|
|
});
|
|
if (signal?.aborted) {
|
|
log.info("COMBO", `Client disconnected during retry delay — aborting`);
|
|
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
|
}
|
|
}
|
|
|
|
log.info(
|
|
"COMBO",
|
|
`Trying model ${i + 1}/${orderedTargets.length}: ${modelStr}${retry > 0 ? ` (retry ${retry})` : ""}`
|
|
);
|
|
emit("combo.target.attempt", {
|
|
comboName: combo.name,
|
|
targetIndex: i,
|
|
provider,
|
|
model: modelStr,
|
|
timestamp: Date.now(),
|
|
strategy,
|
|
});
|
|
|
|
// Deep clone the body to ensure context preservation and prevent mutations
|
|
// from affecting other targets in the combo
|
|
let attemptBody = JSON.parse(JSON.stringify(body));
|
|
|
|
// Proactive Context Compression for fallbacks (Zero-Latency optimization)
|
|
if (
|
|
zeroLatencyOptimizationsEnabled &&
|
|
i > 0 &&
|
|
config.fallbackCompressionMode &&
|
|
config.fallbackCompressionMode !== "off"
|
|
) {
|
|
const { estimateTokens } = await import("./contextManager.ts");
|
|
const estimatedTokens = estimateTokens(JSON.stringify(attemptBody));
|
|
if (estimatedTokens > (config.fallbackCompressionThreshold ?? 1000)) {
|
|
const { applyCompression } = await import("./compression/strategySelector.ts");
|
|
const compressionResult = applyCompression(
|
|
attemptBody,
|
|
config.fallbackCompressionMode as CompressionMode,
|
|
{ model: modelStr }
|
|
);
|
|
if (compressionResult.compressed) {
|
|
log.info(
|
|
"COMBO",
|
|
`Proactive fallback compression applied (${config.fallbackCompressionMode}): ${estimatedTokens} -> ${compressionResult.stats?.compressedTokens} tokens`
|
|
);
|
|
attemptBody = compressionResult.body;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Universal handoff: inject existing handoff if model changed
|
|
if (
|
|
universalHandoffConfig.enabled &&
|
|
relayOptions?.sessionId &&
|
|
!(body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
|
) {
|
|
const lastModel = getLastSessionModel(relayOptions.sessionId, combo.name);
|
|
if (lastModel && lastModel !== modelStr) {
|
|
const existingHandoff = getHandoff(relayOptions.sessionId, combo.name);
|
|
attemptBody = injectUniversalHandoffBody(
|
|
attemptBody, // Use the cloned body to maintain isolation
|
|
lastModel,
|
|
modelStr,
|
|
`Model routing: ${lastModel} → ${modelStr}`,
|
|
existingHandoff
|
|
);
|
|
}
|
|
}
|
|
|
|
// Issue #3587: Reasoning models can spend the whole output budget on
|
|
// reasoning. Only add headroom when the complete buffer fits inside the
|
|
// model's known output cap; otherwise preserve the client's explicit limit.
|
|
{
|
|
const bodyRecord = attemptBody as Record<string, unknown>;
|
|
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
|
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
|
|
modelStr,
|
|
bodyRecord.max_tokens,
|
|
{ enabled: reasoningTokenBufferEnabled }
|
|
);
|
|
if (currentMaxTokens !== null && bufferedMaxTokens !== null) {
|
|
bodyRecord.max_tokens = bufferedMaxTokens;
|
|
if (bufferedMaxTokens !== currentMaxTokens) {
|
|
log.info(
|
|
"COMBO",
|
|
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
|
|
...targetForAttempt,
|
|
failoverBeforeRetry: config.failoverBeforeRetry,
|
|
});
|
|
|
|
// Success — validate response quality before returning
|
|
if (result.ok) {
|
|
const quality = await validateResponseQuality(result, clientRequestedStream, log);
|
|
if (!quality.valid) {
|
|
log.warn(
|
|
"COMBO",
|
|
`Model ${modelStr} returned 200 but failed quality check: ${quality.reason}`
|
|
);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
// Fix #1707: Set terminal state so the fallback doesn't emit
|
|
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
|
|
lastError = `Upstream response failed quality validation: ${quality.reason}`;
|
|
if (!lastStatus) lastStatus = 502;
|
|
if (i > 0) fallbackCount++;
|
|
if (provider && rawModel) {
|
|
const mlSettings = resolveModelLockoutSettings(settings);
|
|
if (mlSettings.enabled && mlSettings.errorCodes.includes(502)) {
|
|
recordModelLockoutFailure(
|
|
provider,
|
|
target.connectionId || "",
|
|
rawModel,
|
|
"quality_failure",
|
|
502,
|
|
mlSettings.baseCooldownMs,
|
|
profile,
|
|
{
|
|
exactCooldownMs: mlSettings.useExponentialBackoff
|
|
? 0
|
|
: mlSettings.baseCooldownMs,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
emit("combo.target.failed", {
|
|
comboName: combo.name,
|
|
targetIndex: i,
|
|
provider,
|
|
model: modelStr,
|
|
error: `Quality: ${quality.reason}`,
|
|
latencyMs: Date.now() - startTime,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
// Success decay: a healthy response walks the model's lockout failure
|
|
// count back down (and eventually clears an expired lockout entirely).
|
|
if (provider && rawModel) {
|
|
const dcResult = decayModelFailureCount(
|
|
provider,
|
|
target.connectionId || "",
|
|
rawModel
|
|
);
|
|
if (dcResult.cleared) {
|
|
log.info("COMBO", `Model ${modelStr} fully recovered — lockout cleared`);
|
|
} else if (dcResult.newFailureCount > 0) {
|
|
log.debug(
|
|
"COMBO",
|
|
`Model ${modelStr} decayed to failureCount=${dcResult.newFailureCount}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const latencyMs = Date.now() - startTime;
|
|
emit("combo.target.succeeded", {
|
|
comboName: combo.name,
|
|
targetIndex: i,
|
|
provider,
|
|
model: modelStr,
|
|
latencyMs,
|
|
});
|
|
log.info(
|
|
"COMBO",
|
|
`Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
|
|
);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: true,
|
|
latencyMs,
|
|
fallbackCount,
|
|
strategy,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
|
|
// Reset cooldown on success
|
|
if (provider && provider !== "unknown") {
|
|
recordProviderSuccess(provider, target.connectionId ?? undefined);
|
|
}
|
|
// Webhook fan-out: best-effort, never blocks the response stream.
|
|
notifyWebhookEvent("request.completed", {
|
|
combo: combo.name,
|
|
provider,
|
|
model: modelStr,
|
|
latencyMs,
|
|
fallbackCount,
|
|
});
|
|
|
|
// Context cache pinning: record model usage for session-based pinning
|
|
// (independent of universal handoff — always fires when context_cache_protection is on)
|
|
if (
|
|
combo.context_cache_protection &&
|
|
relayOptions?.sessionId &&
|
|
!(body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
|
) {
|
|
recordSessionModelUsage(
|
|
relayOptions.sessionId,
|
|
combo.name,
|
|
modelStr,
|
|
provider,
|
|
target.connectionId ?? undefined
|
|
);
|
|
}
|
|
|
|
// Universal handoff: record model usage for session
|
|
if (
|
|
universalHandoffConfig.enabled &&
|
|
relayOptions?.sessionId &&
|
|
!(body as Record<string, unknown>)?.[SKIP_UNIVERSAL_HANDOFF_FLAG]
|
|
) {
|
|
const prevModel = getLastSessionModel(relayOptions.sessionId, combo.name);
|
|
recordSessionModelUsage(
|
|
relayOptions.sessionId,
|
|
combo.name,
|
|
modelStr,
|
|
provider,
|
|
target.connectionId ?? undefined
|
|
);
|
|
if (prevModel && prevModel !== modelStr) {
|
|
const handoffSourceMessages =
|
|
Array.isArray(body?.messages) && body.messages.length > 0
|
|
? body.messages
|
|
: Array.isArray(body?.input)
|
|
? body.input
|
|
: [];
|
|
|
|
maybeGenerateUniversalHandoff({
|
|
sessionId: relayOptions.sessionId,
|
|
comboName: combo.name,
|
|
messages: handoffSourceMessages as MessageLike[],
|
|
prevModel,
|
|
currModel: modelStr,
|
|
universalConfig: universalHandoffConfig,
|
|
handleSingleModel: handleSingleModelWithTimeout,
|
|
});
|
|
}
|
|
|
|
recordSessionModelUsage(
|
|
relayOptions.sessionId,
|
|
combo.name,
|
|
modelStr,
|
|
provider,
|
|
target.connectionId ?? undefined
|
|
);
|
|
}
|
|
// Context-relay intentionally splits responsibilities:
|
|
// combo.ts decides whether a successful turn should generate a handoff,
|
|
// while chat.ts injects the handoff after the real connectionId is resolved.
|
|
if (
|
|
strategy === "context-relay" &&
|
|
relayOptions?.sessionId &&
|
|
relayConfig &&
|
|
relayConfig.handoffProviders.includes(provider) &&
|
|
provider === "codex"
|
|
) {
|
|
const connectionId = getSessionConnection(relayOptions.sessionId);
|
|
if (connectionId) {
|
|
const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
|
|
if (quotaInfo) {
|
|
const resetCandidates = [
|
|
quotaInfo.windows?.session?.resetAt,
|
|
quotaInfo.windows?.weekly?.resetAt,
|
|
quotaInfo.resetAt,
|
|
]
|
|
.filter(
|
|
(value): value is string => typeof value === "string" && value.length > 0
|
|
)
|
|
.sort((a, b) => a.localeCompare(b));
|
|
const handoffSourceMessages =
|
|
Array.isArray(body?.messages) && body.messages.length > 0
|
|
? body.messages
|
|
: Array.isArray(body?.input)
|
|
? body.input
|
|
: [];
|
|
|
|
maybeGenerateHandoff({
|
|
sessionId: relayOptions.sessionId,
|
|
comboName: combo.name,
|
|
connectionId,
|
|
percentUsed: quotaInfo.percentUsed,
|
|
messages: handoffSourceMessages,
|
|
model: modelStr,
|
|
expiresAt: resetCandidates[0] || null,
|
|
config: relayConfig,
|
|
handleSingleModel: handleSingleModelWithTimeout,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Record last known good provider (LKGP) for this combo/model (#919)
|
|
if (provider) {
|
|
const connId = target.connectionId || undefined;
|
|
void (async () => {
|
|
try {
|
|
const { setLKGP } = await import("../../src/lib/localDb");
|
|
await Promise.all([
|
|
setLKGP(combo.name, target.executionKey, provider, connId),
|
|
setLKGP(combo.name, combo.id || combo.name, provider, connId),
|
|
]);
|
|
} catch (err) {
|
|
log.warn(
|
|
"COMBO",
|
|
"Failed to record Last Known Good Provider. This is non-fatal.",
|
|
{
|
|
err,
|
|
}
|
|
);
|
|
}
|
|
})();
|
|
}
|
|
|
|
return { ok: true, response: quality.clonedResponse ?? result };
|
|
}
|
|
|
|
// Extract error info from response
|
|
let errorText = result.statusText || "";
|
|
let errorBody: ComboErrorBody = null;
|
|
let retryAfter: ComboRetryAfter | null = null;
|
|
try {
|
|
const cloned = result.clone();
|
|
try {
|
|
const text = await cloned.text();
|
|
if (text) {
|
|
errorText = text.substring(0, 500);
|
|
errorBody = JSON.parse(text);
|
|
const parsedError = errorBody?.error;
|
|
errorText =
|
|
(typeof parsedError === "object" && parsedError?.message) ||
|
|
(typeof parsedError === "string" ? parsedError : null) ||
|
|
errorBody?.message ||
|
|
errorText;
|
|
retryAfter = errorBody?.retryAfter || null;
|
|
}
|
|
} catch {
|
|
/* Clone parse failed */
|
|
}
|
|
} catch {
|
|
/* Clone failed */
|
|
}
|
|
|
|
// Track earliest retryAfter
|
|
if (
|
|
retryAfter &&
|
|
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
|
|
) {
|
|
earliestRetryAfter = retryAfter;
|
|
}
|
|
|
|
// Normalize error text
|
|
if (typeof errorText !== "string") {
|
|
try {
|
|
errorText = JSON.stringify(errorText);
|
|
} catch {
|
|
errorText = String(errorText);
|
|
}
|
|
}
|
|
|
|
const isStreamReadinessFailure =
|
|
(result.status === 502 || result.status === 504) &&
|
|
isStreamReadinessFailureErrorBody(errorBody);
|
|
|
|
// FIX 5: a local per-API-key token-limit 429 must not cool shared accounts.
|
|
const isTokenLimitBreach =
|
|
result.status === 429 && isTokenLimitBreachErrorBody(errorBody);
|
|
|
|
// Fix #1681: Status 499 means client disconnected — stop combo loop immediately.
|
|
// There is no point trying fallback models when nobody is listening.
|
|
if (result.status === 499) {
|
|
log.info("COMBO", `Client disconnected (499) during ${modelStr} — stopping combo loop`);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
// executeTarget must return the {ok,response} contract — a raw Response
|
|
// here makes the speculative loop's res.ok/res.response checks both miss,
|
|
// so the combo would wrongly fall through to the next model after a 499.
|
|
return { ok: false, response: result };
|
|
}
|
|
|
|
// Combo fallback is target-level orchestration: a non-ok target response is
|
|
// treated as local to that target and the combo continues to the next target.
|
|
// Error classification is retained only for retry/cooldown pacing; it must
|
|
// not decide whether fallback happens, including for generic 400 responses.
|
|
const rawError = errorBody?.error;
|
|
const structuredError =
|
|
rawError && typeof rawError === "object"
|
|
? {
|
|
// Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}).
|
|
// Coerce to string if present instead of discarding, so downstream string
|
|
// ops (.toLowerCase, .startsWith) can run safely without type crashes.
|
|
code:
|
|
(rawError as Record<string, unknown>).code !== undefined &&
|
|
(rawError as Record<string, unknown>).code !== null
|
|
? String((rawError as Record<string, unknown>).code)
|
|
: undefined,
|
|
type:
|
|
(rawError as Record<string, unknown>).type !== undefined &&
|
|
(rawError as Record<string, unknown>).type !== null
|
|
? String((rawError as Record<string, unknown>).type)
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
const fallbackResult = checkFallbackError(
|
|
result.status,
|
|
errorText,
|
|
0,
|
|
null,
|
|
provider,
|
|
result.headers,
|
|
profile,
|
|
structuredError
|
|
);
|
|
const { cooldownMs } = fallbackResult;
|
|
|
|
// #1731: If the entire provider quota is exhausted, mark it so subsequent
|
|
// same-provider targets are skipped immediately. API-key 429s still use
|
|
// the short resilience cooldown, but explicit quota text should stop the
|
|
// combo from trying another target for the same provider in this request.
|
|
const providerExhausted =
|
|
Boolean(provider && provider !== "unknown") &&
|
|
(isProviderExhaustedReason(fallbackResult) ||
|
|
classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED);
|
|
if (providerExhausted) {
|
|
exhaustedProviders.add(provider);
|
|
log.info(
|
|
"COMBO",
|
|
`Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`
|
|
);
|
|
} else if (
|
|
result.status === 429 &&
|
|
!isTokenLimitBreach &&
|
|
provider &&
|
|
provider !== "unknown"
|
|
) {
|
|
transientRateLimitedProviders.add(provider);
|
|
}
|
|
// #1731: Connection-level errors (502/503/504) suggest the provider itself is having
|
|
// issues (e.g. upstream unreachable, proxy error). Skip remaining same-provider
|
|
// targets in this request to avoid hammering a known-bad connection.
|
|
if (
|
|
!providerExhausted &&
|
|
provider &&
|
|
provider !== "unknown" &&
|
|
[408, 500, 502, 503, 504, 524].includes(result.status) &&
|
|
!isProviderCircuitOpenResult(result, errorText)
|
|
) {
|
|
const connId = target.connectionId as string | undefined;
|
|
if (connId) {
|
|
exhaustedConnections.add(`${provider}:${connId}`);
|
|
log.info(
|
|
"COMBO",
|
|
`Provider ${provider} connection ${connId} error (${result.status}) — marking for skip on remaining targets (#1731v2)`
|
|
);
|
|
} else {
|
|
exhaustedProviders.add(provider);
|
|
log.info(
|
|
"COMBO",
|
|
`Provider ${provider} connection error (${result.status}) — marking for skip on remaining targets (#1731)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// #2101: Prevent infinite fallback loops with 400 Bad Request errors that indicate
|
|
// request-body-specific issues (context overflow, malformed request, model access denied).
|
|
// These errors are unlikely to be resolved by trying different target models since
|
|
// the same problematic request body would be sent to all targets.
|
|
if (
|
|
result.status === 400 &&
|
|
fallbackResult.shouldFallback &&
|
|
(fallbackResult.reason === RateLimitReason.MODEL_CAPACITY ||
|
|
errorText.toLowerCase().includes("context") ||
|
|
errorText.toLowerCase().includes("prompt") ||
|
|
errorText.toLowerCase().includes("token") ||
|
|
errorText.toLowerCase().includes("malformed") ||
|
|
errorText.toLowerCase().includes("invalid") ||
|
|
errorText.toLowerCase().includes("bad request"))
|
|
) {
|
|
log.warn(
|
|
"COMBO",
|
|
`400 Bad Request with body-specific error detected on ${modelStr} — skipping fallback to other targets to prevent infinite loop`
|
|
);
|
|
// Record the failure and break to avoid trying other targets with the same bad request
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
lastError = errorText || String(result.status);
|
|
if (!lastStatus) lastStatus = result.status;
|
|
if (i > 0) fallbackCount++;
|
|
log.warn("COMBO", `Model ${modelStr} failed with body-specific error, stopping combo`);
|
|
break; // Break out of the target loop to avoid trying other models
|
|
}
|
|
|
|
// Trigger shared provider circuit breaker for 5xx errors and connection failures.
|
|
// If the next target in the combo is on the same provider, don't mark the provider
|
|
// as failed — different models on the same provider may still succeed.
|
|
// G-02: when fallbackResult.skipProviderBreaker is set (embedded service supervisor
|
|
// outage signalled via X-Omni-Fallback-Hint: connection_cooldown) apply connection
|
|
// cooldown only — do NOT trip the whole-provider breaker.
|
|
const nextTarget = orderedTargets[i + 1];
|
|
const sameProviderNext =
|
|
typeof nextTarget?.provider === "string" && nextTarget.provider === provider;
|
|
if (
|
|
!isStreamReadinessFailure &&
|
|
isProviderFailureCode(result.status) &&
|
|
!sameProviderNext &&
|
|
!fallbackResult.skipProviderBreaker
|
|
) {
|
|
recordProviderFailure(provider, log, target.connectionId, profile);
|
|
}
|
|
|
|
// Check if this is a transient error worth retrying on same model.
|
|
// A token-limit 429 is terminal for the client — never retry it.
|
|
const isTransient =
|
|
!isStreamReadinessFailure &&
|
|
!isTokenLimitBreach &&
|
|
[408, 429, 500, 502, 503, 504].includes(result.status);
|
|
if (retry < maxRetries && isTransient && !providerExhausted) {
|
|
// Record model lockout immediately on the first transient failure —
|
|
// once the model is cooling down, retrying it would waste an upstream
|
|
// call and extend the cooldown via exponential backoff.
|
|
let lockoutRecorded = false;
|
|
if (provider && rawModel && retry === 0) {
|
|
const mlSettings = resolveModelLockoutSettings(settings);
|
|
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
|
recordModelLockoutFailure(
|
|
provider,
|
|
target.connectionId || "",
|
|
rawModel,
|
|
classifyLockoutReason(result.status),
|
|
result.status,
|
|
mlSettings.baseCooldownMs,
|
|
profile,
|
|
{
|
|
exactCooldownMs: mlSettings.useExponentialBackoff
|
|
? 0
|
|
: mlSettings.baseCooldownMs,
|
|
}
|
|
);
|
|
lockoutRecorded = true;
|
|
}
|
|
}
|
|
if (lockoutRecorded) {
|
|
log.info("COMBO", `Skipping retry for ${modelStr} — model lockout active`);
|
|
if (i > 0) fallbackCount++;
|
|
return null;
|
|
}
|
|
continue; // Retry same model (transient error, no lockout recorded)
|
|
}
|
|
|
|
// Done retrying this model
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy,
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
lastError = errorText || String(result.status);
|
|
if (!lastStatus) lastStatus = result.status;
|
|
if (i > 0) fallbackCount++;
|
|
// Wire combo failures into the resilience dashboard (model-level lockout)
|
|
// alongside the provider-level cooldown below — they govern different scopes.
|
|
if (provider && rawModel) {
|
|
const mlSettings = resolveModelLockoutSettings(settings);
|
|
if (mlSettings.enabled && mlSettings.errorCodes.includes(result.status)) {
|
|
recordModelLockoutFailure(
|
|
provider,
|
|
target.connectionId || "",
|
|
rawModel,
|
|
classifyLockoutReason(result.status),
|
|
result.status,
|
|
mlSettings.baseCooldownMs,
|
|
profile,
|
|
{
|
|
exactCooldownMs: mlSettings.useExponentialBackoff ? 0 : mlSettings.baseCooldownMs,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
|
|
|
if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") {
|
|
recordProviderCooldown(provider, target.connectionId ?? undefined, resilienceSettings);
|
|
}
|
|
|
|
const fallbackWaitMs =
|
|
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
|
? Math.min(cooldownMs, fallbackDelayMs)
|
|
: 0;
|
|
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
|
|
log.debug?.("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, fallbackWaitMs);
|
|
signal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve(undefined);
|
|
},
|
|
{ once: true }
|
|
);
|
|
});
|
|
if (signal?.aborted) {
|
|
log.info("COMBO", `Client disconnected during fallback wait — aborting`);
|
|
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
for (let i = 0; i < orderedTargets.length; i++) {
|
|
if (anySuccess) break;
|
|
|
|
const abortController = new AbortController();
|
|
abortControllers.set(i, abortController);
|
|
const onClientAbort = () => abortController.abort();
|
|
signal?.addEventListener("abort", onClientAbort);
|
|
|
|
const task = (async () => {
|
|
try {
|
|
const res = await executeTarget(i);
|
|
if (res && !anySuccess) {
|
|
if (res.ok) {
|
|
anySuccess = true;
|
|
globalResolve!(res.response!);
|
|
for (const [idx, ac] of abortControllers.entries()) {
|
|
if (idx !== i) ac.abort();
|
|
}
|
|
} else if (res.response) {
|
|
// Fatal error, abort combo
|
|
anySuccess = true;
|
|
globalResolve!(res.response);
|
|
}
|
|
}
|
|
} finally {
|
|
signal?.removeEventListener("abort", onClientAbort);
|
|
}
|
|
})().catch((err) => {
|
|
const logError = log.error ?? log.warn;
|
|
logError("COMBO", `Speculative task error for target ${i}`, err);
|
|
});
|
|
|
|
runningTasks.add(task);
|
|
task.finally(() => runningTasks.delete(task));
|
|
|
|
if (zeroLatencyOptimizationsEnabled && config.hedging && i + 1 < orderedTargets.length) {
|
|
const hedgeDelay = resolveDelayMs(config.hedgeDelayMs, 500);
|
|
let timeoutResolve: () => void;
|
|
const timeoutPromise = new Promise<void>((r) => {
|
|
timeoutResolve = r;
|
|
setTimeout(r, hedgeDelay);
|
|
});
|
|
await Promise.race([task, globalPromise, timeoutPromise]);
|
|
} else {
|
|
await Promise.race([task, globalPromise]);
|
|
}
|
|
}
|
|
|
|
if (!anySuccess && runningTasks.size > 0) {
|
|
await Promise.race([globalPromise, Promise.all([...runningTasks])]);
|
|
}
|
|
|
|
if (anySuccess) {
|
|
return await globalPromise;
|
|
}
|
|
|
|
// All models failed in this set try
|
|
const latencyMs = Date.now() - startTime;
|
|
if (recordedAttempts === 0) {
|
|
recordComboRequest(combo.name, null, {
|
|
success: false,
|
|
latencyMs,
|
|
fallbackCount,
|
|
strategy,
|
|
});
|
|
}
|
|
|
|
// Retry the entire set if more attempts remain
|
|
if (setTry < maxSetRetries) continue;
|
|
|
|
// All set retries exhausted — return the final error
|
|
if (!lastStatus) {
|
|
notifyWebhookEvent("request.failed", {
|
|
combo: combo.name,
|
|
reason: "ALL_ACCOUNTS_INACTIVE",
|
|
latencyMs,
|
|
fallbackCount,
|
|
});
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Service temporarily unavailable: all upstream accounts are inactive",
|
|
type: "service_unavailable",
|
|
code: "ALL_ACCOUNTS_INACTIVE",
|
|
},
|
|
}),
|
|
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
const status = lastStatus;
|
|
const msg = lastError || "All combo models unavailable";
|
|
|
|
if (earliestRetryAfter) {
|
|
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
|
|
log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`);
|
|
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
|
}
|
|
|
|
log.warn("COMBO", `All models failed | ${msg}`);
|
|
return new Response(JSON.stringify({ error: { message: msg } }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
return errorResponse(503, "Combo routing completed without an upstream response");
|
|
} finally {
|
|
// G2: Clean up candidate registry to prevent unbounded memory growth.
|
|
_unregisterExecutionCandidates(_registeredExecutionKeys);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle round-robin combo: each request goes to the next model in circular order.
|
|
* Uses semaphore-based concurrency control with queue + rate-limit awareness.
|
|
*
|
|
* Flow:
|
|
* 1. Pick target model via atomic counter (counter % models.length)
|
|
* 2. Acquire semaphore slot (may queue if at max concurrency)
|
|
* 3. Send request to target model
|
|
* 4. On 429 → mark model rate-limited, try next model in rotation
|
|
* 5. On semaphore timeout → fallback to next available model
|
|
*/
|
|
async function handleRoundRobinCombo({
|
|
body,
|
|
combo,
|
|
handleSingleModel,
|
|
isModelAvailable,
|
|
log,
|
|
settings,
|
|
allCombos,
|
|
signal,
|
|
}: HandleRoundRobinOptions): Promise<Response> {
|
|
const config = settings
|
|
? resolveComboConfig(combo, settings)
|
|
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
|
|
const concurrency = config.concurrencyPerModel ?? 3;
|
|
const queueTimeout = config.queueTimeoutMs ?? 30000;
|
|
const maxRetries = config.maxRetries ?? 1;
|
|
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
|
|
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
|
|
const reasoningTokenBufferEnabled = config.reasoningTokenBufferEnabled !== false;
|
|
|
|
const resilienceSettings: ResilienceSettings = settings
|
|
? resolveResilienceSettings(settings)
|
|
: resolveResilienceSettings(null);
|
|
|
|
const orderedTargets = resolveComboTargets(combo, allCombos);
|
|
const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log);
|
|
const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log);
|
|
const filteredTargets = filterTargetsByRequestCompatibility(
|
|
evalRankedTargets,
|
|
body,
|
|
log,
|
|
"Context-aware round-robin fallback"
|
|
);
|
|
const modelCount = filteredTargets.length;
|
|
if (modelCount === 0) {
|
|
return comboModelNotFoundResponse("Round-robin combo has no executable targets");
|
|
}
|
|
|
|
scheduleShadowRouting(
|
|
combo,
|
|
config,
|
|
body,
|
|
resolveShadowTargets(combo, config, allCombos),
|
|
handleSingleModel,
|
|
isModelAvailable,
|
|
"round-robin",
|
|
log
|
|
);
|
|
|
|
// Get and increment atomic counter
|
|
const counter = rrCounters.get(combo.name) || 0;
|
|
if (!rrCounters.has(combo.name) && rrCounters.size >= MAX_RR_COUNTERS) {
|
|
const oldest = rrCounters.keys().next().value;
|
|
if (oldest !== undefined) rrCounters.delete(oldest);
|
|
}
|
|
rrCounters.set(combo.name, counter + 1);
|
|
const startIndex = counter % modelCount;
|
|
|
|
const clientRequestedStream = body?.stream === true;
|
|
const startTime = Date.now();
|
|
let lastError: string | null = null;
|
|
let lastStatus: number | null = null;
|
|
let earliestRetryAfter: ComboRetryAfter | null = null;
|
|
let globalAttempts = 0;
|
|
let fallbackCount = 0;
|
|
let recordedAttempts = 0;
|
|
|
|
// #1731: Per-request in-memory set of providers whose quota is fully exhausted.
|
|
// When a target returns a quota-exhausted 429, remaining targets from the same
|
|
// provider are skipped to avoid the cascade through N same-provider targets.
|
|
const exhaustedProviders = new Set<string>();
|
|
const exhaustedConnections = new Set<string>();
|
|
const transientRateLimitedProviders = new Set<string>();
|
|
|
|
// Try each model starting from the round-robin target
|
|
for (let offset = 0; offset < modelCount; offset++) {
|
|
const modelIndex = (startIndex + offset) % modelCount;
|
|
const target = filteredTargets[modelIndex];
|
|
const modelStr = target.modelStr;
|
|
const provider = target.provider;
|
|
const profile = await getRuntimeProviderProfile(provider);
|
|
const semaphoreKey = `combo:${combo.name}:${target.executionKey}`;
|
|
const allowRateLimitedConnection =
|
|
Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider);
|
|
const targetForAttempt = allowRateLimitedConnection
|
|
? { ...target, allowRateLimitedConnection: true }
|
|
: target;
|
|
|
|
// Pre-check availability
|
|
if (isModelAvailable) {
|
|
const available = await isModelAvailable(modelStr, targetForAttempt);
|
|
if (!available) {
|
|
log.debug?.(
|
|
"COMBO-RR",
|
|
`Skipping ${modelStr} — no credentials available or model excluded`
|
|
);
|
|
if (offset > 0) fallbackCount++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (
|
|
resilienceSettings.providerCooldown.enabled &&
|
|
Boolean(provider && provider !== "unknown") &&
|
|
isProviderInCooldown(provider, target.connectionId as string | undefined, resilienceSettings)
|
|
) {
|
|
log.info("COMBO-RR", `Skipping ${modelStr} — provider ${provider} in global cooldown`);
|
|
if (offset > 0) fallbackCount++;
|
|
continue;
|
|
}
|
|
|
|
// #1731: Skip targets from a provider that already signaled full quota exhaustion
|
|
// this request.
|
|
// #1731v2: Skip targets whose provider:connection pair had a connection-level error.
|
|
if (provider && target.connectionId) {
|
|
const connKey = `${provider}:${target.connectionId}`;
|
|
if (exhaustedConnections.has(connKey)) {
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Skipping ${modelStr} — connection ${target.connectionId} for provider ${provider} had connection error (#1731v2)`
|
|
);
|
|
if (offset > 0) fallbackCount++;
|
|
continue;
|
|
}
|
|
}
|
|
if (provider && exhaustedProviders.has(provider)) {
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Skipping ${modelStr} — provider ${provider} marked exhausted this request (#1731)`
|
|
);
|
|
if (offset > 0) fallbackCount++;
|
|
continue;
|
|
}
|
|
|
|
// Acquire semaphore slot (may wait in queue)
|
|
let release: () => void;
|
|
try {
|
|
release = await semaphore.acquire(semaphoreKey, {
|
|
maxConcurrency: concurrency,
|
|
timeoutMs: queueTimeout,
|
|
});
|
|
} catch (err) {
|
|
const errCode = isRecord(err) && typeof err.code === "string" ? err.code : null;
|
|
if (errCode === "SEMAPHORE_TIMEOUT" || errCode === "SEMAPHORE_QUEUE_FULL") {
|
|
log.warn(
|
|
"COMBO-RR",
|
|
`Semaphore ${errCode === "SEMAPHORE_QUEUE_FULL" ? "queue full" : "timeout"} for ${modelStr}, trying next model`
|
|
);
|
|
if (offset > 0) fallbackCount++;
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Retry loop within this model
|
|
try {
|
|
for (let retry = 0; retry <= maxRetries; retry++) {
|
|
globalAttempts++;
|
|
if (globalAttempts > MAX_GLOBAL_ATTEMPTS) {
|
|
log.warn(
|
|
"COMBO-RR",
|
|
`Maximum combo attempts (${MAX_GLOBAL_ATTEMPTS}) exceeded. Terminating loop to prevent runaway requests.`
|
|
);
|
|
return errorResponse(503, "Maximum combo retry limit reached");
|
|
}
|
|
if (retry > 0) {
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Retrying ${modelStr} in ${retryDelayMs}ms (attempt ${retry + 1}/${maxRetries + 1})`
|
|
);
|
|
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
}
|
|
|
|
log.info(
|
|
"COMBO-RR",
|
|
`[RR #${counter}] → ${modelStr}${offset > 0 ? ` (fallback +${offset})` : ""}${retry > 0 ? ` (retry ${retry})` : ""}`
|
|
);
|
|
|
|
// Issue #3587: Reasoning models can spend the whole output budget on
|
|
// reasoning. Apply any safe buffer to a per-attempt copy so round-robin
|
|
// retries never compound across models.
|
|
let attemptBody = body;
|
|
{
|
|
const bodyRecord = body as Record<string, unknown>;
|
|
const currentMaxTokens = toPositiveInteger(bodyRecord.max_tokens);
|
|
const bufferedMaxTokens = resolveReasoningBufferedMaxTokens(
|
|
modelStr,
|
|
bodyRecord.max_tokens,
|
|
{ enabled: reasoningTokenBufferEnabled }
|
|
);
|
|
if (
|
|
currentMaxTokens !== null &&
|
|
bufferedMaxTokens !== null &&
|
|
bufferedMaxTokens !== currentMaxTokens
|
|
) {
|
|
attemptBody = {
|
|
...bodyRecord,
|
|
max_tokens: bufferedMaxTokens,
|
|
} as typeof body;
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Reasoning model ${modelStr}: adjusted max_tokens ${currentMaxTokens} -> ${bufferedMaxTokens}`
|
|
);
|
|
}
|
|
}
|
|
|
|
const result = await handleSingleModel(attemptBody, modelStr, {
|
|
...targetForAttempt,
|
|
failoverBeforeRetry: config.failoverBeforeRetry,
|
|
});
|
|
|
|
// Success — validate response quality before returning
|
|
if (result.ok) {
|
|
const quality = await validateResponseQuality(result, clientRequestedStream, log);
|
|
if (!quality.valid) {
|
|
log.warn(
|
|
"COMBO-RR",
|
|
`${modelStr} returned 200 but failed quality check: ${quality.reason}`
|
|
);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy: "round-robin",
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
// Fix #1707: Set terminal state so the fallback doesn't emit
|
|
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
|
|
lastError = `Upstream response failed quality validation: ${quality.reason}`;
|
|
if (!lastStatus) lastStatus = 502;
|
|
if (offset > 0) fallbackCount++;
|
|
break; // move to next model
|
|
}
|
|
const latencyMs = Date.now() - startTime;
|
|
log.info(
|
|
"COMBO-RR",
|
|
`${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)`
|
|
);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: true,
|
|
latencyMs,
|
|
fallbackCount,
|
|
strategy: "round-robin",
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
|
|
if (provider && provider !== "unknown") {
|
|
recordProviderSuccess(provider, target.connectionId ?? undefined);
|
|
}
|
|
|
|
if (provider) {
|
|
const connId = target.connectionId || undefined;
|
|
void (async () => {
|
|
try {
|
|
const { setLKGP } = await import("../../src/lib/localDb");
|
|
await Promise.all([
|
|
setLKGP(combo.name, target.executionKey, provider, connId),
|
|
setLKGP(combo.name, combo.id || combo.name, provider, connId),
|
|
]);
|
|
} catch (err) {
|
|
log.warn(
|
|
"COMBO-RR",
|
|
"Failed to record Last Known Good Provider. This is non-fatal.",
|
|
{
|
|
err,
|
|
}
|
|
);
|
|
}
|
|
})();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Extract error info
|
|
let errorText = result.statusText || "";
|
|
let retryAfter: ComboRetryAfter | null = null;
|
|
let errorBody: ComboErrorBody = null;
|
|
try {
|
|
const cloned = result.clone();
|
|
try {
|
|
const text = await cloned.text();
|
|
if (text) {
|
|
errorText = text.substring(0, 500);
|
|
errorBody = JSON.parse(text);
|
|
const parsedError = errorBody?.error;
|
|
errorText =
|
|
(typeof parsedError === "object" && parsedError?.message) ||
|
|
(typeof parsedError === "string" ? parsedError : null) ||
|
|
errorBody?.message ||
|
|
errorText;
|
|
retryAfter = errorBody?.retryAfter || null;
|
|
}
|
|
} catch {
|
|
/* Clone parse failed */
|
|
}
|
|
} catch {
|
|
/* Clone failed */
|
|
}
|
|
|
|
if (result.status === 499) {
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Client disconnected (499) during ${modelStr} — stopping combo loop`
|
|
);
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy: "round-robin",
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
return result;
|
|
}
|
|
|
|
if (
|
|
retryAfter &&
|
|
(!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))
|
|
) {
|
|
earliestRetryAfter = retryAfter;
|
|
}
|
|
|
|
if (typeof errorText !== "string") {
|
|
try {
|
|
errorText = JSON.stringify(errorText);
|
|
} catch {
|
|
errorText = String(errorText);
|
|
}
|
|
}
|
|
|
|
const isStreamReadinessFailure =
|
|
(result.status === 502 || result.status === 504) &&
|
|
isStreamReadinessFailureErrorBody(errorBody);
|
|
|
|
// FIX 5: a local per-API-key token-limit 429 must not cool shared accounts.
|
|
const isTokenLimitBreach = result.status === 429 && isTokenLimitBreachErrorBody(errorBody);
|
|
|
|
// Round-robin uses the same target-level fallback rule as other combo
|
|
// strategies: non-ok target responses fall through to the next target.
|
|
// Classification stays here only to support cooldown/semaphore pacing,
|
|
// not to decide whether fallback is allowed.
|
|
const rawError = errorBody?.error;
|
|
const structuredError =
|
|
rawError && typeof rawError === "object"
|
|
? {
|
|
// Upstream JSON may carry a numeric `code`/`type` (e.g. {"code":40001}).
|
|
// Coerce to string if present instead of discarding, so downstream string
|
|
// ops (.toLowerCase, .startsWith) can run safely without type crashes.
|
|
code:
|
|
(rawError as Record<string, unknown>).code !== undefined &&
|
|
(rawError as Record<string, unknown>).code !== null
|
|
? String((rawError as Record<string, unknown>).code)
|
|
: undefined,
|
|
type:
|
|
(rawError as Record<string, unknown>).type !== undefined &&
|
|
(rawError as Record<string, unknown>).type !== null
|
|
? String((rawError as Record<string, unknown>).type)
|
|
: undefined,
|
|
}
|
|
: undefined;
|
|
const fallbackResult = checkFallbackError(
|
|
result.status,
|
|
errorText,
|
|
0,
|
|
null,
|
|
provider,
|
|
result.headers,
|
|
profile,
|
|
structuredError
|
|
);
|
|
const { cooldownMs } = fallbackResult;
|
|
|
|
const isAllAccountsRateLimited = isAllAccountsRateLimitedResponse(
|
|
result.status,
|
|
result.headers?.get("content-type") ?? null,
|
|
errorText
|
|
);
|
|
|
|
// #1731: If the entire provider quota is exhausted, mark it so subsequent
|
|
// same-provider targets are skipped immediately. API-key 429s still use
|
|
// the short resilience cooldown, but explicit quota text should stop the
|
|
// combo from trying another target for the same provider in this request.
|
|
const providerExhausted =
|
|
Boolean(provider && provider !== "unknown") &&
|
|
(isProviderExhaustedReason(fallbackResult) ||
|
|
classifyErrorText(errorText) === RateLimitReason.QUOTA_EXHAUSTED ||
|
|
isAllAccountsRateLimited);
|
|
if (providerExhausted) {
|
|
exhaustedProviders.add(provider);
|
|
log.debug?.(
|
|
"COMBO-RR",
|
|
`Provider ${provider} quota exhausted — marking for skip (#1731)`
|
|
);
|
|
} else if (
|
|
result.status === 429 &&
|
|
!isTokenLimitBreach &&
|
|
provider &&
|
|
provider !== "unknown"
|
|
) {
|
|
transientRateLimitedProviders.add(provider);
|
|
}
|
|
|
|
// #1731v2: Connection-level errors (502/503/504) — skip remaining same-connection targets
|
|
if (
|
|
!providerExhausted &&
|
|
provider &&
|
|
provider !== "unknown" &&
|
|
[408, 500, 502, 503, 504, 524].includes(result.status) &&
|
|
!isProviderCircuitOpenResult(result, errorText)
|
|
) {
|
|
const connId = target.connectionId as string | undefined;
|
|
if (connId) {
|
|
exhaustedConnections.add(`${provider}:${connId}`);
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Provider ${provider} connection ${connId} error (${result.status}) — marking for skip (#1731v2)`
|
|
);
|
|
} else {
|
|
exhaustedProviders.add(provider);
|
|
log.info(
|
|
"COMBO-RR",
|
|
`Provider ${provider} connection error (${result.status}) — marking for skip (#1731)`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Transient errors → mark in semaphore so round-robin stops stampeding this target.
|
|
if (
|
|
!isStreamReadinessFailure &&
|
|
!isTokenLimitBreach &&
|
|
TRANSIENT_FOR_SEMAPHORE.includes(result.status) &&
|
|
cooldownMs > 0
|
|
) {
|
|
semaphore.markRateLimited(semaphoreKey, cooldownMs);
|
|
log.warn("COMBO-RR", `${modelStr} error ${result.status}, cooldown ${cooldownMs}ms`);
|
|
}
|
|
|
|
if (isAllAccountsRateLimited) {
|
|
log.info(
|
|
"COMBO-RR",
|
|
`All accounts rate-limited for ${modelStr}, falling back to next model`
|
|
);
|
|
}
|
|
|
|
// Transient error → retry same model.
|
|
// A token-limit 429 is terminal for the client — never retry it.
|
|
const isTransient =
|
|
!isStreamReadinessFailure &&
|
|
!isTokenLimitBreach &&
|
|
[408, 429, 500, 502, 503, 504].includes(result.status);
|
|
if (retry < maxRetries && isTransient && !providerExhausted) {
|
|
continue;
|
|
}
|
|
|
|
// Done with this model
|
|
recordComboRequest(combo.name, modelStr, {
|
|
success: false,
|
|
latencyMs: Date.now() - startTime,
|
|
fallbackCount,
|
|
strategy: "round-robin",
|
|
target: toRecordedTarget(target),
|
|
});
|
|
recordedAttempts++;
|
|
lastError = errorText || String(result.status);
|
|
if (!lastStatus) lastStatus = result.status;
|
|
if (offset > 0) fallbackCount++;
|
|
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
|
|
|
|
if (resilienceSettings.providerCooldown.enabled && provider && provider !== "unknown") {
|
|
recordProviderCooldown(provider, target.connectionId ?? undefined, resilienceSettings);
|
|
}
|
|
|
|
const fallbackWaitMs =
|
|
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
|
? Math.min(cooldownMs, fallbackDelayMs)
|
|
: 0;
|
|
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
|
|
log.debug?.("COMBO-RR", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
|
|
await new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, fallbackWaitMs);
|
|
signal?.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
resolve(undefined);
|
|
},
|
|
{ once: true }
|
|
);
|
|
});
|
|
if (signal?.aborted) {
|
|
log.info("COMBO-RR", `Client disconnected during fallback wait — aborting`);
|
|
return errorResponse(499, "Client disconnected");
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
} finally {
|
|
// ALWAYS release semaphore slot
|
|
release();
|
|
}
|
|
}
|
|
|
|
// All models exhausted
|
|
const latencyMs = Date.now() - startTime;
|
|
if (recordedAttempts === 0) {
|
|
recordComboRequest(combo.name, null, {
|
|
success: false,
|
|
latencyMs,
|
|
fallbackCount,
|
|
strategy: "round-robin",
|
|
});
|
|
}
|
|
|
|
if (!lastStatus) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: "Service temporarily unavailable: all upstream accounts are inactive",
|
|
type: "service_unavailable",
|
|
code: "ALL_ACCOUNTS_INACTIVE",
|
|
},
|
|
}),
|
|
{ status: 503, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
const status = lastStatus;
|
|
const msg = lastError || "All round-robin combo models unavailable";
|
|
|
|
if (earliestRetryAfter) {
|
|
const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter));
|
|
log.warn("COMBO-RR", `All models failed | ${msg} (${retryHuman})`);
|
|
return unavailableResponse(status, msg, earliestRetryAfter, retryHuman);
|
|
}
|
|
|
|
log.warn("COMBO-RR", `All models failed | ${msg}`);
|
|
return new Response(JSON.stringify({ error: { message: msg } }), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|