Files
OmniRoute/open-sse/services/combo/autoStrategy.ts
ViFigueiredo 577bbf3e47 [defer] feat(combo): universal cooldown-aware retry & auto-strategy combo-ref guard (#7301)
* feat(combo): universal cooldown-aware retry & auto-strategy combo-ref guard

Two changes:

1. Universal cooldown-aware retry (combo.ts):
   - Remove strategy==="quota-share" gate from comboCooldownWaitEnabled
   - Enables all 18 combo strategies (priority, weighted, round-robin, etc.)
     to wait out a short transient cooldown and retry the full set
   - Non-quota-share strategies use shouldWaitForComboCooldown directly
     with earliestRetryAfter and reason="rate_limit" (no per-model lockout)
   - quota-share retains its existing per-target model lockout logic

2. Auto-strategy combo-ref guard (autoStrategy.ts):
   - expandAutoComboCandidatePool now detects kind==="combo-ref" entries
   - When present, returns eligibleTargets without expanding to ALL providers
   - Fixes scenario where an "auto" combo with combo-ref delegates to a
     sub-combo but pulls in every model from every active provider

* feat(combo): global comboTimeoutMs + aggregated error diagnostics

Adds two features to improve combo resilience and debuggability:

1. Global combo timeout (comboTimeoutMs)
   - Configurable per-combo via DEFAULT_COMBO_CONFIG (default 0 = disabled)
   - After each target completes, checks if total elapsed time exceeds limit
   - When exceeded, stops trying further targets and returns 504 immediately
   - Backward-compatible: 0 preserves legacy unlimited-iteration behavior

2. Aggregated error diagnostics
   - comboErrors array accumulates per-model failure details (model, status, error)
   - On combo timeout or all-models-exhausted, returns a message listing the
     first (up to 5) model-level errors with their HTTP status codes
   - Enables operators to see WHICH models failed and WHY without digging
     through individual server logs

* test(combo): cover universal cooldown-aware retry, comboTimeoutMs, and combo-ref guard

Adds/updates automated coverage for this PR's production changes (PR Test
Policy requires tests alongside src/open-sse/electron/bin changes):

- Update the "preserves the first failure status" expectation in
  combo-routing-engine.test.ts: the aggregated per-model error-diagnostics
  suffix is new intended output, not a regression.
- Add two new tests for the global comboTimeoutMs feature: the combo stops
  dispatching further targets and returns 504/COMBO_TIMEOUT with aggregated
  diagnostics once the ceiling trips, and comboTimeoutMs=0 (default) never
  trips it.
- Rewrite the non-quota-share (priority) cooldown-wait scenario in
  combo-quota-share-cooldown-wait-timing.test.ts: comboCooldownWait is no
  longer gated on strategy === "quota-share", so a priority combo now waits
  out a short 429 and re-dispatches too (via shouldWaitForComboCooldown with
  reason "rate_limit"), instead of propagating immediately as before. Also
  adds the disabled-flag counterpart for parity with the quota-share suite.
- Add coverage for the #COMBO-REF guard in expandAutoComboCandidatePool:
  a combo whose models array contains a kind:"combo-ref" entry must not be
  expanded to every model of every active provider.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* test(combo): keep the new comboTimeoutMs tests free of no-explicit-any

The two new tests initially copied the neighbouring tests' `any`-typed
handleSingleModel params / json() casts. Those neighbours are pre-existing
violations frozen in config/quality/eslint-suppressions.json at a count of 261
for this file, so the 7 new occurrences pushed it to 268 and broke `npm run
lint` (no-explicit-any is an error in tests/ since #6218; new violations must
be fixed, not re-frozen).

Type the new tests properly instead: `unknown`/`string` params, a
ComboErrorPayload interface for the parsed body, and drop the unused
`relayOptions: null as any` (the sibling combo cooldown suites already omit
it). Back to exactly 261 — the suppression file is untouched.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* fix(combo): gate the universal cooldown retry on the REAL lock reason, not a hardcoded "rate_limit"

The universal cooldown-aware retry kept the quota-share path on
resolveComboCooldownWaitDecision but gave every OTHER strategy a shortcut that
hardcoded `reason: "rate_limit"` and fed shouldWaitForComboCooldown the
earliestRetryAfter directly.

