mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
fix(combo): least-used quota strategy and wildcard UI preservation (#8894)
Validated in post-merge-train sweep (boards clean on release/v3.8.50 tip)
This commit is contained in:
@@ -18,10 +18,18 @@
|
|||||||
* and orderTargetsByResetWindow shares the same rrCounters Map from ./rrState.ts
|
* and orderTargetsByResetWindow shares the same rrCounters Map from ./rrState.ts
|
||||||
* (D7a) so reset-aware tie rotation stays consistent with round-robin routing.
|
* (D7a) so reset-aware tie rotation stays consistent with round-robin routing.
|
||||||
*
|
*
|
||||||
|
* @changes
|
||||||
|
* - [2026-07-24] [Composer] - Exclude Antigravity accounts without stored projectId from reset-aware pool
|
||||||
|
* - [2026-07-24] [Composer] - Skip quota-exhausted and rate-limited connections in reset-aware expansion
|
||||||
|
*
|
||||||
* Pure leaf: this module never imports from the combo barrel.
|
* Pure leaf: this module never imports from the combo barrel.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getRuntimeProviderProfile, type ProviderProfile } from "../accountFallback.ts";
|
import {
|
||||||
|
getRuntimeProviderProfile,
|
||||||
|
isAccountUnavailable,
|
||||||
|
type ProviderProfile,
|
||||||
|
} from "../accountFallback.ts";
|
||||||
import { PRE_SCREEN_CONCURRENCY } from "../comboConfig.ts";
|
import { PRE_SCREEN_CONCURRENCY } from "../comboConfig.ts";
|
||||||
import { getQuotaFetcher } from "../quotaPreflight.ts";
|
import { getQuotaFetcher } from "../quotaPreflight.ts";
|
||||||
import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker";
|
import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker";
|
||||||
@@ -37,6 +45,8 @@ import {
|
|||||||
type QuotaFetchCacheConfig,
|
type QuotaFetchCacheConfig,
|
||||||
} from "./quotaScoring.ts";
|
} from "./quotaScoring.ts";
|
||||||
import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts";
|
import { rankByHeadroom, type HeadroomSaturation } from "./headroomRanking.ts";
|
||||||
|
import { preferAntigravityConnectionsWithStoredProject } from "../antigravityProjectPersistence.ts";
|
||||||
|
import { isQuotaExhaustedForRequest } from "../../../src/domain/quotaCache.ts";
|
||||||
|
|
||||||
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
|
const RESET_AWARE_CONNECTION_CACHE_TTL_MS = 30_000;
|
||||||
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
|
const RESET_AWARE_QUOTA_FETCH_CONCURRENCY = 5;
|
||||||
@@ -75,9 +85,14 @@ async function getQuotaAwareConnectionsForTarget(
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const connections = await getCachedProviderConnections({ provider, isActive: true });
|
const connections = await getCachedProviderConnections({ provider, isActive: true });
|
||||||
const activeConnections = Array.isArray(connections)
|
let activeConnections = Array.isArray(connections)
|
||||||
? (connections as Array<Record<string, unknown>>)
|
? (connections as Array<Record<string, unknown>>)
|
||||||
: [];
|
: [];
|
||||||
|
if (provider === "antigravity" || provider === "agy") {
|
||||||
|
activeConnections = preferAntigravityConnectionsWithStoredProject(
|
||||||
|
activeConnections
|
||||||
|
) as Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!resetAwareConnectionCache.has(provider) &&
|
!resetAwareConnectionCache.has(provider) &&
|
||||||
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
|
resetAwareConnectionCache.size >= MAX_RESET_AWARE_CACHE
|
||||||
@@ -199,6 +214,18 @@ async function expandTargetsByQuotaAwareConnections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const connectionId of connectionIds) {
|
for (const connectionId of connectionIds) {
|
||||||
|
const provider = getResetAwareProvider(target);
|
||||||
|
const connection = connectionById.get(connectionId);
|
||||||
|
if (
|
||||||
|
connection &&
|
||||||
|
typeof connection.rateLimitedUntil === "string" &&
|
||||||
|
isAccountUnavailable(connection.rateLimitedUntil)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (provider && isQuotaExhaustedForRequest(connectionId, provider, target.modelStr || null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
expandedTargets.push({
|
expandedTargets.push({
|
||||||
...target,
|
...target,
|
||||||
connectionId,
|
connectionId,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
normalizeIntelligentRoutingFilter,
|
normalizeIntelligentRoutingFilter,
|
||||||
normalizeIntelligentRoutingConfig,
|
normalizeIntelligentRoutingConfig,
|
||||||
} from "@/lib/combos/intelligentRouting";
|
} from "@/lib/combos/intelligentRouting";
|
||||||
|
import { getComboStepTarget } from "@/lib/combos/steps";
|
||||||
import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage";
|
import { resolveServerErrorMessage } from "@/lib/api/serverErrorMessage";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -570,6 +571,13 @@ function normalizeModelEntry(entry) {
|
|||||||
weight: entry.weight || 0,
|
weight: entry.weight || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (entry?.kind === "provider-wildcard") {
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
model: getComboStepTarget(entry),
|
||||||
|
weight: entry.weight || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...entry,
|
...entry,
|
||||||
model: entry.model,
|
model: entry.model,
|
||||||
@@ -580,6 +588,7 @@ function normalizeModelEntry(entry) {
|
|||||||
function getModelString(entry) {
|
function getModelString(entry) {
|
||||||
if (typeof entry === "string") return entry;
|
if (typeof entry === "string") return entry;
|
||||||
if (entry?.kind === "combo-ref") return entry.comboName;
|
if (entry?.kind === "combo-ref") return entry.comboName;
|
||||||
|
if (entry?.kind === "provider-wildcard") return getComboStepTarget(entry);
|
||||||
return entry.model;
|
return entry.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +644,37 @@ function formatComboEntryDisplay(
|
|||||||
return `Combo → ${normalizedEntry.comboName}`;
|
return `Combo → ${normalizedEntry.comboName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedEntry.kind === "provider-wildcard") {
|
||||||
|
const providerIdentifier = normalizedEntry.providerId;
|
||||||
|
const builderProvider = findBuilderProviderByIdentifier(builderProviders, providerIdentifier);
|
||||||
|
const providerNode = findProviderNodeByIdentifier(providerNodes, providerIdentifier);
|
||||||
|
const providerLabel =
|
||||||
|
builderProvider?.displayName || providerNode?.name || providerIdentifier || "provider";
|
||||||
|
const patternLabel = normalizedEntry.modelPattern || "*";
|
||||||
|
const wildcardLabel = `${providerLabel}/${patternLabel}`;
|
||||||
|
|
||||||
|
if (!includeConnection) {
|
||||||
|
return wildcardLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectionId = normalizedEntry.connectionId || null;
|
||||||
|
const rawConnectionLabel =
|
||||||
|
(connectionId &&
|
||||||
|
builderProvider?.connections?.find((connection) => connection.id === connectionId)
|
||||||
|
?.label) ||
|
||||||
|
normalizedEntry.label ||
|
||||||
|
null;
|
||||||
|
const connectionLabel = rawConnectionLabel
|
||||||
|
? pickDisplayValue([rawConnectionLabel], showFullEmails, rawConnectionLabel)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (connectionId) {
|
||||||
|
return `${wildcardLabel} · ${connectionLabel || `acct ${connectionId.slice(0, 8)}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${wildcardLabel} · dynamic account`;
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseQualifiedModel(normalizedEntry.model);
|
const parsed = parseQualifiedModel(normalizedEntry.model);
|
||||||
if (!parsed) return normalizedEntry.model;
|
if (!parsed) return normalizedEntry.model;
|
||||||
|
|
||||||
@@ -3420,15 +3460,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
|||||||
<div className="text-[10px] text-text-muted truncate">
|
<div className="text-[10px] text-text-muted truncate">
|
||||||
{entry.kind === "combo-ref"
|
{entry.kind === "combo-ref"
|
||||||
? getI18nOrFallback(t, "builderComboRefStep", "Nested combo reference")
|
? getI18nOrFallback(t, "builderComboRefStep", "Nested combo reference")
|
||||||
: entry.connectionId
|
: entry.kind === "provider-wildcard"
|
||||||
? getI18nOrFallback(t, "builderPinnedAccount", "Pinned account")
|
? getI18nOrFallback(
|
||||||
: entry.providerId
|
t,
|
||||||
? getI18nOrFallback(
|
"builderProviderWildcard",
|
||||||
t,
|
"All matching provider models"
|
||||||
"builderDynamicAccountShort",
|
)
|
||||||
"Dynamic account"
|
: entry.connectionId
|
||||||
)
|
? getI18nOrFallback(t, "builderPinnedAccount", "Pinned account")
|
||||||
: getI18nOrFallback(t, "builderLegacyEntry", "Legacy model entry")}
|
: entry.providerId
|
||||||
|
? getI18nOrFallback(
|
||||||
|
t,
|
||||||
|
"builderDynamicAccountShort",
|
||||||
|
"Dynamic account"
|
||||||
|
)
|
||||||
|
: getI18nOrFallback(
|
||||||
|
t,
|
||||||
|
"builderLegacyEntry",
|
||||||
|
"Legacy model entry"
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4542,19 +4592,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
|
|||||||
"builderComboRefStep",
|
"builderComboRefStep",
|
||||||
"Nested combo reference"
|
"Nested combo reference"
|
||||||
)
|
)
|
||||||
: entry.connectionId
|
: entry.kind === "provider-wildcard"
|
||||||
? getI18nOrFallback(t, "builderPinnedAccount", "Pinned account")
|
? getI18nOrFallback(
|
||||||
: entry.providerId
|
t,
|
||||||
? getI18nOrFallback(
|
"builderProviderWildcard",
|
||||||
t,
|
"All matching provider models"
|
||||||
"builderDynamicAccountShort",
|
)
|
||||||
"Dynamic account"
|
: entry.connectionId
|
||||||
)
|
? getI18nOrFallback(t, "builderPinnedAccount", "Pinned account")
|
||||||
: getI18nOrFallback(
|
: entry.providerId
|
||||||
t,
|
? getI18nOrFallback(
|
||||||
"builderLegacyEntry",
|
t,
|
||||||
"Legacy model entry"
|
"builderDynamicAccountShort",
|
||||||
)}
|
"Dynamic account"
|
||||||
|
)
|
||||||
|
: getI18nOrFallback(
|
||||||
|
t,
|
||||||
|
"builderLegacyEntry",
|
||||||
|
"Legacy model entry"
|
||||||
|
)}
|
||||||
{strategy === "weighted" && entry.weight > 0
|
{strategy === "weighted" && entry.weight > 0
|
||||||
? ` · ${entry.weight}%`
|
? ` · ${entry.weight}%`
|
||||||
: ""}
|
: ""}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
* - Active accounts (quota > 0%): refetch every 5 minutes
|
* - Active accounts (quota > 0%): refetch every 5 minutes
|
||||||
* - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes)
|
* - Exhausted accounts: refetch every 5 minutes (or immediately after resetAt passes)
|
||||||
*
|
*
|
||||||
|
* @changes
|
||||||
|
* - [2026-07-24] [Composer] - Scope Antigravity per-model exhaustion to exact model + family weekly windows
|
||||||
|
*
|
||||||
* @module domain/quotaCache
|
* @module domain/quotaCache
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -236,6 +239,43 @@ export function __clearForTests() {
|
|||||||
getState().cache.clear();
|
getState().cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAntigravityQuotaWindowsForModel(
|
||||||
|
quotaNames: string[],
|
||||||
|
requestedModel: string
|
||||||
|
): string[] {
|
||||||
|
const requestedFamily = getAntigravityQuotaFamily(requestedModel);
|
||||||
|
const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, "");
|
||||||
|
const bareModel = cleanRequestedModel.includes("/")
|
||||||
|
? cleanRequestedModel.slice(cleanRequestedModel.lastIndexOf("/") + 1)
|
||||||
|
: cleanRequestedModel;
|
||||||
|
|
||||||
|
if (requestedFamily === "other") {
|
||||||
|
return quotaNames.filter((windowName) => {
|
||||||
|
const bare = windowName.replace(/^(antigravity|agy)\//, "");
|
||||||
|
return bare === bareModel || bare === cleanRequestedModel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyAggregates =
|
||||||
|
requestedFamily === "gemini"
|
||||||
|
? ["gemini_weekly"]
|
||||||
|
: requestedFamily === "claude"
|
||||||
|
? ["claude_gpt_weekly"]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const exactWindows = quotaNames.filter((windowName) => {
|
||||||
|
const bare = windowName.replace(/^(antigravity|agy)\//, "");
|
||||||
|
return bare === bareModel;
|
||||||
|
});
|
||||||
|
const aggregateWindows = familyAggregates.filter((key) => quotaNames.includes(key));
|
||||||
|
const scoped = [...exactWindows, ...aggregateWindows];
|
||||||
|
if (scoped.length > 0) return scoped;
|
||||||
|
|
||||||
|
return quotaNames.filter(
|
||||||
|
(windowName) => getAntigravityQuotaFamily(windowName) === requestedFamily
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isAntigravityQuotaExhausted(
|
function isAntigravityQuotaExhausted(
|
||||||
connectionId: string,
|
connectionId: string,
|
||||||
entry: QuotaCacheEntry,
|
entry: QuotaCacheEntry,
|
||||||
@@ -244,18 +284,13 @@ function isAntigravityQuotaExhausted(
|
|||||||
if (!requestedModel) return entry.exhausted;
|
if (!requestedModel) return entry.exhausted;
|
||||||
const quotaNames = Object.keys(entry.quotas || {});
|
const quotaNames = Object.keys(entry.quotas || {});
|
||||||
if (quotaNames.length === 0) return entry.exhausted;
|
if (quotaNames.length === 0) return entry.exhausted;
|
||||||
const requestedFamily = getAntigravityQuotaFamily(requestedModel);
|
const matchingWindows = resolveAntigravityQuotaWindowsForModel(quotaNames, requestedModel);
|
||||||
const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, "");
|
|
||||||
const matchingWindows = quotaNames.filter((windowName) => {
|
|
||||||
if (requestedFamily === "other") {
|
|
||||||
return windowName.replace(/^(antigravity|agy)\//, "") === cleanRequestedModel;
|
|
||||||
}
|
|
||||||
return getAntigravityQuotaFamily(windowName) === requestedFamily;
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
matchingWindows.length > 0 &&
|
matchingWindows.length > 0 &&
|
||||||
matchingWindows.every(
|
matchingWindows.every(
|
||||||
(windowName) => getQuotaWindowStatus(connectionId, windowName, 100)?.reachedThreshold
|
(windowName) =>
|
||||||
|
getQuotaWindowStatus(connectionId, windowName, DEFAULT_QUOTA_THRESHOLD_PERCENT)
|
||||||
|
?.reachedThreshold
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,7 +308,9 @@ function isCodexQuotaExhausted(
|
|||||||
return (
|
return (
|
||||||
scopedWindowNames.length > 0 &&
|
scopedWindowNames.length > 0 &&
|
||||||
scopedWindowNames.every(
|
scopedWindowNames.every(
|
||||||
(windowName) => getQuotaWindowStatus(connectionId, windowName, 100)?.reachedThreshold
|
(windowName) =>
|
||||||
|
getQuotaWindowStatus(connectionId, windowName, DEFAULT_QUOTA_THRESHOLD_PERCENT)
|
||||||
|
?.reachedThreshold
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -512,7 +549,11 @@ export function getQuotaWindowStatus(
|
|||||||
usedPercentage,
|
usedPercentage,
|
||||||
resetAt,
|
resetAt,
|
||||||
// If reset time has already passed, avoid stale cached percentages blocking selection.
|
// If reset time has already passed, avoid stale cached percentages blocking selection.
|
||||||
reachedThreshold: windowExpired ? false : usedPercentage >= thresholdPercent,
|
reachedThreshold: windowExpired
|
||||||
|
? false
|
||||||
|
: remainingPercentage <= 0
|
||||||
|
? true
|
||||||
|
: usedPercentage >= thresholdPercent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export interface ComboControlCenterTargetHealth {
|
|||||||
|
|
||||||
export interface ComboControlCenterTarget {
|
export interface ComboControlCenterTarget {
|
||||||
id: string;
|
id: string;
|
||||||
kind: "model" | "combo-ref";
|
kind: "model" | "combo-ref" | "provider-wildcard";
|
||||||
index: number;
|
index: number;
|
||||||
label: string;
|
label: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -134,6 +134,9 @@ function getStepTags(step: ComboStep): string[] {
|
|||||||
function getStepLabel(step: ComboStep): string {
|
function getStepLabel(step: ComboStep): string {
|
||||||
if (step.label) return step.label;
|
if (step.label) return step.label;
|
||||||
if (step.kind === "combo-ref") return `Combo → ${step.comboName}`;
|
if (step.kind === "combo-ref") return `Combo → ${step.comboName}`;
|
||||||
|
if (step.kind === "provider-wildcard") {
|
||||||
|
return `All ${step.providerId}/${step.modelPattern}`;
|
||||||
|
}
|
||||||
return step.model;
|
return step.model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,12 +156,19 @@ export function getComboControlCenterTargets(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return steps.map((step, index) => {
|
return steps.map((step, index) => {
|
||||||
const model = step.kind === "combo-ref" ? step.comboName : step.model;
|
const model =
|
||||||
|
step.kind === "combo-ref"
|
||||||
|
? step.comboName
|
||||||
|
: step.kind === "provider-wildcard"
|
||||||
|
? `${step.providerId}/${step.modelPattern}`
|
||||||
|
: step.model;
|
||||||
const healthEntry = healthByStepId.get(step.id) || healthByModel.get(model) || null;
|
const healthEntry = healthByStepId.get(step.id) || healthByModel.get(model) || null;
|
||||||
const provider =
|
const provider =
|
||||||
step.kind === "model"
|
step.kind === "model"
|
||||||
? step.providerId || providerFromModel(step.model) || healthEntry?.provider || null
|
? step.providerId || providerFromModel(step.model) || healthEntry?.provider || null
|
||||||
: null;
|
: step.kind === "provider-wildcard"
|
||||||
|
? step.providerId
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: step.id,
|
id: step.id,
|
||||||
@@ -167,7 +177,10 @@ export function getComboControlCenterTargets(
|
|||||||
label: getStepLabel(step),
|
label: getStepLabel(step),
|
||||||
model,
|
model,
|
||||||
provider,
|
provider,
|
||||||
connectionId: step.kind === "model" ? step.connectionId || null : null,
|
connectionId:
|
||||||
|
step.kind === "model" || step.kind === "provider-wildcard"
|
||||||
|
? step.connectionId || null
|
||||||
|
: null,
|
||||||
weight: step.weight || 0,
|
weight: step.weight || 0,
|
||||||
tags: getStepTags(step),
|
tags: getStepTags(step),
|
||||||
health: healthEntry,
|
health: healthEntry,
|
||||||
@@ -252,7 +265,9 @@ export function summarizeComboControlCenter(
|
|||||||
strategy: combo.strategy || "priority",
|
strategy: combo.strategy || "priority",
|
||||||
isActive: combo.isActive !== false,
|
isActive: combo.isActive !== false,
|
||||||
targetCount: targets.length,
|
targetCount: targets.length,
|
||||||
modelTargetCount: targets.filter((target) => target.kind === "model").length,
|
modelTargetCount: targets.filter(
|
||||||
|
(target) => target.kind === "model" || target.kind === "provider-wildcard"
|
||||||
|
).length,
|
||||||
nestedComboCount: targets.filter((target) => target.kind === "combo-ref").length,
|
nestedComboCount: targets.filter((target) => target.kind === "combo-ref").length,
|
||||||
providerCount: providers.size,
|
providerCount: providers.size,
|
||||||
totalRequests,
|
totalRequests,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* @file steps.ts
|
||||||
|
* @description Combo step normalization helpers for model, combo-ref, provider-wildcard, and routing metadata.
|
||||||
|
*
|
||||||
|
* @changes
|
||||||
|
* - [2026-07-25] [Composer] - Preserve provider-wildcard steps during combo normalization
|
||||||
|
*/
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
export const COMBO_SCHEMA_VERSION = 2;
|
export const COMBO_SCHEMA_VERSION = 2;
|
||||||
@@ -28,7 +35,18 @@ export interface ComboRefStep {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ComboStep = ComboModelStep | ComboRefStep;
|
export interface ComboProviderWildcardStep {
|
||||||
|
id: string;
|
||||||
|
kind: "provider-wildcard";
|
||||||
|
providerId: string;
|
||||||
|
modelPattern: string;
|
||||||
|
connectionId?: string | null;
|
||||||
|
allowedConnectionIds?: string[] | null;
|
||||||
|
weight: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ComboStep = ComboModelStep | ComboRefStep | ComboProviderWildcardStep;
|
||||||
|
|
||||||
type ComboCollectionLike =
|
type ComboCollectionLike =
|
||||||
| Array<{ name?: unknown } | string>
|
| Array<{ name?: unknown } | string>
|
||||||
@@ -103,6 +121,18 @@ function parseProviderId(model: string): string | null {
|
|||||||
return providerId.length > 0 ? providerId : null;
|
return providerId.length > 0 ? providerId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseProviderWildcardPattern(
|
||||||
|
target: string
|
||||||
|
): { providerId: string; modelPattern: string } | null {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
const slashIndex = trimmed.indexOf("/");
|
||||||
|
if (slashIndex <= 0) return null;
|
||||||
|
const providerId = trimmed.slice(0, slashIndex).trim();
|
||||||
|
const modelPattern = trimmed.slice(slashIndex + 1).trim();
|
||||||
|
if (!providerId || !modelPattern.includes("*")) return null;
|
||||||
|
return { providerId, modelPattern };
|
||||||
|
}
|
||||||
|
|
||||||
function toFullModelString(model: string, providerId?: string | null): string {
|
function toFullModelString(model: string, providerId?: string | null): string {
|
||||||
const trimmedModel = model.trim();
|
const trimmedModel = model.trim();
|
||||||
if (trimmedModel.includes("/")) return trimmedModel;
|
if (trimmedModel.includes("/")) return trimmedModel;
|
||||||
@@ -124,12 +154,9 @@ function buildStepId(
|
|||||||
index: number,
|
index: number,
|
||||||
seed: string
|
seed: string
|
||||||
) {
|
) {
|
||||||
const parts = [
|
const kindSlug =
|
||||||
slugify(comboName || "combo"),
|
kind === "combo-ref" ? "ref" : kind === "provider-wildcard" ? "wildcard" : "model";
|
||||||
kind === "combo-ref" ? "ref" : "model",
|
const parts = [slugify(comboName || "combo"), kindSlug, String(index + 1), slugify(seed)];
|
||||||
String(index + 1),
|
|
||||||
slugify(seed),
|
|
||||||
];
|
|
||||||
return parts.join("-").slice(0, 200);
|
return parts.join("-").slice(0, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +214,11 @@ export function getComboStepTarget(
|
|||||||
|
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
if (value.kind === "combo-ref") return toTrimmedString(value.comboName);
|
if (value.kind === "combo-ref") return toTrimmedString(value.comboName);
|
||||||
|
if (value.kind === "provider-wildcard") {
|
||||||
|
const providerId = toTrimmedString(value.providerId);
|
||||||
|
const modelPattern = toTrimmedString(value.modelPattern) || "*";
|
||||||
|
return providerId ? `${providerId}/${modelPattern}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
const rawModel = toTrimmedString(value.model);
|
const rawModel = toTrimmedString(value.model);
|
||||||
if (!rawModel) return null;
|
if (!rawModel) return null;
|
||||||
@@ -223,6 +255,22 @@ export function normalizeComboStep(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wildcardPattern = parseProviderWildcardPattern(target);
|
||||||
|
if (wildcardPattern) {
|
||||||
|
return {
|
||||||
|
id: buildStepId(
|
||||||
|
"provider-wildcard",
|
||||||
|
comboName,
|
||||||
|
index,
|
||||||
|
`${wildcardPattern.providerId}/${wildcardPattern.modelPattern}`
|
||||||
|
),
|
||||||
|
kind: "provider-wildcard",
|
||||||
|
providerId: wildcardPattern.providerId,
|
||||||
|
modelPattern: wildcardPattern.modelPattern,
|
||||||
|
weight: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const providerId = parseProviderId(target);
|
const providerId = parseProviderId(target);
|
||||||
return {
|
return {
|
||||||
id: buildStepId("model", comboName, index, target),
|
id: buildStepId("model", comboName, index, target),
|
||||||
@@ -252,6 +300,41 @@ export function normalizeComboStep(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value.kind === "provider-wildcard") {
|
||||||
|
const providerId =
|
||||||
|
toTrimmedString(value.providerId) ||
|
||||||
|
toTrimmedString(value.provider) ||
|
||||||
|
parseProviderId(toTrimmedString(value.model) || "");
|
||||||
|
if (!providerId) return null;
|
||||||
|
const modelPattern = toTrimmedString(value.modelPattern) || "*";
|
||||||
|
const connectionId =
|
||||||
|
value.connectionId === null ? null : toTrimmedString(value.connectionId) || undefined;
|
||||||
|
const allowedConnectionIds = Array.isArray(value.allowedConnectionIds)
|
||||||
|
? value.allowedConnectionIds
|
||||||
|
.map((connId) => toTrimmedString(connId))
|
||||||
|
.filter((connId): connId is string => !!connId)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
id:
|
||||||
|
explicitId ||
|
||||||
|
buildStepId(
|
||||||
|
"provider-wildcard",
|
||||||
|
comboName,
|
||||||
|
index,
|
||||||
|
connectionId
|
||||||
|
? `${providerId}/${modelPattern}:${connectionId}`
|
||||||
|
: `${providerId}/${modelPattern}`
|
||||||
|
),
|
||||||
|
kind: "provider-wildcard",
|
||||||
|
providerId,
|
||||||
|
modelPattern,
|
||||||
|
...(connectionId !== undefined ? { connectionId } : {}),
|
||||||
|
weight,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const rawModel = toTrimmedString(value.model);
|
const rawModel = toTrimmedString(value.model);
|
||||||
if (!rawModel) return null;
|
if (!rawModel) return null;
|
||||||
const isExplicitModel = value.kind === "model";
|
const isExplicitModel = value.kind === "model";
|
||||||
@@ -261,6 +344,36 @@ export function normalizeComboStep(
|
|||||||
toTrimmedString(value.provider) ||
|
toTrimmedString(value.provider) ||
|
||||||
parseProviderId(rawModel);
|
parseProviderId(rawModel);
|
||||||
|
|
||||||
|
const wildcardPattern = parseProviderWildcardPattern(rawModel);
|
||||||
|
if (wildcardPattern) {
|
||||||
|
const connectionId =
|
||||||
|
value.connectionId === null ? null : toTrimmedString(value.connectionId) || undefined;
|
||||||
|
const allowedConnectionIds = Array.isArray(value.allowedConnectionIds)
|
||||||
|
? value.allowedConnectionIds
|
||||||
|
.map((connId) => toTrimmedString(connId))
|
||||||
|
.filter((connId): connId is string => !!connId)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
id:
|
||||||
|
explicitId ||
|
||||||
|
buildStepId(
|
||||||
|
"provider-wildcard",
|
||||||
|
comboName,
|
||||||
|
index,
|
||||||
|
connectionId
|
||||||
|
? `${wildcardPattern.providerId}/${wildcardPattern.modelPattern}:${connectionId}`
|
||||||
|
: `${wildcardPattern.providerId}/${wildcardPattern.modelPattern}`
|
||||||
|
),
|
||||||
|
kind: "provider-wildcard",
|
||||||
|
providerId: wildcardPattern.providerId,
|
||||||
|
modelPattern: wildcardPattern.modelPattern,
|
||||||
|
...(connectionId !== undefined ? { connectionId } : {}),
|
||||||
|
weight,
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
|
if (!isExplicitModel && shouldTreatAsComboRef(rawModel, providerId, options)) {
|
||||||
return {
|
return {
|
||||||
id: explicitId || buildStepId("combo-ref", comboName, index, rawModel),
|
id: explicitId || buildStepId("combo-ref", comboName, index, rawModel),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type JsonRecord = Record<string, unknown>;
|
|||||||
|
|
||||||
interface StatementLike<TRow = unknown> {
|
interface StatementLike<TRow = unknown> {
|
||||||
get: (...params: unknown[]) => TRow | undefined;
|
get: (...params: unknown[]) => TRow | undefined;
|
||||||
|
all: (...params: unknown[]) => TRow[];
|
||||||
run: (...params: unknown[]) => { changes: number };
|
run: (...params: unknown[]) => { changes: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,3 +254,40 @@ export function deleteSessionModelHistory(sessionId: string, comboName: string):
|
|||||||
.run(sessionId, comboName);
|
.run(sessionId, comboName);
|
||||||
return result.changes ?? 0;
|
return result.changes ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage counts for ALL models in a session's history.
|
||||||
|
* Returns a Map<{model}> -> {count} for least-used strategy.
|
||||||
|
*
|
||||||
|
* Queries the session_model_history table and aggregates by model_str.
|
||||||
|
* Can optionally filter by connectionId if provided.
|
||||||
|
*
|
||||||
|
* @param connectionId - Optional connection ID to filter by. If not provided, returns all connections.
|
||||||
|
* @returns Promise<Map<string, number>> of model strings to their usage count.
|
||||||
|
*/
|
||||||
|
export async function getSessionModelUsageCounts(
|
||||||
|
connectionId?: string
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
const db = getDbInstance() as any;
|
||||||
|
|
||||||
|
let sql = `SELECT model_str, COUNT(*) as count
|
||||||
|
FROM session_model_history
|
||||||
|
WHERE 1=1`;
|
||||||
|
const params: unknown[] = [];
|
||||||
|
|
||||||
|
if (connectionId) {
|
||||||
|
sql += ` AND connection_id = ?`;
|
||||||
|
params.push(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` GROUP BY model_str ORDER BY count ASC`;
|
||||||
|
|
||||||
|
const rows = db.prepare(sql).all(...params) as Array<{ model_str: string; count: number }>;
|
||||||
|
|
||||||
|
const usageMap = new Map<string, number>();
|
||||||
|
rows.forEach((row: { model_str: string; count: number }) => {
|
||||||
|
usageMap.set(row.model_str, row.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
return usageMap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ test("getComboControlCenterTargets normalizes legacy, structured and nested comb
|
|||||||
assert.equal(targets[2].label, "Combo → cheap-fallback");
|
assert.equal(targets[2].label, "Combo → cheap-fallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("getComboControlCenterTargets normalizes provider-wildcard steps", () => {
|
||||||
|
const targets = getComboControlCenterTargets({
|
||||||
|
name: "alibabafree",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "alibaba-main",
|
||||||
|
kind: "provider-wildcard",
|
||||||
|
providerId: "alibaba",
|
||||||
|
modelPattern: "*",
|
||||||
|
connectionId: "conn-main",
|
||||||
|
weight: 0,
|
||||||
|
label: "main",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0].kind, "provider-wildcard");
|
||||||
|
assert.equal(targets[0].model, "alibaba/*");
|
||||||
|
assert.equal(targets[0].provider, "alibaba");
|
||||||
|
assert.equal(targets[0].connectionId, "conn-main");
|
||||||
|
assert.equal(targets[0].label, "main");
|
||||||
|
});
|
||||||
|
|
||||||
test("summarizeComboControlCenter combines health and runtime metrics", () => {
|
test("summarizeComboControlCenter combines health and runtime metrics", () => {
|
||||||
const summary = summarizeComboControlCenter(
|
const summary = summarizeComboControlCenter(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user