comboCooldownRetry.ts documents TWO deliberate barriers ("SECURITY —
quota_exhausted must be excluded"): (1) the reason allow-list, and (2) the
maxWaitMs ceiling, explicitly called the SECOND barrier. Hardcoding the reason
removed barrier 1 for 17 of the 18 strategies and left only the ceiling — which
does NOT cover a quota_exhausted lock whose wait lands under maxWaitMs. In that
case the combo waits, redispatches against a model that is locked until the
quota resets, and burns the retry budget for nothing.

The shortcut's premise ("non-quota-share combos have no per-connection model
lockout tracking") is also false: recordModelLockoutFailure in the target loop
is not gated on quota-share, so every strategy records model lockouts and the
real reason is always available.

Fix: one decision path for every strategy, always through
resolveComboCooldownWaitDecision, so the reason always crosses the allow-list.
The lock lookup is now keyed on each TARGET's own model (via a new third
`target` arg on lookupLock) — quota-share combos are single-model/multi-account
so this is identical to the previous orderedTargets[0] behavior, but
heterogeneous combos (priority, weighted, round-robin, …) carry a different
model per target and would otherwise miss every lock but the first.

Regression guard (tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts):
a priority combo where modelLockout.errorCodes=[403] leaves the 403's
quota_exhausted lock as the only one in play while a 429 crystallizes the
status, and the resulting wait is short enough that the ceiling lets it through
— so only the allow-list can stop it. Verified failing-then-passing: with the
hardcoded reason the combo makes 6 dispatches (wait+redispatch x2); with the
real reason it makes 2 and propagates the 429.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* chore(quality): rebaseline file-size for PR #7301 own growth (combo +91, combo-routing-engine test +68)

---------

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
2026-07-21 16:30:37 -03:00

510 lines
20 KiB
TypeScript

/**
* Auto-combo scoring, intent extraction, request-tag routing, candidate-pool
* expansion and the quota-soft execution-candidate registry — extracted from
* combo.ts (Quality Gate v2 / Fase 9, combo split D8 — reduced).
*
* Logic is unchanged (byte-identical move); the moved public symbols
* (QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty, scoreAutoTargets,
* expandAutoComboCandidatePool) are re-exported from combo.ts for backward
* compatibility — including chatCore.ts's dynamic `import("../services/combo")`
* which reads setCandidateQuotaSoftPenalty + QUOTA_SOFT_DEPRIORITIZE_FACTOR.
*
* The _activeExecutionCandidates registry Map MUST stay a single instance, so it
* and its three mutators live together here.
*
* NOTE: buildAutoCandidates (and its two private-only helpers
* calculateTargetContextAffinity / getBootstrapLatencyMs) deliberately stay in
* combo.ts — it is the sole user of the internal reset-window helpers
* (resolveResetWindowConfig / fetchResetAwareQuotaWithCache /
* calculateResetWindowAffinity), so keeping it there avoids a combo ⇄ autoStrategy
* import cycle. This module never imports from the combo barrel.
*/
import { isRecord } from "./comboData.ts";
import type {
AutoProviderCandidate,
ComboLike,
HistoricalLatencyStatsEntry,
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
calculateFactors,
calculateScore,
type ProviderCandidate,
type ScoringWeights,
} from "../autoCombo/scoring.ts";
import type { RoutingHint } from "../manifestAdapter";
import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
import { getProviderModels } from "../../config/providerModels.ts";
import {
getConnectionRoutingTags,
matchesRoutingTags,
resolveRequestRoutingTags,
} from "../../../src/domain/tagRouter.ts";
// 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"
);
// #4540: Status soft-deprioritization factor.
// When the quota-preflight HARD cutoff is OFF (default), a candidate whose connection
// is in a terminal/transient unavailable status (credits_exhausted / rate_limited /
// banned / expired / future-dated unavailable) is NOT hard-blocked — instead its
// auto-combo score is multiplied by this factor so an exhausted provider ranks strictly
// below an otherwise-identical healthy one, without surfacing a misleading 429.
// Override via STATUS_SOFT_DEPRIORITIZE_FACTOR env var (range 0..1, default 0.5).
export const STATUS_SOFT_DEPRIORITIZE_FACTOR = Number(
process.env.STATUS_SOFT_DEPRIORITIZE_FACTOR ?? "0.5"
);
// 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.
*/
export 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.
*/
export function _unregisterExecutionCandidates(executionKeys: string[]): void {
for (const key of executionKeys) {
_activeExecutionCandidates.delete(key);
}
}
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");
}
export 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 "";
}
export 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 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 [];
}
export 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) }
: {}),
};
}
export 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 getCachedProviderConnections({ 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;
}
// #3266: when a step already carries an account allowlist, intersect it with
// the tag-matched connections (most-restrictive wins). An empty intersection
// means no connection satisfies both constraints, so the target is dropped —
// the same outcome the `matchedConnectionIds.length === 0` guard above yields.
const tagMatched = Array.from(new Set(matchedConnectionIds));
const stepAllow = Array.isArray(target.allowedConnectionIds)
? target.allowedConnectionIds.filter(
(id): id is string => typeof id === "string" && id.length > 0
)
: null;
const effectiveAllow =
stepAllow && stepAllow.length > 0
? tagMatched.filter((id) => stepAllow.includes(id))
: tagMatched;
if (effectiveAllow.length === 0) {
return acc;
}
acc.push({
...target,
allowedConnectionIds: effectiveAllow,
});
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 scoreAutoTargets(
targets: ResolvedComboTarget[],
candidates: AutoProviderCandidate[],
taskType: string | null,
weights: ScoringWeights,
manifestHint?: RoutingHint | null
) {
const targetByExecutionKey = new Map(targets.map((target) => [target.executionKey, target]));
const activeCandidates = candidates.filter((candidate) => candidate.quotaCutoffBlocked !== true);
return activeCandidates
.map((candidate) => {
const baseTarget =
targetByExecutionKey.get(candidate.executionKey) ||
targets.find(
(target) =>
target.stepId === candidate.stepId ||
(target.provider === candidate.provider && target.modelStr === candidate.modelStr)
);
if (!baseTarget) return null;
const target: ResolvedComboTarget = {
...baseTarget,
stepId: candidate.stepId,
executionKey: candidate.executionKey,
modelStr: candidate.modelStr,
provider: candidate.provider,
connectionId: candidate.connectionId ?? baseTarget.connectionId,
};
const factors = calculateFactors(
candidate as ProviderCandidate,
activeCandidates,
taskType ?? "general",
getTaskFitness,
manifestHint ?? undefined
);
let score = calculateScore(factors, weights);
// B17: Quota Share soft-policy deprioritization
if ("quotaSoftPenalty" in candidate && candidate.quotaSoftPenalty === true) {
score *= QUOTA_SOFT_DEPRIORITIZE_FACTOR;
}
// #4540: terminal/transient connection status soft penalty (no hard block).
// A no-fetcher exhausted provider keeps quotaRemaining=100, so without this its
// score would tie a healthy provider's. The penalty pushes it strictly below.
if ("statusPenalty" in candidate && candidate.statusPenalty === true) {
score *= STATUS_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) && localAutoConfig.candidatePool.length > 0)
return eligibleTargets;
// #COMBO-REF: if the combo references other combos via kind:"combo-ref" entries,
// the resolved eligibleTargets already represent the operator's intended pool.
// Expanding to ALL providers would defeat the purpose of the combo-ref constraint
// (e.g. an "auto" combo delegating to a "priority" sub-combo should not pull in
// every model from every active provider).
const rawModels = (combo as Record<string, unknown> | null | undefined)?.models;
if (Array.isArray(rawModels) && rawModels.some((m) => isRecord(m) && m.kind === "combo-ref"))
return eligibleTargets;
try {
const allConnections = await getCachedProviderConnections({ 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;
}
/**
* Derive a STABLE per-conversation session key for combo context-cache pinning when
* the client did not provide an explicit session id (#3825).
*
* Most OpenAI-compatible clients send no session id, so the server-side pin added by
* #3399 (gated on `relayOptions?.sessionId`) never engaged → combos rotated every turn,
* causing upstream prompt-cache misses, cold high-reasoning starts and intermittent
* 504s. We reuse `extractSessionAffinityKey(body)` (the same conversation fingerprint
* used for codex failover affinity), which hashes the first user/system message — stable
* across turns of the same conversation and identical on turn 2 of a continued chat.
*
* Returns null when no stable fingerprint is available (e.g. empty body), in which case
* the caller falls back to NO pinning — preserving prior behavior rather than guessing.
*/
export function deriveComboSessionKey(body: Record<string, unknown>): string | null {
try {
return extractSessionAffinityKey(body) ?? null;
} catch {
return null;
}
}
/**
* Surface TTFT/E2E-latency/tokens-per-second from a historical latency-stats
* entry onto an AutoProviderCandidate's speed-telemetry fields (#6875). Pure
* projection — only positive, finite numbers pass through; anything else is
* omitted so the existing speed-ranking factor (speedRanking.ts, #6011) falls
* back to its own pool-median default instead of scoring on a bad 0/NaN.
*/
export function deriveSpeedTelemetry(
metric: HistoricalLatencyStatsEntry | null
): Pick<AutoProviderCandidate, "avgTtftMs" | "avgE2ELatencyMs" | "avgTokensPerSecond"> {
const positive = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
return {
avgTtftMs: positive(metric?.avgTtftMs),
avgE2ELatencyMs: positive(metric?.avgE2ELatencyMs),
avgTokensPerSecond: positive(metric?.avgTokensPerSecond),
};
}