mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Merge PR #2019 and resolve conflicts
This commit is contained in:
@@ -17,6 +17,11 @@ export const FETCH_TIMEOUT_MS = upstreamTimeouts.fetchTimeoutMs;
|
||||
// idle for this duration. Override with STREAM_IDLE_TIMEOUT_MS env var.
|
||||
export const STREAM_IDLE_TIMEOUT_MS = upstreamTimeouts.streamIdleTimeoutMs;
|
||||
|
||||
// Timeout for the first useful SSE event. Keep this much shorter than the
|
||||
// post-start idle timeout so slow-thinking models can keep streaming after the
|
||||
// first token, while dead 200 OK streams fail fast enough for combo fallback.
|
||||
export const STREAM_READINESS_TIMEOUT_MS = upstreamTimeouts.streamReadinessTimeoutMs;
|
||||
|
||||
// Timeout for reading the full response body after headers arrive (ms).
|
||||
// Prevents indefinite hangs when the upstream sends headers but stalls on the body.
|
||||
// Defaults to FETCH_TIMEOUT_MS. Override with FETCH_BODY_TIMEOUT_MS env var.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../utils/stream.ts";
|
||||
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
|
||||
import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts";
|
||||
import { createSseHeartbeatTransform } from "../utils/sseHeartbeat.ts";
|
||||
import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts";
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.ts";
|
||||
import { createRequestLogger } from "../utils/requestLogger.ts";
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
MAX_TOOLS_LIMIT,
|
||||
PROVIDER_MAX_TOKENS,
|
||||
STREAM_IDLE_TIMEOUT_MS,
|
||||
STREAM_READINESS_TIMEOUT_MS,
|
||||
} from "../config/constants.ts";
|
||||
import {
|
||||
classifyProviderError,
|
||||
@@ -78,6 +80,11 @@ import {
|
||||
providerSupportsCaching,
|
||||
} from "../utils/cacheControlPolicy.ts";
|
||||
import { getCachedSettings } from "@/lib/db/readCache";
|
||||
import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier";
|
||||
import {
|
||||
getCodexRequestDefaults,
|
||||
normalizeCodexServiceTier,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts";
|
||||
import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts";
|
||||
import {
|
||||
@@ -976,6 +983,7 @@ export async function handleChatCore({
|
||||
comboStepId = null,
|
||||
comboExecutionKey = null,
|
||||
disableEmergencyFallback = false,
|
||||
cachedSettings = null,
|
||||
}) {
|
||||
let { provider, model, extendedContext } = modelInfo;
|
||||
const requestedModel =
|
||||
@@ -990,6 +998,21 @@ export async function handleChatCore({
|
||||
log?.info?.("STAGE_TRACE", `${traceId} ${label} t=${elapsed}ms${suffix}`);
|
||||
};
|
||||
let tokensCompressed: number | null = null;
|
||||
let effectiveServiceTier: "standard" | "priority" = "standard";
|
||||
const resolveEffectiveServiceTier = (requestBody?: unknown): "standard" | "priority" => {
|
||||
if (provider !== "codex") return "standard";
|
||||
const requestRecord =
|
||||
requestBody && typeof requestBody === "object" && !Array.isArray(requestBody)
|
||||
? (requestBody as Record<string, unknown>)
|
||||
: {};
|
||||
const rawServiceTier = requestRecord.service_tier;
|
||||
if (typeof rawServiceTier === "string" && rawServiceTier.trim().length > 0) {
|
||||
return normalizeCodexServiceTier(rawServiceTier) ? "priority" : "standard";
|
||||
}
|
||||
return getCodexRequestDefaults(credentials?.providerSpecificData).serviceTier === "priority"
|
||||
? "priority"
|
||||
: "standard";
|
||||
};
|
||||
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
|
||||
saveRequestUsage({
|
||||
provider: provider || "unknown",
|
||||
@@ -1004,6 +1027,7 @@ export async function handleChatCore({
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
serviceTier: effectiveServiceTier,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -1093,7 +1117,9 @@ export async function handleChatCore({
|
||||
| undefined)
|
||||
: undefined;
|
||||
const idempotentCost = idempotentUsage
|
||||
? await calculateCost(provider, model, idempotentUsage as Record<string, number>)
|
||||
? await calculateCost(provider, model, idempotentUsage as Record<string, number>, {
|
||||
serviceTier: effectiveServiceTier,
|
||||
})
|
||||
: 0;
|
||||
return {
|
||||
success: true,
|
||||
@@ -1411,7 +1437,9 @@ export async function handleChatCore({
|
||||
nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath)
|
||||
? false
|
||||
: resolveStreamFlag(body?.stream, acceptHeader);
|
||||
const settings = await getCachedSettings();
|
||||
const settings = cachedSettings ?? (await getCachedSettings());
|
||||
credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings);
|
||||
effectiveServiceTier = resolveEffectiveServiceTier(body);
|
||||
setGeminiThoughtSignatureMode(settings.antigravitySignatureCacheMode);
|
||||
const semanticCacheEnabled = settings.semanticCacheEnabled !== false;
|
||||
|
||||
@@ -1449,7 +1477,9 @@ export async function handleChatCore({
|
||||
extractUsageFromResponse(cached as Record<string, unknown>, provider) ||
|
||||
((cached as Record<string, unknown>)?.usage as Record<string, unknown> | undefined);
|
||||
const cachedCost = cachedUsage
|
||||
? await calculateCost(provider, model, cachedUsage as Record<string, number>)
|
||||
? await calculateCost(provider, model, cachedUsage as Record<string, number>, {
|
||||
serviceTier: effectiveServiceTier,
|
||||
})
|
||||
: 0;
|
||||
persistAttemptLogs({
|
||||
status: 200,
|
||||
@@ -1907,7 +1937,8 @@ export async function handleChatCore({
|
||||
effectiveModel ?? "",
|
||||
{
|
||||
input: tokensSaved,
|
||||
}
|
||||
},
|
||||
{ serviceTier: effectiveServiceTier }
|
||||
);
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -2993,6 +3024,7 @@ export async function handleChatCore({
|
||||
providerUrl = result.url;
|
||||
providerHeaders = result.headers;
|
||||
finalBody = result.transformedBody;
|
||||
effectiveServiceTier = resolveEffectiveServiceTier(finalBody);
|
||||
claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
|
||||
targetFormat,
|
||||
finalBody,
|
||||
@@ -3726,6 +3758,7 @@ export async function handleChatCore({
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
serviceTier: effectiveServiceTier,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to save usage stats:", err.message);
|
||||
});
|
||||
@@ -3858,7 +3891,9 @@ export async function handleChatCore({
|
||||
(translatedResponse?.usage && typeof translatedResponse.usage === "object"
|
||||
? translatedResponse.usage
|
||||
: null);
|
||||
const estimatedCost = responseUsage ? await calculateCost(provider, model, responseUsage) : 0;
|
||||
const estimatedCost = responseUsage
|
||||
? await calculateCost(provider, model, responseUsage, { serviceTier: effectiveServiceTier })
|
||||
: 0;
|
||||
|
||||
if (postCallGuardrails.blocked) {
|
||||
const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail";
|
||||
@@ -3952,7 +3987,7 @@ export async function handleChatCore({
|
||||
|
||||
// Streaming response
|
||||
const streamReadiness = await ensureStreamReadiness(providerResponse, {
|
||||
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
||||
timeoutMs: STREAM_READINESS_TIMEOUT_MS,
|
||||
provider,
|
||||
model,
|
||||
log,
|
||||
@@ -4000,8 +4035,9 @@ export async function handleChatCore({
|
||||
)
|
||||
),
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
[OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS",
|
||||
...buildOmniRouteResponseMetaHeaders({
|
||||
provider,
|
||||
@@ -4070,6 +4106,7 @@ export async function handleChatCore({
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
serviceTier: effectiveServiceTier,
|
||||
}).catch((err) => {
|
||||
console.error("Failed to save usage stats:", err.message);
|
||||
});
|
||||
@@ -4088,7 +4125,7 @@ export async function handleChatCore({
|
||||
});
|
||||
|
||||
if (apiKeyInfo?.id && streamUsage) {
|
||||
calculateCost(provider, model, streamUsage)
|
||||
calculateCost(provider, model, streamUsage, { serviceTier: effectiveServiceTier })
|
||||
.then((estimatedCost) => {
|
||||
if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost);
|
||||
})
|
||||
@@ -4227,6 +4264,9 @@ export async function handleChatCore({
|
||||
} else {
|
||||
finalStream = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
||||
}
|
||||
finalStream = finalStream.pipeThrough(
|
||||
createSseHeartbeatTransform({ signal: streamController.signal })
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
* Supports: priority, weighted, round-robin, random, least-used, cost-optimized,
|
||||
* strict-random, auto, fill-first, p2c, lkgp, context-optimized, and context-relay strategies
|
||||
* reset-aware, strict-random, auto, fill-first, p2c, lkgp, context-optimized,
|
||||
* and context-relay strategies
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import { recordComboIntent, recordComboRequest, getComboMetrics } from "./comboM
|
||||
import { resolveComboConfig, getDefaultComboConfig } from "./comboConfig.ts";
|
||||
import { maybeGenerateHandoff, resolveContextRelayConfig } from "./contextHandoff.ts";
|
||||
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";
|
||||
@@ -72,6 +74,12 @@ 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);
|
||||
}
|
||||
@@ -88,6 +96,18 @@ const DEFAULT_MODEL_P95_MS = {
|
||||
"deepseek-chat": 2000,
|
||||
};
|
||||
const MIN_HISTORY_SAMPLES = 10;
|
||||
const RESET_AWARE_SESSION_WINDOW_MS = 5 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_WEEKLY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const RESET_AWARE_REMAINING_WEIGHT = 0.55;
|
||||
const RESET_AWARE_RESET_WEIGHT = 0.45;
|
||||
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,
|
||||
};
|
||||
|
||||
export type ResolvedComboTarget = {
|
||||
kind: "model";
|
||||
@@ -212,6 +232,11 @@ export async function validateResponseQuality(
|
||||
// Resets on server restart (by design — no stale state)
|
||||
const rrCounters = new Map();
|
||||
|
||||
const resetAwareConnectionCache = new Map<
|
||||
string,
|
||||
{ fetchedAt: number; connections: Array<Record<string, unknown>> }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Normalize a model entry to { model, weight }
|
||||
* Supports both legacy string format and new object format
|
||||
@@ -699,6 +724,363 @@ function orderTargetsByPowerOfTwoChoices(targets: ResolvedComboTarget[], comboNa
|
||||
return [targets[selectedIndex], ...targets.filter((_, index) => index !== selectedIndex)];
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
|
||||
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 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
|
||||
): number {
|
||||
return (
|
||||
RESET_AWARE_REMAINING_WEIGHT * clamp01(remaining) +
|
||||
RESET_AWARE_RESET_WEIGHT * getResetUrgency(resetAt, windowMs)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
const weeklyScore = scoreQuotaWindow(
|
||||
weeklyRemaining,
|
||||
weeklyWindow?.resetAt ?? normalizeResetAt(quota.resetAt),
|
||||
RESET_AWARE_WEEKLY_WINDOW_MS
|
||||
);
|
||||
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>>)
|
||||
: [];
|
||||
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 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,
|
||||
fetcher(target.connectionId, connectionById.get(target.connectionId)).catch((error) => {
|
||||
log.warn?.("COMBO", "Reset-aware quota fetch failed.", {
|
||||
comboName,
|
||||
connectionId: target.connectionId,
|
||||
err: error,
|
||||
operation: "quotaFetch",
|
||||
provider,
|
||||
});
|
||||
return null;
|
||||
})
|
||||
);
|
||||
}
|
||||
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;
|
||||
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 toTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
@@ -1076,6 +1458,7 @@ export async function handleComboChat({
|
||||
allCombos,
|
||||
relayOptions,
|
||||
signal,
|
||||
apiKeyAllowedConnections = null,
|
||||
}) {
|
||||
const strategy = normalizeRoutingStrategy(combo.strategy || "priority");
|
||||
const relayConfig =
|
||||
@@ -1284,7 +1667,8 @@ export async function handleComboChat({
|
||||
? resolveComboConfig(combo, settings)
|
||||
: { ...getDefaultComboConfig(), ...(combo.config || {}) };
|
||||
const maxRetries = config.maxRetries ?? 1;
|
||||
const retryDelayMs = config.retryDelayMs ?? 2000;
|
||||
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
|
||||
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
|
||||
|
||||
let orderedTargets =
|
||||
strategy === "weighted"
|
||||
@@ -1516,6 +1900,18 @@ export async function handleComboChat({
|
||||
}
|
||||
}
|
||||
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 === "context-optimized") {
|
||||
orderedTargets = sortTargetsByContextSize(orderedTargets);
|
||||
log.info("COMBO", `Context-optimized ordering: largest first (${orderedTargets[0]?.modelStr})`);
|
||||
@@ -1671,17 +2067,19 @@ export async function handleComboChat({
|
||||
|
||||
// Record last known good provider (LKGP) for this combo/model (#919)
|
||||
if (provider) {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn("COMBO", "Failed to record Last Known Good Provider. This is non-fatal.", {
|
||||
err,
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return quality.clonedResponse ?? result;
|
||||
@@ -1785,8 +2183,8 @@ export async function handleComboChat({
|
||||
log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status });
|
||||
|
||||
const fallbackWaitMs =
|
||||
retryDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
||||
? Math.min(cooldownMs, retryDelayMs)
|
||||
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
||||
? Math.min(cooldownMs, fallbackDelayMs)
|
||||
: 0;
|
||||
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
|
||||
log.info("COMBO", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
|
||||
@@ -1873,7 +2271,8 @@ async function handleRoundRobinCombo({
|
||||
const concurrency = config.concurrencyPerModel ?? 3;
|
||||
const queueTimeout = config.queueTimeoutMs ?? 30000;
|
||||
const maxRetries = config.maxRetries ?? 1;
|
||||
const retryDelayMs = config.retryDelayMs ?? 2000;
|
||||
const retryDelayMs = resolveDelayMs(config.retryDelayMs, 2000);
|
||||
const fallbackDelayMs = resolveDelayMs(config.fallbackDelayMs, 0);
|
||||
|
||||
const orderedTargets = resolveComboTargets(combo, allCombos);
|
||||
const filteredTargets = await applyRequestTagRouting(orderedTargets, body, log);
|
||||
@@ -1997,21 +2396,23 @@ async function handleRoundRobinCombo({
|
||||
});
|
||||
recordedAttempts++;
|
||||
if (provider) {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO-RR",
|
||||
"Failed to record Last Known Good Provider. This is non-fatal.",
|
||||
{
|
||||
err,
|
||||
}
|
||||
);
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const { setLKGP } = await import("../../src/lib/localDb");
|
||||
await Promise.all([
|
||||
setLKGP(combo.name, target.executionKey, provider),
|
||||
setLKGP(combo.name, combo.id || combo.name, provider),
|
||||
]);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
"COMBO-RR",
|
||||
"Failed to record Last Known Good Provider. This is non-fatal.",
|
||||
{
|
||||
err,
|
||||
}
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2131,8 +2532,8 @@ async function handleRoundRobinCombo({
|
||||
log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status });
|
||||
|
||||
const fallbackWaitMs =
|
||||
retryDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
||||
? Math.min(cooldownMs, retryDelayMs)
|
||||
fallbackDelayMs > 0 && cooldownMs > 0 && cooldownMs <= MAX_FALLBACK_WAIT_MS
|
||||
? Math.min(cooldownMs, fallbackDelayMs)
|
||||
: 0;
|
||||
if ([502, 503, 504].includes(result.status) && fallbackWaitMs > 0) {
|
||||
log.info("COMBO-RR", `Waiting ${fallbackWaitMs}ms before fallback to next model`);
|
||||
|
||||
@@ -9,8 +9,9 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
strategy: "priority",
|
||||
maxRetries: 1,
|
||||
retryDelayMs: 2000,
|
||||
concurrencyPerModel: 3,
|
||||
queueTimeoutMs: 30000,
|
||||
fallbackDelayMs: 0,
|
||||
concurrencyPerModel: 3, // max simultaneous requests per model (round-robin)
|
||||
queueTimeoutMs: 30000, // max wait time in semaphore queue (round-robin)
|
||||
handoffThreshold: 0.85,
|
||||
handoffModel: "",
|
||||
handoffProviders: ["codex"],
|
||||
@@ -18,6 +19,10 @@ const DEFAULT_COMBO_CONFIG = {
|
||||
maxComboDepth: 3,
|
||||
trackMetrics: true,
|
||||
manifestRouting: false,
|
||||
resetAwareSessionWeight: 0.35,
|
||||
resetAwareWeeklyWeight: 0.65,
|
||||
resetAwareTieBandPercent: 5,
|
||||
resetAwareExhaustionGuardPercent: 10,
|
||||
};
|
||||
|
||||
const LEGACY_COMBO_RESILIENCE_KEYS = new Set([
|
||||
|
||||
@@ -35,6 +35,10 @@ export function registerQuotaFetcher(provider: string, fetcher: QuotaFetcher): v
|
||||
quotaFetcherRegistry.set(provider, fetcher);
|
||||
}
|
||||
|
||||
export function getQuotaFetcher(provider: string): QuotaFetcher | undefined {
|
||||
return quotaFetcherRegistry.get(provider) || quotaFetcherRegistry.get(provider.toLowerCase());
|
||||
}
|
||||
|
||||
export function isQuotaPreflightEnabled(connection: Record<string, unknown>): boolean {
|
||||
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
|
||||
return psd?.quotaPreflightEnabled === true;
|
||||
@@ -49,7 +53,7 @@ export async function preflightQuota(
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
const fetcher = quotaFetcherRegistry.get(provider);
|
||||
const fetcher = getQuotaFetcher(provider);
|
||||
if (!fetcher) {
|
||||
return { proceed: true };
|
||||
}
|
||||
|
||||
@@ -521,7 +521,46 @@ async function getCrofUsage(apiKey: string) {
|
||||
return { quotas };
|
||||
}
|
||||
|
||||
const GLM_QUOTA_ORDER = ["5 Hours Quota", "Weekly Quota", "Monthly Tools", "Tokens", "Time Limit"];
|
||||
|
||||
function getGlmQuotaLabel(type: unknown, unit: unknown): string | null {
|
||||
const normalized = typeof type === "string" ? type.trim().toUpperCase() : "";
|
||||
const unitValue = toNumber(unit, -1);
|
||||
|
||||
switch (normalized) {
|
||||
case "TOKENS_LIMIT":
|
||||
case "TOKEN_LIMIT":
|
||||
if (unitValue === 3) return "5 Hours Quota";
|
||||
if (unitValue === 6) return "Weekly Quota";
|
||||
return "Tokens";
|
||||
case "TIME_LIMIT":
|
||||
case "TIME_USAGE_LIMIT":
|
||||
if (unitValue === 5) return "Monthly Tools";
|
||||
return "Time Limit";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function orderGlmQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||||
const ordered: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const key of GLM_QUOTA_ORDER) {
|
||||
if (quotas[key]) ordered[key] = quotas[key];
|
||||
}
|
||||
|
||||
for (const [key, quota] of Object.entries(quotas)) {
|
||||
if (!ordered[key]) ordered[key] = quota;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string, unknown>) {
|
||||
if (!apiKey) {
|
||||
return { message: "API key not available. Add a coding plan API key to view usage." };
|
||||
}
|
||||
|
||||
const quotaUrl = getGlmQuotaUrl(providerSpecificData);
|
||||
|
||||
const res = await fetch(quotaUrl, {
|
||||
@@ -543,13 +582,14 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
|
||||
for (const limit of limits) {
|
||||
const src = toRecord(limit);
|
||||
if (src.type !== "TOKENS_LIMIT") continue;
|
||||
const label = getGlmQuotaLabel(src.type, src.unit);
|
||||
if (!label) continue;
|
||||
|
||||
const usedPercent = toNumber(src.percentage, 0);
|
||||
const resetMs = toNumber(src.nextResetTime, 0);
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
quotas["session"] = {
|
||||
quotas[label] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
@@ -564,7 +604,7 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "Unknown";
|
||||
|
||||
return { plan, quotas };
|
||||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -714,6 +754,7 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
case "qoder":
|
||||
return await getQoderUsage(accessToken);
|
||||
case "glm":
|
||||
case "zai":
|
||||
case "glmt":
|
||||
return await getGlmUsage(apiKey, providerSpecificData);
|
||||
case "minimax":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const DEFAULT_INTERVAL_MS = 15_000;
|
||||
export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000;
|
||||
|
||||
type SseHeartbeatTransformOptions = {
|
||||
intervalMs?: number;
|
||||
@@ -6,7 +6,7 @@ type SseHeartbeatTransformOptions = {
|
||||
};
|
||||
|
||||
export function createSseHeartbeatTransform({
|
||||
intervalMs = DEFAULT_INTERVAL_MS,
|
||||
intervalMs = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS,
|
||||
signal,
|
||||
}: SseHeartbeatTransformOptions = {}) {
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { trackPendingRequest } from "@/lib/usageDb";
|
||||
// Stream handler with disconnect detection - shared for all providers
|
||||
|
||||
const PENDING_REQUEST_CLEARED_MARKER = "__omniroutePendingRequestCleared";
|
||||
const DISCONNECT_ABORT_DELAY_MS = 2_000;
|
||||
|
||||
type StreamDisconnectEvent = {
|
||||
reason: string;
|
||||
@@ -97,7 +98,7 @@ export function createStreamController({
|
||||
// Delay abort to allow cleanup
|
||||
abortTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, 500);
|
||||
}, DISCONNECT_ABORT_DELAY_MS);
|
||||
|
||||
onDisconnect?.({ reason, duration: Date.now() - startTime });
|
||||
},
|
||||
@@ -201,7 +202,9 @@ export function createDisconnectAwareStream(transformStream, streamController) {
|
||||
cancel(reason) {
|
||||
streamController.handleDisconnect(reason || "cancelled");
|
||||
reader.cancel();
|
||||
writer.abort();
|
||||
setTimeout(() => {
|
||||
writer.abort();
|
||||
}, DISCONNECT_ABORT_DELAY_MS).unref?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -116,6 +116,52 @@ function extractContentPreview(content) {
|
||||
|
||||
// ---------- Main ----------
|
||||
|
||||
if (!fs.existsSync(DOCS_DIR)) {
|
||||
if (fs.existsSync(OUT_FILE)) {
|
||||
console.warn(
|
||||
`[generate-docs-index] ${DOCS_DIR} not found; keeping existing generated docs index.`
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
OUT_FILE,
|
||||
`// AUTO-GENERATED by scripts/generate-docs-index.mjs — DO NOT EDIT MANUALLY
|
||||
// Regenerate with: node scripts/generate-docs-index.mjs
|
||||
|
||||
export interface AutoGenDocItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface AutoGenNavSection {
|
||||
title: string;
|
||||
items: AutoGenDocItem[];
|
||||
}
|
||||
|
||||
export interface AutoGenSearchItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
section: string;
|
||||
content: string;
|
||||
headings: string[];
|
||||
}
|
||||
|
||||
export const autoNavSections: AutoGenNavSection[] = [];
|
||||
|
||||
export const autoSearchIndex: AutoGenSearchItem[] = [];
|
||||
|
||||
export const autoAllSlugs: string[] = [];
|
||||
`,
|
||||
"utf8"
|
||||
);
|
||||
console.warn(`[generate-docs-index] ${DOCS_DIR} not found; generated empty docs index.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md") || f.endsWith(".mdx"));
|
||||
|
||||
const docs = [];
|
||||
|
||||
@@ -64,11 +64,14 @@ const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({
|
||||
|
||||
const STRATEGY_LABEL_FALLBACK = {
|
||||
"context-relay": "Context Relay",
|
||||
"reset-aware": "Reset-Aware RR",
|
||||
};
|
||||
|
||||
const STRATEGY_DESC_FALLBACK = {
|
||||
"context-relay":
|
||||
"Priority-style routing with automatic context handoffs when account rotation happens.",
|
||||
"reset-aware":
|
||||
"Quota remaining and reset windows decide the order; similar scores rotate round-robin.",
|
||||
};
|
||||
|
||||
const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
@@ -108,6 +111,11 @@ const STRATEGY_GUIDANCE_FALLBACK = {
|
||||
avoid: "Avoid when pricing data is missing or outdated.",
|
||||
example: "Example: Batch or background jobs where lower cost matters most.",
|
||||
},
|
||||
"reset-aware": {
|
||||
when: "Use when multiple accounts with quota telemetry have different reset windows.",
|
||||
avoid: "Avoid when quota telemetry is unavailable for most accounts.",
|
||||
example: "Example: Prefer a 60% weekly account resetting tomorrow over 80% that resets later.",
|
||||
},
|
||||
"fill-first": {
|
||||
when: "Use when you want to drain one provider's quota fully before moving to the next.",
|
||||
avoid: "Avoid when you need request-level load balancing across providers.",
|
||||
@@ -230,6 +238,15 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = {
|
||||
"Use for batch/background jobs where cost is the main KPI.",
|
||||
],
|
||||
},
|
||||
"reset-aware": {
|
||||
title: "Reset-aware account rotation",
|
||||
description: "Balances remaining provider quota against reset timing.",
|
||||
tips: [
|
||||
"Use explicit account steps or account-tag routing for providers with quota telemetry.",
|
||||
"Tune session vs weekly weights when short-term exhaustion is more risky.",
|
||||
"Keep the tie band small so equivalent accounts still rotate fairly.",
|
||||
],
|
||||
},
|
||||
"fill-first": {
|
||||
title: "Quota drain strategy",
|
||||
description: "Exhausts one provider's quota before moving to the next in chain.",
|
||||
@@ -439,6 +456,7 @@ function getStrategyBadgeClass(strategy) {
|
||||
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
|
||||
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
|
||||
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
|
||||
if (strategy === "reset-aware") return "bg-lime-500/15 text-lime-700 dark:text-lime-300";
|
||||
if (strategy === "fill-first") return "bg-orange-500/15 text-orange-600 dark:text-orange-400";
|
||||
if (strategy === "p2c") return "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400";
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
|
||||
@@ -53,6 +53,10 @@ import {
|
||||
getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults,
|
||||
getCodexRequestDefaults as _getCodexRequestDefaults,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import {
|
||||
getCodexEffectiveFastServiceTier,
|
||||
isCodexGlobalFastServiceTierEnabled,
|
||||
} from "@/lib/providers/codexFastTier";
|
||||
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
|
||||
import { parseExtraApiKeys } from "@/shared/utils/parseApiKeys";
|
||||
import { resolveDashboardProviderInfo } from "../providerPageUtils";
|
||||
@@ -512,6 +516,7 @@ interface ConnectionRowProps {
|
||||
isOAuth: boolean;
|
||||
isClaude?: boolean;
|
||||
isCodex?: boolean;
|
||||
codexFastGlobalEnabled?: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onMoveUp: () => void;
|
||||
@@ -1015,6 +1020,8 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
const [applyingCodexAuthId, setApplyingCodexAuthId] = useState<string | null>(null);
|
||||
const [exportingCodexAuthId, setExportingCodexAuthId] = useState<string | null>(null);
|
||||
const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false);
|
||||
const [savingCodexGlobalFastServiceTier, setSavingCodexGlobalFastServiceTier] = useState(false);
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isAnthropicCompatible =
|
||||
@@ -1239,6 +1246,16 @@ export default function ProviderDetailPage() {
|
||||
.catch(() => {});
|
||||
}, [fetchConnections, fetchAliases]);
|
||||
|
||||
useEffect(() => {
|
||||
if (providerId !== "codex") return;
|
||||
fetch("/api/settings", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
setCodexGlobalFastServiceTier(isCodexGlobalFastServiceTierEnabled(data));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [providerId]);
|
||||
|
||||
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
|
||||
if (!conns.length) return;
|
||||
try {
|
||||
@@ -1686,6 +1703,32 @@ export default function ProviderDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleCodexGlobalFastServiceTier = async (enabled: boolean) => {
|
||||
if (savingCodexGlobalFastServiceTier) return;
|
||||
setSavingCodexGlobalFastServiceTier(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codexServiceTier: { enabled } }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
notify.error(data.error || "Failed to update Codex Fast setting");
|
||||
return;
|
||||
}
|
||||
|
||||
setCodexGlobalFastServiceTier(enabled);
|
||||
notify.success(enabled ? "Codex Fast enabled globally" : "Codex Fast disabled globally");
|
||||
} catch (error) {
|
||||
console.error("Error toggling Codex Fast setting:", error);
|
||||
notify.error("Failed to update Codex Fast setting");
|
||||
} finally {
|
||||
setSavingCodexGlobalFastServiceTier(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetestConnection = async (connectionId) => {
|
||||
if (!connectionId || retestingId) return;
|
||||
setRetestingId(connectionId);
|
||||
@@ -2795,9 +2838,22 @@ export default function ProviderDetailPage() {
|
||||
{/* Connections */}
|
||||
{!isUpstreamProxyProvider && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{t("connections")}</h2>
|
||||
{providerId === "codex" && (
|
||||
<div title="Apply Codex Fast tier to all Codex connections by default">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={codexGlobalFastServiceTier}
|
||||
onChange={handleToggleCodexGlobalFastServiceTier}
|
||||
disabled={savingCodexGlobalFastServiceTier}
|
||||
label="Fast default"
|
||||
ariaLabel="Toggle Codex Fast default"
|
||||
className="rounded-lg border border-sky-500/20 bg-sky-500/5 px-2 py-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Provider-level proxy indicator/button */}
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -2826,42 +2882,44 @@ export default function ProviderDetailPage() {
|
||||
: t("providerProxy")}
|
||||
</button>
|
||||
</div>
|
||||
{connections.length > 1 && (
|
||||
<button
|
||||
onClick={handleBatchTestAll}
|
||||
disabled={batchTesting || !!retestingId}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
batchTesting
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("testAll")}
|
||||
aria-label={t("testAll")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{batchTesting ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{batchTesting ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
)}
|
||||
{!isCompatible ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" icon="add" onClick={openPrimaryAddFlow}>
|
||||
{providerSupportsPat ? "Add PAT" : t("add")}
|
||||
</Button>
|
||||
{providerId === "qoder" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowOAuthModal(true)}>
|
||||
Experimental OAuth
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{connections.length > 1 && (
|
||||
<button
|
||||
onClick={handleBatchTestAll}
|
||||
disabled={batchTesting || !!retestingId}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
batchTesting
|
||||
? "bg-primary/20 border-primary/40 text-primary animate-pulse"
|
||||
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40"
|
||||
}`}
|
||||
title={t("testAll")}
|
||||
aria-label={t("testAll")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{batchTesting ? "sync" : "play_arrow"}
|
||||
</span>
|
||||
{batchTesting ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
)}
|
||||
{!isCompatible ? (
|
||||
<>
|
||||
<Button size="sm" icon="add" onClick={openPrimaryAddFlow}>
|
||||
{providerSupportsPat ? "Add PAT" : t("add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
connections.length === 0 && (
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{providerId === "qoder" && (
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowOAuthModal(true)}>
|
||||
Experimental OAuth
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
connections.length === 0 && (
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddApiKeyModal(true)}>
|
||||
{t("add")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{connections.length === 0 ? (
|
||||
@@ -2904,6 +2962,7 @@ export default function ProviderDetailPage() {
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={index === 0}
|
||||
isLast={index === sorted.length - 1}
|
||||
onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])}
|
||||
@@ -3022,6 +3081,7 @@ export default function ProviderDetailPage() {
|
||||
connection={conn}
|
||||
isOAuth={conn.authType === "oauth"}
|
||||
isClaude={providerId === "claude"}
|
||||
codexFastGlobalEnabled={codexGlobalFastServiceTier}
|
||||
isFirst={gi === 0 && index === 0}
|
||||
isLast={
|
||||
gi === groupKeys.length - 1 && index === groupConns.length - 1
|
||||
@@ -4966,6 +5026,7 @@ function ConnectionRow({
|
||||
isOAuth,
|
||||
isClaude,
|
||||
isCodex,
|
||||
codexFastGlobalEnabled,
|
||||
isCcCompatible,
|
||||
cliproxyapiEnabled,
|
||||
isFirst,
|
||||
@@ -5069,6 +5130,12 @@ function ConnectionRow({
|
||||
const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy);
|
||||
const codex5hEnabled = normalizedCodexPolicy.use5h;
|
||||
const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly;
|
||||
const codexFastEnabled = isCodex
|
||||
? getCodexEffectiveFastServiceTier(
|
||||
connection.providerSpecificData,
|
||||
codexFastGlobalEnabled === true
|
||||
)
|
||||
: false;
|
||||
const claudeBlockExtraUsageEnabled = isClaude
|
||||
? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData)
|
||||
: false;
|
||||
@@ -5209,6 +5276,19 @@ function ConnectionRow({
|
||||
{isCodex && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
{codexFastEnabled && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-sky-500/15 text-sky-500"
|
||||
title={
|
||||
codexFastGlobalEnabled
|
||||
? "Global Codex fast tier is active"
|
||||
: "Codex fast tier is active for this connection"
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">bolt</span>
|
||||
Fast
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onToggleCodex5h?.(!codex5hEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ProviderStats {
|
||||
errorTime?: string | null;
|
||||
allDisabled?: boolean;
|
||||
expiryStatus?: "expired" | "expiring_soon" | string | null;
|
||||
codexFastActive?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
@@ -56,7 +57,8 @@ function getStatusDisplay(
|
||||
connected: number,
|
||||
error: number,
|
||||
errorCode: string | null | undefined,
|
||||
t: ReturnType<typeof useTranslations>
|
||||
t: ReturnType<typeof useTranslations>,
|
||||
afterConnected?: ReactNode
|
||||
) {
|
||||
const parts: ReactNode[] = [];
|
||||
if (connected > 0) {
|
||||
@@ -65,6 +67,7 @@ function getStatusDisplay(
|
||||
{t("connected", { count: connected })}
|
||||
</Badge>
|
||||
);
|
||||
if (afterConnected) parts.push(afterConnected);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode
|
||||
@@ -97,6 +100,17 @@ export default function ProviderCard({
|
||||
const isCompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible;
|
||||
const codexFastChip =
|
||||
providerId === "codex" && stats.codexFastActive ? (
|
||||
<span
|
||||
key="fast"
|
||||
className="inline-flex items-center gap-0.5 rounded-full bg-sky-500/10 px-1.5 py-0 text-[9px] font-semibold uppercase tracking-wide text-sky-600 dark:text-sky-400"
|
||||
title="Codex Fast tier is active"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[10px] leading-none">bolt</span>
|
||||
Fast
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const dotLabels: Record<string, string> = {
|
||||
free: tc("free"),
|
||||
@@ -183,7 +197,7 @@ export default function ProviderCard({
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{getStatusDisplay(connected, error, stats.errorCode, t)}
|
||||
{getStatusDisplay(connected, error, stats.errorCode, t, codexFastChip)}
|
||||
{(authType === "free" || provider.hasFree === true) && (
|
||||
<Badge
|
||||
variant="success"
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
} from "./providerPageUtils";
|
||||
import type { ProviderEntry } from "./providerPageUtils";
|
||||
import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage";
|
||||
import {
|
||||
getCodexEffectiveFastServiceTier,
|
||||
isCodexGlobalFastServiceTierEnabled,
|
||||
} from "@/lib/providers/codexFastTier";
|
||||
import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal";
|
||||
import ProviderCard from "./components/ProviderCard";
|
||||
import ProviderCountBadge from "./components/ProviderCountBadge";
|
||||
@@ -102,6 +106,7 @@ export default function ProvidersPage() {
|
||||
const [providerNodes, setProviderNodes] = useState<any[]>([]);
|
||||
const [ccCompatibleProviderEnabled, setCcCompatibleProviderEnabled] = useState(false);
|
||||
const [expirations, setExpirations] = useState<any>(null);
|
||||
const [codexGlobalFastServiceTier, setCodexGlobalFastServiceTier] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false);
|
||||
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
|
||||
@@ -131,20 +136,23 @@ export default function ProvidersPage() {
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [connectionsRes, nodesRes, expirationsRes] = await Promise.all([
|
||||
const [connectionsRes, nodesRes, expirationsRes, settingsRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
fetch("/api/providers/expiration"),
|
||||
fetch("/api/settings", { cache: "no-store" }),
|
||||
]);
|
||||
const connectionsData = await connectionsRes.json();
|
||||
const nodesData = await nodesRes.json();
|
||||
const expirationsData = await expirationsRes.json();
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : null;
|
||||
if (connectionsRes.ok) setConnections(connectionsData.connections || []);
|
||||
if (nodesRes.ok) {
|
||||
setProviderNodes(nodesData.nodes || []);
|
||||
setCcCompatibleProviderEnabled(nodesData.ccCompatibleProviderEnabled === true);
|
||||
}
|
||||
if (expirationsRes.ok && expirationsData) setExpirations(expirationsData);
|
||||
setCodexGlobalFastServiceTier(isCodexGlobalFastServiceTierEnabled(settingsData));
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
@@ -279,7 +287,23 @@ export default function ProvidersPage() {
|
||||
if (hasExpired) expiryStatus = "expired";
|
||||
else if (hasExpiringSoon) expiryStatus = "expiring_soon";
|
||||
|
||||
return { connected, error, total, errorCode, errorTime, allDisabled, expiryStatus };
|
||||
const codexFastActive =
|
||||
providerId === "codex" &&
|
||||
(codexGlobalFastServiceTier ||
|
||||
providerConnections.some((connection) =>
|
||||
getCodexEffectiveFastServiceTier(connection.providerSpecificData, false)
|
||||
));
|
||||
|
||||
return {
|
||||
connected,
|
||||
error,
|
||||
total,
|
||||
errorCode,
|
||||
errorTime,
|
||||
allDisabled,
|
||||
expiryStatus,
|
||||
codexFastActive,
|
||||
};
|
||||
};
|
||||
|
||||
// Toggle all connections for a provider on/off
|
||||
|
||||
@@ -36,6 +36,7 @@ const PROVIDER_CONFIG = {
|
||||
codex: { label: "OpenAI Codex", color: "#10A37F" },
|
||||
claude: { label: "Claude Code", color: "#D97757" },
|
||||
glm: { label: "GLM (Z.AI)", color: "#4A90D9" },
|
||||
zai: { label: "Z.AI", color: "#2563EB" },
|
||||
glmt: { label: "GLM Thinking", color: "#2563EB" },
|
||||
"kimi-coding": { label: "Kimi Coding", color: "#1E3A8A" },
|
||||
minimax: { label: "MiniMax", color: "#7C3AED" },
|
||||
@@ -299,11 +300,12 @@ export default function ProviderLimits() {
|
||||
claude: 5,
|
||||
kiro: 6,
|
||||
glm: 7,
|
||||
glmt: 8,
|
||||
"kimi-coding": 9,
|
||||
minimax: 10,
|
||||
"minimax-cn": 11,
|
||||
nanogpt: 12,
|
||||
zai: 8,
|
||||
glmt: 9,
|
||||
"kimi-coding": 10,
|
||||
minimax: 11,
|
||||
"minimax-cn": 12,
|
||||
nanogpt: 13,
|
||||
};
|
||||
return [...filteredConnections].sort(
|
||||
(a, b) => (priority[a.provider] || 9) - (priority[b.provider] || 9)
|
||||
@@ -552,7 +554,12 @@ export default function ProviderLimits() {
|
||||
{/* Account Info */}
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
|
||||
<ProviderIcon providerId={conn.provider} size={32} type="color" />
|
||||
<ProviderIcon
|
||||
providerId={conn.provider}
|
||||
size={32}
|
||||
type="color"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-semibold text-text-main truncate">
|
||||
|
||||
@@ -22,6 +22,11 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
agentic_request_freetrial: "Agentic (Trial)",
|
||||
credits: "AI Credits",
|
||||
models: "Models",
|
||||
"5 Hours Quota": "5 Hours",
|
||||
"Weekly Quota": "Weekly",
|
||||
"Monthly Tools": "Monthly Tools",
|
||||
tokens: "Tokens",
|
||||
time_limit: "Time Limit",
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
|
||||
@@ -49,6 +49,7 @@ export async function GET(request: Request) {
|
||||
strategy: "priority",
|
||||
maxRetries: 1,
|
||||
retryDelayMs: 2000,
|
||||
fallbackDelayMs: 0,
|
||||
handoffThreshold: 0.85,
|
||||
handoffModel: "",
|
||||
maxMessagesForSummary: 30,
|
||||
|
||||
@@ -36,7 +36,8 @@ const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
type PricingByProvider = Record<string, Record<string, Record<string, unknown>>>;
|
||||
type ComputeCostFromPricing = (
|
||||
pricing: Record<string, unknown> | null | undefined,
|
||||
tokens: Record<string, number | undefined> | null | undefined
|
||||
tokens: Record<string, number | undefined> | null | undefined,
|
||||
options?: Record<string, unknown>
|
||||
) => number;
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
@@ -56,6 +57,15 @@ function roundCost(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeServiceTier(value: unknown): "standard" | "priority" {
|
||||
const tier = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
return tier === "priority" || tier === "fast" ? "priority" : "standard";
|
||||
}
|
||||
|
||||
function getServiceTierLabel(serviceTier: string): string {
|
||||
return normalizeServiceTier(serviceTier) === "priority" ? "Fast" : "Standard";
|
||||
}
|
||||
|
||||
function appendWhereCondition(whereClause: string, condition: string): string {
|
||||
return whereClause ? `${whereClause} AND (${condition})` : `WHERE (${condition})`;
|
||||
}
|
||||
@@ -206,6 +216,7 @@ function computeUsageRowCost(
|
||||
const provider = toStringValue(row.provider);
|
||||
const model = toStringValue(row.model);
|
||||
if (!provider || !model) return 0;
|
||||
const serviceTier = normalizeServiceTier(row.serviceTier ?? row.service_tier);
|
||||
|
||||
const pricing = resolveModelPricing(
|
||||
pricingByProvider,
|
||||
@@ -216,13 +227,21 @@ function computeUsageRowCost(
|
||||
);
|
||||
if (!pricing) return 0;
|
||||
|
||||
return computeCostFromPricing(pricing, {
|
||||
input: toNumber(row.promptTokens),
|
||||
output: toNumber(row.completionTokens),
|
||||
cacheRead: toNumber(row.cacheReadTokens),
|
||||
cacheCreation: toNumber(row.cacheCreationTokens),
|
||||
reasoning: toNumber(row.reasoningTokens),
|
||||
});
|
||||
return computeCostFromPricing(
|
||||
pricing,
|
||||
{
|
||||
input: toNumber(row.promptTokens),
|
||||
output: toNumber(row.completionTokens),
|
||||
cacheRead: toNumber(row.cacheReadTokens),
|
||||
cacheCreation: toNumber(row.cacheCreationTokens),
|
||||
reasoning: toNumber(row.reasoningTokens),
|
||||
},
|
||||
{
|
||||
provider,
|
||||
model,
|
||||
serviceTier,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function formatUtcDate(date: Date): string {
|
||||
@@ -349,6 +368,7 @@ export async function GET(request: Request) {
|
||||
DATE(timestamp) as date,
|
||||
LOWER(provider) as provider,
|
||||
LOWER(model) as model,
|
||||
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
@@ -356,7 +376,7 @@ export async function GET(request: Request) {
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
${whereClause}
|
||||
GROUP BY DATE(timestamp), LOWER(provider), LOWER(model)
|
||||
GROUP BY DATE(timestamp), LOWER(provider), LOWER(model), serviceTier
|
||||
ORDER BY date ASC
|
||||
`
|
||||
)
|
||||
@@ -402,6 +422,7 @@ export async function GET(request: Request) {
|
||||
SELECT
|
||||
LOWER(model) as model,
|
||||
LOWER(provider) as provider,
|
||||
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
@@ -414,9 +435,8 @@ export async function GET(request: Request) {
|
||||
COALESCE(MAX(timestamp), '') as lastUsed
|
||||
FROM usage_history
|
||||
${whereClause}
|
||||
GROUP BY LOWER(model), LOWER(provider)
|
||||
GROUP BY LOWER(model), LOWER(provider), serviceTier
|
||||
ORDER BY requests DESC
|
||||
LIMIT 50
|
||||
`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
@@ -427,6 +447,7 @@ export async function GET(request: Request) {
|
||||
SELECT
|
||||
LOWER(provider) as provider,
|
||||
LOWER(model) as model,
|
||||
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
@@ -434,7 +455,7 @@ export async function GET(request: Request) {
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
|
||||
FROM usage_history
|
||||
${whereClause}
|
||||
GROUP BY LOWER(provider), LOWER(model)
|
||||
GROUP BY LOWER(provider), LOWER(model), serviceTier
|
||||
`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
@@ -465,6 +486,7 @@ export async function GET(request: Request) {
|
||||
COALESCE(NULLIF(c.display_name, ''), NULLIF(c.email, ''), NULLIF(c.name, ''), usage_history.connection_id, 'unknown') as account,
|
||||
LOWER(usage_history.provider) as provider,
|
||||
LOWER(usage_history.model) as model,
|
||||
COALESCE(NULLIF(usage_history.service_tier, ''), 'standard') as serviceTier,
|
||||
COALESCE(SUM(usage_history.tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(usage_history.tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(usage_history.tokens_cache_read), 0) as cacheReadTokens,
|
||||
@@ -473,7 +495,7 @@ export async function GET(request: Request) {
|
||||
FROM usage_history
|
||||
LEFT JOIN provider_connections c ON c.id = usage_history.connection_id
|
||||
${whereClause.replace(/timestamp/g, "usage_history.timestamp").replace(/api_key_/g, "usage_history.api_key_")}
|
||||
GROUP BY account, LOWER(usage_history.provider), LOWER(usage_history.model)
|
||||
GROUP BY account, LOWER(usage_history.provider), LOWER(usage_history.model), serviceTier
|
||||
`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
@@ -511,6 +533,7 @@ export async function GET(request: Request) {
|
||||
COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey,
|
||||
LOWER(provider) as provider,
|
||||
LOWER(model) as model,
|
||||
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
@@ -520,7 +543,28 @@ export async function GET(request: Request) {
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
${apiKeyWhereClause}
|
||||
GROUP BY COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown'), NULLIF(api_key_id, ''), LOWER(provider), LOWER(model)
|
||||
GROUP BY COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown'), NULLIF(api_key_id, ''), LOWER(provider), LOWER(model), serviceTier
|
||||
`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
|
||||
const serviceTierRows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
|
||||
LOWER(provider) as provider,
|
||||
LOWER(model) as model,
|
||||
COUNT(*) as requests,
|
||||
COALESCE(SUM(tokens_input), 0) as promptTokens,
|
||||
COALESCE(SUM(tokens_output), 0) as completionTokens,
|
||||
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
|
||||
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
|
||||
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens,
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
${whereClause}
|
||||
GROUP BY serviceTier, LOWER(provider), LOWER(model)
|
||||
`
|
||||
)
|
||||
.all(params) as Array<Record<string, unknown>>;
|
||||
@@ -633,6 +677,11 @@ export async function GET(request: Request) {
|
||||
firstRequest: summaryRow?.firstRequest || "",
|
||||
lastRequest: summaryRow?.lastRequest || "",
|
||||
fallbackCount: Number(fallbackRow?.fallbacks || 0),
|
||||
fastRequests: 0,
|
||||
standardRequests: 0,
|
||||
fastCost: 0,
|
||||
standardCost: 0,
|
||||
fastRequestSharePct: 0,
|
||||
fallbackRatePct:
|
||||
Number(fallbackRow?.fallback_eligible || 0) > 0
|
||||
? Number(
|
||||
@@ -697,14 +746,11 @@ export async function GET(request: Request) {
|
||||
}
|
||||
summary.streak = computeActivityStreak(activityMap);
|
||||
|
||||
const byModel = modelRows.map((row) => {
|
||||
const modelMap = new Map<string, Record<string, unknown>>();
|
||||
for (const row of modelRows) {
|
||||
const model = row.model as string;
|
||||
const provider = row.provider as string;
|
||||
const short = normalizeModelName(model);
|
||||
const tokens = {
|
||||
input: Number(row.promptTokens) || 0,
|
||||
output: Number(row.completionTokens) || 0,
|
||||
};
|
||||
const cost = computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
@@ -712,23 +758,59 @@ export async function GET(request: Request) {
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
return {
|
||||
const key = `${provider}::${model}`;
|
||||
const existing = modelMap.get(key) || {
|
||||
model: short,
|
||||
provider,
|
||||
rawModel: model,
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
latencyWeightedTotal: 0,
|
||||
successfulRequests: 0,
|
||||
lastUsed: "",
|
||||
cost: 0,
|
||||
};
|
||||
const requests = Number(row.requests) || 0;
|
||||
existing.requests = Number(existing.requests || 0) + requests;
|
||||
existing.promptTokens = Number(existing.promptTokens || 0) + Number(row.promptTokens || 0);
|
||||
existing.completionTokens =
|
||||
Number(existing.completionTokens || 0) + Number(row.completionTokens || 0);
|
||||
existing.totalTokens = Number(existing.totalTokens || 0) + Number(row.totalTokens || 0);
|
||||
existing.latencyWeightedTotal =
|
||||
Number(existing.latencyWeightedTotal || 0) + Number(row.avgLatencyMs || 0) * requests;
|
||||
existing.successfulRequests =
|
||||
Number(existing.successfulRequests || 0) + Number(row.successfulRequests || 0);
|
||||
if (!existing.lastUsed || String(row.lastUsed || "") > String(existing.lastUsed || "")) {
|
||||
existing.lastUsed = row.lastUsed;
|
||||
}
|
||||
existing.cost = Number(existing.cost || 0) + cost;
|
||||
modelMap.set(key, existing);
|
||||
}
|
||||
|
||||
const byModel = Array.from(modelMap.values())
|
||||
.map((row) => ({
|
||||
model: row.model,
|
||||
provider: row.provider,
|
||||
rawModel: row.rawModel,
|
||||
requests: Number(row.requests),
|
||||
promptTokens: tokens.input,
|
||||
completionTokens: tokens.output,
|
||||
promptTokens: Number(row.promptTokens),
|
||||
completionTokens: Number(row.completionTokens),
|
||||
totalTokens: Number(row.totalTokens),
|
||||
avgLatencyMs: Math.round(Number(row.avgLatencyMs)),
|
||||
avgLatencyMs:
|
||||
Number(row.requests) > 0
|
||||
? Math.round(Number(row.latencyWeightedTotal || 0) / Number(row.requests))
|
||||
: 0,
|
||||
successRatePct:
|
||||
Number(row.requests) > 0
|
||||
? Number((Number(row.successfulRequests) / Number(row.requests)) * 100).toFixed(2)
|
||||
? Number((Number(row.successfulRequests || 0) / Number(row.requests)) * 100).toFixed(2)
|
||||
: 0,
|
||||
lastUsed: row.lastUsed,
|
||||
cost: roundCost(cost),
|
||||
};
|
||||
});
|
||||
cost: roundCost(Number(row.cost || 0)),
|
||||
}))
|
||||
.sort((left, right) => Number(right.requests) - Number(left.requests))
|
||||
.slice(0, 50);
|
||||
|
||||
const totalCost = Array.from(dailyCostByDate.values()).reduce((sum, cost) => sum + cost, 0);
|
||||
summary.totalCost = roundCost(totalCost);
|
||||
@@ -839,6 +921,56 @@ export async function GET(request: Request) {
|
||||
.map((row) => ({ ...row, cost: roundCost(row.cost) }))
|
||||
.sort((left, right) => right.cost - left.cost);
|
||||
|
||||
const serviceTierMap = new Map<
|
||||
string,
|
||||
{
|
||||
serviceTier: "standard" | "priority";
|
||||
label: string;
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cost: number;
|
||||
}
|
||||
>();
|
||||
for (const row of serviceTierRows) {
|
||||
const serviceTier = normalizeServiceTier(row.serviceTier);
|
||||
const existing = serviceTierMap.get(serviceTier) || {
|
||||
serviceTier,
|
||||
label: getServiceTierLabel(serviceTier),
|
||||
requests: 0,
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cost: 0,
|
||||
};
|
||||
existing.requests += Number(row.requests || 0);
|
||||
existing.promptTokens += Number(row.promptTokens || 0);
|
||||
existing.completionTokens += Number(row.completionTokens || 0);
|
||||
existing.totalTokens += Number(row.totalTokens || 0);
|
||||
existing.cost += computeUsageRowCost(
|
||||
row,
|
||||
pricingByProvider,
|
||||
PROVIDER_ID_TO_ALIAS,
|
||||
normalizeModelName,
|
||||
computeCostFromPricing
|
||||
);
|
||||
serviceTierMap.set(serviceTier, existing);
|
||||
}
|
||||
const byServiceTier = Array.from(serviceTierMap.values())
|
||||
.map((row) => ({ ...row, cost: roundCost(row.cost) }))
|
||||
.sort((left, right) => (left.serviceTier === "priority" ? -1 : 1));
|
||||
const fastTier = serviceTierMap.get("priority");
|
||||
const standardTier = serviceTierMap.get("standard");
|
||||
summary.fastRequests = fastTier?.requests || 0;
|
||||
summary.fastCost = roundCost(fastTier?.cost || 0);
|
||||
summary.standardRequests = standardTier?.requests || 0;
|
||||
summary.standardCost = roundCost(standardTier?.cost || 0);
|
||||
summary.fastRequestSharePct =
|
||||
summary.totalRequests > 0
|
||||
? Number(((Number(summary.fastRequests) / Number(summary.totalRequests)) * 100).toFixed(2))
|
||||
: 0;
|
||||
|
||||
const weeklyTokens = [0, 0, 0, 0, 0, 0, 0];
|
||||
const weeklyCounts = [0, 0, 0, 0, 0, 0, 0];
|
||||
const weeklyPattern = WEEKDAY_LABELS.map((day) => ({
|
||||
@@ -874,6 +1006,7 @@ export async function GET(request: Request) {
|
||||
byProvider,
|
||||
byApiKey,
|
||||
byAccount,
|
||||
byServiceTier,
|
||||
weeklyPattern,
|
||||
weeklyTokens,
|
||||
weeklyCounts,
|
||||
|
||||
@@ -1396,6 +1396,8 @@
|
||||
"randomDesc": "Einheitliche Zufallsauswahl, dann Rückgriff auf verbleibende Modelle",
|
||||
"leastUsedDesc": "Wählt das Modell mit den wenigsten Anfragen aus und gleicht die Last über die Zeit aus",
|
||||
"costOptimizedDesc": "Leitet basierend auf dem Preis zuerst zum günstigsten Modell weiter",
|
||||
"resetAware": "Reset-Aware RR",
|
||||
"resetAwareDesc": "Gewichtet Restquote gegen 5h- und Wochen-Resets und rotiert ähnliche Scores per Round Robin",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Modelle",
|
||||
@@ -1447,6 +1449,11 @@
|
||||
"avoid": "Preisdaten fehlen oder sind veraltet.",
|
||||
"example": "Hintergrund- oder Batch-Jobs, bei denen geringere Kosten bevorzugt werden."
|
||||
},
|
||||
"reset-aware": {
|
||||
"when": "Du routest über mehrere Konten mit Quota-Telemetrie und unterschiedlichen Reset-Fenstern.",
|
||||
"avoid": "Für die meisten Konten fehlen Quota-Telemetriedaten.",
|
||||
"example": "Bevorzuge ein Konto mit 60 % Wochen-Restquote und Reset morgen vor 80 % mit späterem Reset."
|
||||
},
|
||||
"strict-random": {
|
||||
"when": "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
"avoid": "Avoid when models have different quality or latency and order matters.",
|
||||
@@ -1555,6 +1562,13 @@
|
||||
"tip2": "Behalte einen Qualitäts-Fallback für schwierige Prompts.",
|
||||
"tip3": "Ideal für Batch/Hintergrundjobs, bei denen Kosten das Haupt-KPI sind."
|
||||
},
|
||||
"reset-aware": {
|
||||
"title": "Reset-bewusste Kontorotation",
|
||||
"description": "Gewichtet verbleibende Provider-Quote gegen Reset-Zeitpunkte.",
|
||||
"tip1": "Nutze explizite Kontoschritte oder Tag-Routing für Provider mit Quota-Telemetrie.",
|
||||
"tip2": "Passe 5h- und Wochengewichtung an, wenn kurzfristige Erschöpfung riskant ist.",
|
||||
"tip3": "Halte das Tie-Band klein, damit gleichwertige Konten fair rotieren."
|
||||
},
|
||||
"strict-random": {
|
||||
"title": "Shuffle deck distribution",
|
||||
"description": "Each model is used exactly once per cycle before reshuffling.",
|
||||
@@ -2908,6 +2922,8 @@
|
||||
"leastUsedDesc": "Wählen Sie das zuletzt verwendete Konto aus",
|
||||
"costOpt": "Kosten Opt",
|
||||
"costOptDesc": "Bevorzugen Sie das günstigste verfügbare Konto",
|
||||
"resetAware": "Reset-Aware RR",
|
||||
"resetAwareDesc": "Bevorzugt Konten mit gesunder Restquote und näherem Reset",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky-Limit",
|
||||
|
||||
@@ -1505,6 +1505,8 @@
|
||||
"randomDesc": "Uniform random selection, then fallback to remaining models",
|
||||
"leastUsedDesc": "Picks the model with fewest requests, balancing load over time",
|
||||
"costOptimizedDesc": "Routes to the cheapest model first based on pricing",
|
||||
"resetAware": "Reset-Aware RR",
|
||||
"resetAwareDesc": "Balances remaining quota against 5h and weekly resets, then round-robins similar scores",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each model once before reshuffling",
|
||||
"models": "Models",
|
||||
@@ -1563,6 +1565,11 @@
|
||||
"avoid": "Pricing data is missing or outdated.",
|
||||
"example": "Background or batch jobs where lower cost is preferred."
|
||||
},
|
||||
"reset-aware": {
|
||||
"when": "You route across multiple accounts with quota telemetry and different reset windows.",
|
||||
"avoid": "Quota telemetry is unavailable for most accounts.",
|
||||
"example": "Prefer a 60% weekly account resetting tomorrow over an 80% account resetting later."
|
||||
},
|
||||
"strict-random": {
|
||||
"when": "Use when you want perfectly even spread — each model used once before repeating.",
|
||||
"avoid": "Avoid when models have different quality or latency and order matters.",
|
||||
@@ -1735,6 +1742,13 @@
|
||||
"tip2": "Keep a quality fallback for hard prompts.",
|
||||
"tip3": "Use for batch/background jobs where cost is the main KPI."
|
||||
},
|
||||
"reset-aware": {
|
||||
"title": "Reset-aware account rotation",
|
||||
"description": "Balances remaining provider quota against reset timing.",
|
||||
"tip1": "Use explicit account steps or account-tag routing for providers with quota telemetry.",
|
||||
"tip2": "Tune session vs weekly weights when short-term exhaustion is more risky.",
|
||||
"tip3": "Keep the tie band small so equivalent accounts still rotate fairly."
|
||||
},
|
||||
"strict-random": {
|
||||
"title": "Shuffle deck distribution",
|
||||
"description": "Each model is used exactly once per cycle before reshuffling.",
|
||||
@@ -3373,6 +3387,8 @@
|
||||
"leastUsedDesc": "Pick least recently used account",
|
||||
"costOpt": "Cost Opt",
|
||||
"costOptDesc": "Prefer cheapest available account",
|
||||
"resetAware": "Reset-Aware RR",
|
||||
"resetAwareDesc": "Prefer accounts with healthy remaining quota and nearer resets",
|
||||
"strictRandom": "Strict Random",
|
||||
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
|
||||
"stickyLimit": "Sticky Limit",
|
||||
|
||||
@@ -250,6 +250,7 @@ const SCHEMA_SQL = `
|
||||
tokens_cache_read INTEGER DEFAULT 0,
|
||||
tokens_cache_creation INTEGER DEFAULT 0,
|
||||
tokens_reasoning INTEGER DEFAULT 0,
|
||||
service_tier TEXT DEFAULT 'standard',
|
||||
status TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
@@ -541,6 +542,11 @@ function ensureUsageHistoryColumns(db: SqliteDatabase) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN error_code TEXT");
|
||||
console.log("[DB] Added usage_history.error_code column");
|
||||
}
|
||||
if (!columnNames.has("service_tier")) {
|
||||
db.exec("ALTER TABLE usage_history ADD COLUMN service_tier TEXT DEFAULT 'standard'");
|
||||
console.log("[DB] Added usage_history.service_tier column");
|
||||
}
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_uh_service_tier ON usage_history(service_tier)");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn("[DB] Failed to verify usage_history schema:", message);
|
||||
|
||||
@@ -303,8 +303,10 @@ function isSchemaAlreadyApplied(
|
||||
);
|
||||
case "045":
|
||||
return hasColumn(db, "call_logs", "tokens_compressed");
|
||||
case "051":
|
||||
case "053":
|
||||
return !hasColumn(db, "files", "status");
|
||||
case "054":
|
||||
return hasColumn(db, "usage_history", "service_tier");
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ CREATE TABLE IF NOT EXISTS usage_history (
|
||||
tokens_cache_read INTEGER DEFAULT 0,
|
||||
tokens_cache_creation INTEGER DEFAULT 0,
|
||||
tokens_reasoning INTEGER DEFAULT 0,
|
||||
service_tier TEXT DEFAULT 'standard',
|
||||
status TEXT,
|
||||
success INTEGER DEFAULT 1,
|
||||
latency_ms INTEGER DEFAULT 0,
|
||||
@@ -107,6 +108,7 @@ CREATE TABLE IF NOT EXISTS usage_history (
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_timestamp ON usage_history(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_provider ON usage_history(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_model ON usage_history(model);
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_service_tier ON usage_history(service_tier);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS call_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS tier_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tier_assignments (
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
tier TEXT NOT NULL CHECK (tier IN ('free', 'cheap', 'premium')),
|
||||
cost_per_1m_input REAL DEFAULT 0,
|
||||
cost_per_1m_output REAL DEFAULT 0,
|
||||
has_free_tier INTEGER DEFAULT 0,
|
||||
free_quota_limit INTEGER,
|
||||
reason TEXT,
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (provider, model)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tier_assignments_provider ON tier_assignments(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_tier_assignments_tier ON tier_assignments(tier);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- 051: Remove status column from files table
|
||||
ALTER TABLE files DROP COLUMN status;
|
||||
4
src/lib/db/migrations/054_usage_history_service_tier.sql
Normal file
4
src/lib/db/migrations/054_usage_history_service_tier.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Migration 051: Track effective service tier for usage analytics
|
||||
-- Used to distinguish Codex Fast (priority) requests from standard requests.
|
||||
ALTER TABLE usage_history ADD COLUMN service_tier TEXT DEFAULT 'standard';
|
||||
CREATE INDEX IF NOT EXISTS idx_uh_service_tier ON usage_history(service_tier);
|
||||
@@ -97,6 +97,7 @@ export async function getSettings() {
|
||||
hideEndpointTailscaleFunnel: false,
|
||||
hideEndpointNgrokTunnel: false,
|
||||
comboConfigMode: "guided",
|
||||
codexServiceTier: { enabled: false },
|
||||
alwaysPreserveClientCache: "auto",
|
||||
idempotencyWindowMs: 5000,
|
||||
wsAuth: false,
|
||||
|
||||
65
src/lib/providers/codexFastTier.ts
Normal file
65
src/lib/providers/codexFastTier.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getCodexRequestDefaults, normalizeCodexServiceTier } from "./requestDefaults";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
export function isCodexGlobalFastServiceTierEnabled(settings: unknown): boolean {
|
||||
const record = asRecord(settings);
|
||||
const codexServiceTier = record.codexServiceTier;
|
||||
|
||||
if (typeof codexServiceTier === "boolean") {
|
||||
return codexServiceTier;
|
||||
}
|
||||
|
||||
const codexServiceTierRecord = asRecord(codexServiceTier);
|
||||
if (codexServiceTierRecord.enabled === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return record.codexFastServiceTier === true;
|
||||
}
|
||||
|
||||
export function getCodexEffectiveFastServiceTier(
|
||||
providerSpecificData: unknown,
|
||||
globalFastServiceTierEnabled: boolean
|
||||
): boolean {
|
||||
return (
|
||||
globalFastServiceTierEnabled ||
|
||||
getCodexRequestDefaults(providerSpecificData).serviceTier === "priority"
|
||||
);
|
||||
}
|
||||
|
||||
export function applyCodexGlobalFastServiceTier<T extends JsonRecord | null | undefined>(
|
||||
provider: string | null | undefined,
|
||||
credentials: T,
|
||||
settings: unknown
|
||||
): T {
|
||||
if (provider !== "codex" || !isCodexGlobalFastServiceTierEnabled(settings)) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
if (!credentials || typeof credentials !== "object" || Array.isArray(credentials)) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
const providerSpecificData = asRecord(credentials.providerSpecificData);
|
||||
const requestDefaults = asRecord(providerSpecificData.requestDefaults);
|
||||
|
||||
if (normalizeCodexServiceTier(requestDefaults.serviceTier)) {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
return {
|
||||
...credentials,
|
||||
providerSpecificData: {
|
||||
...providerSpecificData,
|
||||
requestDefaults: {
|
||||
...requestDefaults,
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
} as T;
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export function normalizeModelName(model: string): string {
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
export type CostCalculationOptions = {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
};
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
@@ -31,6 +37,35 @@ function toNumber(value: unknown, fallback = 0): number {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeServiceTier(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function stripCodexEffortSuffix(model: string): string {
|
||||
return model.replace(/-(?:xhigh|high|medium|low|none)$/i, "");
|
||||
}
|
||||
|
||||
export function getCodexFastCostMultiplier(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
serviceTier: string | null | undefined
|
||||
): number {
|
||||
const providerKey = normalizeServiceTier(provider);
|
||||
const tier = normalizeServiceTier(serviceTier);
|
||||
if (
|
||||
(providerKey !== "codex" && providerKey !== "cx") ||
|
||||
(tier !== "priority" && tier !== "fast")
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const modelKey = stripCodexEffortSuffix(normalizeModelName(String(model || "")).toLowerCase());
|
||||
const compactModelKey = modelKey.replace(/-/g, "");
|
||||
if (modelKey === "gpt-5.5" || compactModelKey === "gpt5.5") return 2.5;
|
||||
if (modelKey === "gpt-5.4" || compactModelKey === "gpt5.4") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for a usage entry.
|
||||
*
|
||||
@@ -45,7 +80,8 @@ function toNumber(value: unknown, fallback = 0): number {
|
||||
*/
|
||||
export function computeCostFromPricing(
|
||||
pricing: Record<string, unknown> | null | undefined,
|
||||
tokens: Record<string, number | undefined> | null | undefined
|
||||
tokens: Record<string, number | undefined> | null | undefined,
|
||||
options: CostCalculationOptions = {}
|
||||
): number {
|
||||
if (!pricing || !tokens) return 0;
|
||||
const inputPrice = toNumber(pricing.input, 0);
|
||||
@@ -71,13 +107,14 @@ export function computeCostFromPricing(
|
||||
const cacheCreationTokens = tokens.cacheCreation ?? tokens.cache_creation_input_tokens ?? 0;
|
||||
if (cacheCreationTokens > 0) cost += cacheCreationTokens * (cacheCreationPrice / 1_000_000);
|
||||
|
||||
return cost;
|
||||
return cost * getCodexFastCostMultiplier(options.provider, options.model, options.serviceTier);
|
||||
}
|
||||
|
||||
export async function calculateCost(
|
||||
provider: string,
|
||||
model: string,
|
||||
tokens: Record<string, number | undefined> | null | undefined
|
||||
tokens: Record<string, number | undefined> | null | undefined,
|
||||
options: CostCalculationOptions = {}
|
||||
): Promise<number> {
|
||||
if (!tokens || !provider || !model) return 0;
|
||||
|
||||
@@ -98,7 +135,11 @@ export async function calculateCost(
|
||||
pricing && typeof pricing === "object" && !Array.isArray(pricing)
|
||||
? (pricing as Record<string, unknown>)
|
||||
: {};
|
||||
return computeCostFromPricing(pricingRecord, tokens);
|
||||
return computeCostFromPricing(pricingRecord, tokens, {
|
||||
provider,
|
||||
model,
|
||||
...options,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error calculating cost:", error);
|
||||
return 0;
|
||||
|
||||
@@ -46,6 +46,7 @@ interface ProviderConnectionLike {
|
||||
|
||||
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"glm",
|
||||
"zai",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -44,6 +44,11 @@ function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function normalizeServiceTier(value: unknown): string {
|
||||
const tier = typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
return tier === "priority" || tier === "fast" ? "priority" : "standard";
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
@@ -299,6 +304,7 @@ export async function getUsageDb(sinceIso?: string | null, limit?: number, curso
|
||||
connectionId: toStringOrNull(r.connection_id),
|
||||
apiKeyId: toStringOrNull(r.api_key_id),
|
||||
apiKeyName: toStringOrNull(r.api_key_name),
|
||||
serviceTier: normalizeServiceTier(r.service_tier),
|
||||
tokens: {
|
||||
input: toNumber(r.tokens_input),
|
||||
output: toNumber(r.tokens_output),
|
||||
@@ -332,13 +338,14 @@ export async function saveRequestUsage(entry: any) {
|
||||
try {
|
||||
const db = getDbInstance();
|
||||
const timestamp = entry.timestamp || new Date().toISOString();
|
||||
const serviceTier = normalizeServiceTier(entry.serviceTier ?? entry.service_tier);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO usage_history (provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation, tokens_reasoning,
|
||||
status, success, latency_ms, ttft_ms, error_code, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
service_tier, status, success, latency_ms, ttft_ms, error_code, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
entry.provider || null,
|
||||
@@ -351,6 +358,7 @@ export async function saveRequestUsage(entry: any) {
|
||||
getPromptCacheReadTokens(entry.tokens),
|
||||
getPromptCacheCreationTokens(entry.tokens),
|
||||
getReasoningTokens(entry.tokens),
|
||||
serviceTier,
|
||||
entry.status || null,
|
||||
entry.success === false ? 0 : 1,
|
||||
Number.isFinite(Number(entry.latencyMs)) ? Number(entry.latencyMs) : 0,
|
||||
@@ -409,6 +417,7 @@ export async function getUsageHistory(filter: any = {}) {
|
||||
connectionId: toStringOrNull(r.connection_id),
|
||||
apiKeyId: toStringOrNull(r.api_key_id),
|
||||
apiKeyName: toStringOrNull(r.api_key_name),
|
||||
serviceTier: normalizeServiceTier(r.service_tier),
|
||||
tokens: {
|
||||
input: toNumber(r.tokens_input),
|
||||
output: toNumber(r.tokens_output),
|
||||
|
||||
@@ -87,7 +87,8 @@ export async function getUsageStats() {
|
||||
tokens_output,
|
||||
tokens_cache_read,
|
||||
tokens_cache_creation,
|
||||
tokens_reasoning
|
||||
tokens_reasoning,
|
||||
service_tier
|
||||
FROM usage_history
|
||||
WHERE DATE(timestamp) >= ?
|
||||
|
||||
@@ -104,7 +105,8 @@ export async function getUsageStats() {
|
||||
total_output_tokens as tokens_output,
|
||||
0 as tokens_cache_read,
|
||||
0 as tokens_cache_creation,
|
||||
0 as tokens_reasoning
|
||||
0 as tokens_reasoning,
|
||||
'standard' as service_tier
|
||||
FROM daily_usage_summary
|
||||
WHERE date < ?
|
||||
|
||||
@@ -211,6 +213,7 @@ export async function getUsageStats() {
|
||||
const connectionId = toStringOrEmpty(row.connection_id) || null;
|
||||
const apiKeyId = toStringOrEmpty(row.api_key_id) || null;
|
||||
const apiKeyName = toStringOrEmpty(row.api_key_name) || null;
|
||||
const serviceTier = toStringOrEmpty(row.service_tier) || "standard";
|
||||
|
||||
const promptTokens = toNumber(row.tokens_input);
|
||||
const completionTokens = toNumber(row.tokens_output);
|
||||
@@ -223,7 +226,7 @@ export async function getUsageStats() {
|
||||
cacheCreation: toNumber(row.tokens_cache_creation),
|
||||
reasoning: toNumber(row.tokens_reasoning),
|
||||
};
|
||||
const entryCost = await calculateCost(provider, model, entryTokens);
|
||||
const entryCost = await calculateCost(provider, model, entryTokens, { serviceTier });
|
||||
|
||||
stats.totalPromptTokens += promptTokens;
|
||||
stats.totalCompletionTokens += completionTokens;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ToggleProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
title?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export default function Toggle({
|
||||
@@ -21,6 +22,8 @@ export default function Toggle({
|
||||
disabled = false,
|
||||
size = "md",
|
||||
className,
|
||||
title,
|
||||
ariaLabel,
|
||||
}: ToggleProps) {
|
||||
const sizes = {
|
||||
sm: {
|
||||
@@ -58,7 +61,8 @@ export default function Toggle({
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={!label ? description || "Toggle" : undefined}
|
||||
aria-label={ariaLabel || label || description || title || "Toggle"}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
ServiceTierBreakdown,
|
||||
ApiKeyFilterDropdown,
|
||||
CustomRangePicker,
|
||||
} from "./analytics";
|
||||
@@ -310,10 +311,10 @@ export default function UsageAnalytics() {
|
||||
color: "text-violet-500",
|
||||
},
|
||||
{
|
||||
icon: "swap_horiz",
|
||||
label: "Fallback Rate",
|
||||
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
|
||||
color: "text-amber-500",
|
||||
icon: "bolt",
|
||||
label: "Fast Requests",
|
||||
value: fmt(s.fastRequests || 0),
|
||||
color: "text-sky-500",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -330,6 +331,12 @@ export default function UsageAnalytics() {
|
||||
value: `${providerDiversity.toFixed(1)}%`,
|
||||
color: "text-sky-500",
|
||||
},
|
||||
{
|
||||
icon: "swap_horiz",
|
||||
label: "Fallback Rate",
|
||||
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
|
||||
color: "text-amber-500",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -350,6 +357,9 @@ export default function UsageAnalytics() {
|
||||
<ProviderCostDonut byProvider={analytics?.byProvider} />
|
||||
</div>
|
||||
|
||||
{/* Fast / Standard service tier split */}
|
||||
<ServiceTierBreakdown byServiceTier={analytics?.byServiceTier} summary={s} />
|
||||
|
||||
{/* Model Usage Over Time (stacked area) */}
|
||||
<ModelOverTimeChart
|
||||
dailyByModel={analytics?.dailyByModel}
|
||||
|
||||
@@ -1081,6 +1081,71 @@ export function ModelTable({ byModel, summary }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
|
||||
const totalRequests = Number(summary?.totalRequests || 0);
|
||||
const totalCost = Number(summary?.totalCost || 0);
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Service Tier
|
||||
</h3>
|
||||
<span className="text-[11px] text-text-muted">Fast / Standard cost split</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{data.map((tier) => {
|
||||
const isFast = tier.serviceTier === "priority";
|
||||
const requestPct =
|
||||
totalRequests > 0
|
||||
? ((Number(tier.requests || 0) / totalRequests) * 100).toFixed(1)
|
||||
: "0";
|
||||
const costPct =
|
||||
totalCost > 0 ? ((Number(tier.cost || 0) / totalCost) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={tier.serviceTier} className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
isFast ? "text-sky-500" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{isFast ? "bolt" : "speed"}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">{tier.label}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm font-semibold text-amber-500">
|
||||
{fmtCost(tier.cost)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{costPct}% of cost</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${isFast ? "bg-sky-500" : "bg-text-muted/50"}`}
|
||||
style={{ width: `${requestPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UsageDetail ────────────────────────────────────────────────────────────
|
||||
|
||||
export function UsageDetail({ summary }) {
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
ServiceTierBreakdown,
|
||||
} from "./charts";
|
||||
|
||||
export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown";
|
||||
|
||||
@@ -1849,6 +1849,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"claude",
|
||||
"kimi-coding",
|
||||
"glm",
|
||||
"zai",
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ROUTING_STRATEGY_VALUES = [
|
||||
"random",
|
||||
"least-used",
|
||||
"cost-optimized",
|
||||
"reset-aware",
|
||||
"strict-random",
|
||||
"auto",
|
||||
"lkgp",
|
||||
@@ -123,6 +124,13 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [
|
||||
settingsDescKey: "costOptDesc",
|
||||
icon: "savings",
|
||||
},
|
||||
{
|
||||
value: "reset-aware",
|
||||
labelKey: "resetAware",
|
||||
combosDescKey: "resetAwareDesc",
|
||||
settingsDescKey: "resetAwareDesc",
|
||||
icon: "event_repeat",
|
||||
},
|
||||
{
|
||||
value: "strict-random",
|
||||
labelKey: "strictRandom",
|
||||
|
||||
@@ -8,6 +8,7 @@ type ReadTimeoutOptions = {
|
||||
|
||||
export const DEFAULT_FETCH_TIMEOUT_MS = 600_000;
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000;
|
||||
export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000;
|
||||
export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 30_000;
|
||||
@@ -24,6 +25,7 @@ function hasEnvValue(env: EnvSource, name: string): boolean {
|
||||
export type UpstreamTimeoutConfig = {
|
||||
fetchTimeoutMs: number;
|
||||
streamIdleTimeoutMs: number;
|
||||
streamReadinessTimeoutMs: number;
|
||||
fetchHeadersTimeoutMs: number;
|
||||
fetchBodyTimeoutMs: number;
|
||||
fetchConnectTimeoutMs: number;
|
||||
@@ -89,10 +91,20 @@ export function getUpstreamTimeoutConfig(
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const streamReadinessTimeoutMs = readTimeoutMs(
|
||||
env,
|
||||
"STREAM_READINESS_TIMEOUT_MS",
|
||||
DEFAULT_STREAM_READINESS_TIMEOUT_MS,
|
||||
{
|
||||
allowZero: true,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
fetchTimeoutMs,
|
||||
streamIdleTimeoutMs,
|
||||
streamReadinessTimeoutMs,
|
||||
fetchHeadersTimeoutMs: readTimeoutMs(env, "FETCH_HEADERS_TIMEOUT_MS", fetchTimeoutMs, {
|
||||
allowZero: true,
|
||||
logger,
|
||||
|
||||
@@ -375,6 +375,7 @@ const comboRuntimeConfigSchema = z
|
||||
strategy: comboStrategySchema.optional(),
|
||||
maxRetries: z.coerce.number().int().min(0).max(10).optional(),
|
||||
retryDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
|
||||
fallbackDelayMs: z.coerce.number().int().min(0).max(60000).optional(),
|
||||
timeoutMs: z.coerce.number().int().min(1000).optional(),
|
||||
concurrencyPerModel: z.coerce.number().int().min(1).max(20).optional(),
|
||||
queueTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
|
||||
@@ -395,6 +396,10 @@ const comboRuntimeConfigSchema = z
|
||||
explorationRate: z.number().min(0).max(1).optional(),
|
||||
routerStrategy: z.string().optional(),
|
||||
compositeTiers: compositeTiersSchema.optional(),
|
||||
resetAwareSessionWeight: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareWeeklyWeight: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareTieBandPercent: z.coerce.number().min(0).max(100).optional(),
|
||||
resetAwareExhaustionGuardPercent: z.coerce.number().min(0).max(100).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -439,6 +444,7 @@ export const updateSettingsSchema = z.object({
|
||||
bruteForceProtection: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: settingsFallbackStrategySchema.optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const updateSettingsSchema = z.object({
|
||||
debugMode: z.boolean().optional(),
|
||||
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z.object({ enabled: z.boolean() }).optional(),
|
||||
// Routing settings (#134)
|
||||
fallbackStrategy: z.enum(ACCOUNT_FALLBACK_STRATEGY_VALUES).optional(),
|
||||
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import * as log from "../utils/logger";
|
||||
import { checkAndRefreshToken } from "../services/tokenRefresh";
|
||||
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
|
||||
import { getCachedSettings, getSettings, getCombos } from "@/lib/localDb";
|
||||
import { getCachedSettings, getCombos } from "@/lib/localDb";
|
||||
import {
|
||||
ensureOpenAIStoreSessionFallback,
|
||||
isOpenAIResponsesStoreEnabled,
|
||||
@@ -91,6 +91,21 @@ registerBailianCodingPlanQuotaFetcher();
|
||||
// opt-in) when the active bucket reaches zero.
|
||||
registerCrofUsageFetcher();
|
||||
|
||||
let combosCachePromise: Promise<unknown[]> | null = null;
|
||||
let combosCacheTs = 0;
|
||||
const COMBOS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
async function getCombosCachedForChat(): Promise<unknown[]> {
|
||||
const now = Date.now();
|
||||
if (combosCachePromise && now - combosCacheTs < COMBOS_CACHE_TTL_MS) {
|
||||
return combosCachePromise;
|
||||
}
|
||||
|
||||
combosCacheTs = now;
|
||||
combosCachePromise = getCombos().catch(() => []);
|
||||
return combosCachePromise;
|
||||
}
|
||||
|
||||
function normalizeAllowedConnectionIds(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const ids = value.filter(
|
||||
@@ -301,9 +316,18 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
|
||||
// Pre-check function used by combo routing. For explicit combo live tests,
|
||||
// avoid pre-skipping so each model gets a real execution attempt.
|
||||
const comboPreselectedCredentials = new Map<string, any>();
|
||||
const getComboCredentialCacheKey = (
|
||||
modelString: string,
|
||||
target?: { connectionId?: string | null; executionKey?: string | null }
|
||||
) => `${target?.executionKey || target?.connectionId || ""}:${modelString}`;
|
||||
const checkModelAvailable = async (
|
||||
modelString: string,
|
||||
target?: { connectionId?: string | null; allowedConnectionIds?: string[] | null }
|
||||
target?: {
|
||||
connectionId?: string | null;
|
||||
allowedConnectionIds?: string[] | null;
|
||||
executionKey?: string | null;
|
||||
}
|
||||
) => {
|
||||
if (isComboLiveTest) return true;
|
||||
|
||||
@@ -335,13 +359,14 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
|
||||
comboPreselectedCredentials.set(getComboCredentialCacheKey(modelString, target), creds);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Fetch settings and all combos for config cascade and nested resolution
|
||||
const [settings, allCombos] = await Promise.all([
|
||||
getSettings().catch(() => ({})),
|
||||
getCombos().catch(() => []),
|
||||
getCachedSettings().catch(() => ({})),
|
||||
getCombosCachedForChat(),
|
||||
]);
|
||||
const relayConfig =
|
||||
combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null;
|
||||
@@ -376,6 +401,10 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
allowedConnectionIds: target?.allowedConnectionIds ?? null,
|
||||
comboStepId: target?.stepId || null,
|
||||
comboExecutionKey: target?.executionKey || target?.stepId || null,
|
||||
preselectedCredentials: comboPreselectedCredentials.get(
|
||||
getComboCredentialCacheKey(m, target)
|
||||
),
|
||||
cachedSettings: settings,
|
||||
},
|
||||
combo.strategy,
|
||||
true
|
||||
@@ -384,6 +413,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
log,
|
||||
settings,
|
||||
allCombos,
|
||||
apiKeyAllowedConnections: apiKeyInfo?.allowedConnections ?? null,
|
||||
relayOptions:
|
||||
combo.strategy === "context-relay"
|
||||
? {
|
||||
@@ -494,6 +524,8 @@ async function handleSingleModelChat(
|
||||
allowedConnectionIds?: string[] | null;
|
||||
comboStepId?: string | null;
|
||||
comboExecutionKey?: string | null;
|
||||
preselectedCredentials?: any;
|
||||
cachedSettings?: any;
|
||||
} = {},
|
||||
comboStrategy: string | null = null,
|
||||
isCombo: boolean = false
|
||||
@@ -588,7 +620,7 @@ async function handleSingleModelChat(
|
||||
|
||||
const userAgent = request?.headers?.get("user-agent") || "";
|
||||
const baseRetrySettings = resolveCooldownAwareRetrySettings(
|
||||
await getCachedSettings().catch(() => ({}))
|
||||
runtimeOptions.cachedSettings ?? (await getCachedSettings().catch(() => ({})))
|
||||
);
|
||||
const disableCooldownAwareRetry =
|
||||
isCombo || forceLiveComboTest || runtimeOptions.emergencyFallbackTried === true;
|
||||
@@ -622,26 +654,31 @@ async function handleSingleModelChat(
|
||||
let lastError = requestRetryLastError;
|
||||
let lastStatus = requestRetryLastStatus;
|
||||
let lastCooldownMs = requestRetryLastCooldownMs;
|
||||
let preselectedCredentials = runtimeOptions.preselectedCredentials;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentialsWithQuotaPreflight(
|
||||
provider,
|
||||
null,
|
||||
effectiveAllowedConnections,
|
||||
model,
|
||||
{
|
||||
excludeConnectionIds: Array.from(excludedConnectionIds),
|
||||
...(forceLiveComboTest
|
||||
? {
|
||||
allowSuppressedConnections: true,
|
||||
bypassQuotaPolicy: true,
|
||||
const credentials =
|
||||
preselectedCredentials && excludedConnectionIds.size === 0
|
||||
? preselectedCredentials
|
||||
: await getProviderCredentialsWithQuotaPreflight(
|
||||
provider,
|
||||
null,
|
||||
effectiveAllowedConnections,
|
||||
model,
|
||||
{
|
||||
excludeConnectionIds: Array.from(excludedConnectionIds),
|
||||
...(forceLiveComboTest
|
||||
? {
|
||||
allowSuppressedConnections: true,
|
||||
bypassQuotaPolicy: true,
|
||||
}
|
||||
: {}),
|
||||
...(runtimeOptions.forcedConnectionId
|
||||
? { forcedConnectionId: runtimeOptions.forcedConnectionId }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(runtimeOptions.forcedConnectionId
|
||||
? { forcedConnectionId: runtimeOptions.forcedConnectionId }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
);
|
||||
preselectedCredentials = null;
|
||||
|
||||
if (!credentials || "allRateLimited" in credentials) {
|
||||
if (credentials?.allRateLimited) {
|
||||
@@ -775,6 +812,7 @@ async function handleSingleModelChat(
|
||||
comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null,
|
||||
extendedContext,
|
||||
providerProfile,
|
||||
cachedSettings: runtimeOptions.cachedSettings,
|
||||
});
|
||||
if (telemetry) telemetry.endPhase();
|
||||
|
||||
|
||||
@@ -276,6 +276,7 @@ export async function executeChatWithBreaker({
|
||||
comboExecutionKey,
|
||||
extendedContext,
|
||||
providerProfile,
|
||||
cachedSettings,
|
||||
}: any): Promise<{ result: any; tlsFingerprintUsed: boolean }> {
|
||||
let tlsFingerprintUsed = false;
|
||||
|
||||
@@ -296,6 +297,7 @@ export async function executeChatWithBreaker({
|
||||
isCombo,
|
||||
comboStepId,
|
||||
comboExecutionKey,
|
||||
cachedSettings,
|
||||
onCredentialsRefreshed: async (newCreds: any) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Re-export from open-sse with localDb integration
|
||||
import { getModelAliases, getComboByName, getProviderNodes, getCustomModels } from "@/lib/localDb";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { getCachedSettings } from "@/lib/localDb";
|
||||
import { getComboStepTarget } from "@/lib/combos/steps";
|
||||
import {
|
||||
parseModel,
|
||||
@@ -83,7 +83,7 @@ export async function getModelInfo(modelStr) {
|
||||
// stripModelPrefix: if enabled, strip provider prefix and re-resolve
|
||||
// the bare model name using existing heuristics (claude-* → anthropic, etc.)
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const settings = await getCachedSettings();
|
||||
if (settings.stripModelPrefix === true) {
|
||||
const strippedResult = await getModelInfoCore(parsed.model, getModelAliases);
|
||||
return { ...strippedResult, extendedContext };
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Combo {
|
||||
nodes: ComboNode[];
|
||||
maxRetries: number;
|
||||
retryDelayMs: number;
|
||||
fallbackDelayMs?: number;
|
||||
timeoutMs: number;
|
||||
healthCheckEnabled: boolean;
|
||||
createdAt: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ComboDefaults {
|
||||
strategy: RoutingStrategyValue;
|
||||
maxRetries: number;
|
||||
retryDelayMs: number;
|
||||
fallbackDelayMs?: number;
|
||||
maxComboDepth: number;
|
||||
trackMetrics: boolean;
|
||||
concurrencyPerModel?: number;
|
||||
|
||||
55
tests/unit/codex-fast-tier.test.ts
Normal file
55
tests/unit/codex-fast-tier.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyCodexGlobalFastServiceTier,
|
||||
getCodexEffectiveFastServiceTier,
|
||||
isCodexGlobalFastServiceTierEnabled,
|
||||
} from "../../src/lib/providers/codexFastTier.ts";
|
||||
|
||||
test("Codex global fast tier recognizes legacy and current setting shapes", () => {
|
||||
assert.equal(isCodexGlobalFastServiceTierEnabled({ codexServiceTier: { enabled: true } }), true);
|
||||
assert.equal(isCodexGlobalFastServiceTierEnabled({ codexServiceTier: true }), true);
|
||||
assert.equal(isCodexGlobalFastServiceTierEnabled({ codexFastServiceTier: true }), true);
|
||||
assert.equal(
|
||||
isCodexGlobalFastServiceTierEnabled({ codexServiceTier: { enabled: false } }),
|
||||
false
|
||||
);
|
||||
assert.equal(isCodexGlobalFastServiceTierEnabled({}), false);
|
||||
});
|
||||
|
||||
test("Codex effective fast tier combines global and per-connection defaults", () => {
|
||||
assert.equal(getCodexEffectiveFastServiceTier({}, false), false);
|
||||
assert.equal(getCodexEffectiveFastServiceTier({}, true), true);
|
||||
assert.equal(
|
||||
getCodexEffectiveFastServiceTier({ requestDefaults: { serviceTier: "priority" } }, false),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
getCodexEffectiveFastServiceTier({ requestDefaults: { serviceTier: "fast" } }, false),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex global fast tier injects priority default without overwriting connection defaults", () => {
|
||||
const injected = applyCodexGlobalFastServiceTier(
|
||||
"codex",
|
||||
{ providerSpecificData: { workspaceId: "ws-1" } },
|
||||
{ codexServiceTier: { enabled: true } }
|
||||
);
|
||||
|
||||
assert.deepEqual(injected.providerSpecificData, {
|
||||
workspaceId: "ws-1",
|
||||
requestDefaults: { serviceTier: "priority" },
|
||||
});
|
||||
|
||||
const existing = { providerSpecificData: { requestDefaults: { serviceTier: "fast" } } };
|
||||
assert.equal(
|
||||
applyCodexGlobalFastServiceTier("codex", existing, { codexServiceTier: { enabled: true } }),
|
||||
existing
|
||||
);
|
||||
assert.equal(
|
||||
applyCodexGlobalFastServiceTier("openai", existing, { codexServiceTier: { enabled: true } }),
|
||||
existing
|
||||
);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ test("signal abort during fallback wait interrupts immediately", async () => {
|
||||
combo: makeCombo("priority", ["a/m1", "b/m2"]),
|
||||
handleSingleModel,
|
||||
log,
|
||||
settings: { retryDelayMs: 5000 }, // 5s delay would normally be slow
|
||||
settings: { fallbackDelayMs: 5000 }, // 5s delay would normally be slow
|
||||
allCombos: [],
|
||||
signal: ac.signal,
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
||||
assert.equal(first.strategy, "priority");
|
||||
assert.equal(first.maxRetries, 1);
|
||||
assert.equal(first.retryDelayMs, 2000);
|
||||
assert.equal(first.fallbackDelayMs, 0);
|
||||
assert.ok(!("timeoutMs" in first));
|
||||
assert.ok(!("healthCheckEnabled" in first));
|
||||
assert.equal(first.handoffThreshold, 0.85);
|
||||
@@ -40,6 +41,7 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid
|
||||
openai: {
|
||||
timeoutMs: 60000,
|
||||
retryDelayMs: 500,
|
||||
fallbackDelayMs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -48,6 +50,7 @@ test("resolveComboConfig applies the full cascade from defaults to combo overrid
|
||||
|
||||
assert.equal(result.strategy, "round-robin");
|
||||
assert.equal(result.retryDelayMs, 500);
|
||||
assert.equal(result.fallbackDelayMs, 100);
|
||||
assert.equal(result.maxRetries, 4);
|
||||
assert.ok(!("timeoutMs" in result));
|
||||
assert.ok(!("healthCheckEnabled" in result));
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-strategies-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const dbCore = await import("../../src/lib/db/core.ts");
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const { invalidateCodexQuotaCache, registerCodexConnection, registerCodexQuotaFetcher } =
|
||||
await import("../../open-sse/services/codexQuotaFetcher.ts");
|
||||
const { registerQuotaFetcher } = await import("../../open-sse/services/quotaPreflight.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const { recordComboRequest } = await import("../../open-sse/services/comboMetrics.ts");
|
||||
const { saveModelsDevCapabilities } = await import("../../src/lib/modelsDevSync.ts");
|
||||
|
||||
@@ -22,6 +28,7 @@ after(() => {
|
||||
} else {
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
}
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
const reqBodyNullContext = {
|
||||
@@ -93,8 +100,105 @@ async function selectedModelFor(combo: Record<string, unknown>, body: Record<str
|
||||
return calls[0];
|
||||
}
|
||||
|
||||
function codexQuota({
|
||||
used5h,
|
||||
reset5hSeconds,
|
||||
used7d,
|
||||
reset7dSeconds,
|
||||
}: {
|
||||
used5h: number;
|
||||
reset5hSeconds: number;
|
||||
used7d: number;
|
||||
reset7dSeconds: number;
|
||||
}) {
|
||||
return {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: used5h,
|
||||
reset_after_seconds: reset5hSeconds,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: used7d,
|
||||
reset_after_seconds: reset7dSeconds,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installCodexQuotaMock(quotasByToken: Record<string, unknown>) {
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
const authorization = headers?.Authorization || headers?.authorization || "";
|
||||
const token = authorization.replace(/^Bearer\s+/i, "");
|
||||
const quota = quotasByToken[token];
|
||||
if (!quota) return Response.json({ error: "missing quota" }, { status: 404 });
|
||||
return Response.json(quota);
|
||||
};
|
||||
return () => {
|
||||
globalThis.fetch = previousFetch;
|
||||
};
|
||||
}
|
||||
|
||||
function resetAwareCombo(
|
||||
name: string,
|
||||
connections: Array<{ id: string; token: string }>,
|
||||
config: Record<string, unknown> = {}
|
||||
) {
|
||||
registerCodexQuotaFetcher();
|
||||
|
||||
for (const connection of connections) {
|
||||
invalidateCodexQuotaCache(connection.id);
|
||||
registerCodexConnection(connection.id, { accessToken: connection.token });
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
strategy: "reset-aware",
|
||||
config,
|
||||
models: connections.map((connection, index) => ({
|
||||
kind: "model",
|
||||
provider: "codex",
|
||||
providerId: "codex",
|
||||
model: "gpt-5",
|
||||
connectionId: connection.id,
|
||||
id: `${name}-${index}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function selectedConnectionFor(
|
||||
combo: Record<string, unknown>,
|
||||
options: { apiKeyAllowedConnections?: string[] | null } = {}
|
||||
) {
|
||||
const calls: Array<string | null> = [];
|
||||
const response = await handleComboChat({
|
||||
body: reqBodyTextArray,
|
||||
combo,
|
||||
allCombos: [combo],
|
||||
isModelAvailable: undefined,
|
||||
relayOptions: undefined,
|
||||
signal: undefined,
|
||||
settings: {},
|
||||
log: makeLog(),
|
||||
apiKeyAllowedConnections: options.apiKeyAllowedConnections,
|
||||
handleSingleModel: async (
|
||||
_body: unknown,
|
||||
modelStr: string,
|
||||
target?: { connectionId?: string | null; allowedConnectionIds?: string[] | null }
|
||||
) => {
|
||||
calls.push(target?.connectionId ?? null);
|
||||
return okResponse(modelStr);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(calls.length > 0, true);
|
||||
return calls[0];
|
||||
}
|
||||
|
||||
test("least-used strategy prefers the model with fewer recorded combo requests", async () => {
|
||||
const name = `least-used-${crypto.randomUUID()}`;
|
||||
const name = `least-used-${randomUUID()}`;
|
||||
const busyModel = "openai/gpt-4";
|
||||
const idleModel = "openai/gpt-3.5-turbo";
|
||||
const combo = await combosDb.createCombo({
|
||||
@@ -121,7 +225,7 @@ test("context-optimized strategy prefers the largest context window", async () =
|
||||
});
|
||||
|
||||
const combo = await combosDb.createCombo({
|
||||
name: `context-optimized-${crypto.randomUUID()}`,
|
||||
name: `context-optimized-${randomUUID()}`,
|
||||
strategy: "context-optimized",
|
||||
models: ["test-context/small", "test-context/large", "unknown/unknown"],
|
||||
});
|
||||
@@ -131,7 +235,7 @@ test("context-optimized strategy prefers the largest context window", async () =
|
||||
|
||||
test("auto strategy handles null and empty prompt edge cases without throwing", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: `auto-${crypto.randomUUID()}`,
|
||||
name: `auto-${randomUUID()}`,
|
||||
strategy: "auto",
|
||||
config: { auto: { explorationRate: 0 } },
|
||||
models: ["openai/gpt-4"],
|
||||
@@ -146,3 +250,288 @@ test("auto strategy handles null and empty prompt edge cases without throwing",
|
||||
);
|
||||
assert.equal(await selectedModelFor(combo, { model: combo.name, messages: [] }), "openai/gpt-4");
|
||||
});
|
||||
|
||||
test("reset-aware strategy prefers lower weekly remaining quota when reset is much sooner", async (t) => {
|
||||
const soon = { id: `soon-${randomUUID()}`, token: `token-soon-${randomUUID()}` };
|
||||
const later = { id: `later-${randomUUID()}`, token: `token-later-${randomUUID()}` };
|
||||
t.after(
|
||||
installCodexQuotaMock({
|
||||
[soon.token]: codexQuota({
|
||||
used5h: 10,
|
||||
reset5hSeconds: 3600,
|
||||
used7d: 40,
|
||||
reset7dSeconds: 24 * 3600,
|
||||
}),
|
||||
[later.token]: codexQuota({
|
||||
used5h: 10,
|
||||
reset5hSeconds: 3600,
|
||||
used7d: 20,
|
||||
reset7dSeconds: 5 * 24 * 3600,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const combo = resetAwareCombo(`reset-aware-soon-${randomUUID()}`, [soon, later]);
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), soon.id);
|
||||
});
|
||||
|
||||
test("reset-aware strategy avoids accounts near 5h exhaustion", async (t) => {
|
||||
const exhausted5h = {
|
||||
id: `exhausted-${randomUUID()}`,
|
||||
token: `token-exhausted-${randomUUID()}`,
|
||||
};
|
||||
const healthy5h = {
|
||||
id: `healthy-${randomUUID()}`,
|
||||
token: `token-healthy-${randomUUID()}`,
|
||||
};
|
||||
t.after(
|
||||
installCodexQuotaMock({
|
||||
[exhausted5h.token]: codexQuota({
|
||||
used5h: 98,
|
||||
reset5hSeconds: 20 * 60,
|
||||
used7d: 5,
|
||||
reset7dSeconds: 24 * 3600,
|
||||
}),
|
||||
[healthy5h.token]: codexQuota({
|
||||
used5h: 20,
|
||||
reset5hSeconds: 4 * 3600,
|
||||
used7d: 50,
|
||||
reset7dSeconds: 4 * 24 * 3600,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const combo = resetAwareCombo(`reset-aware-guard-${randomUUID()}`, [exhausted5h, healthy5h]);
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), healthy5h.id);
|
||||
});
|
||||
|
||||
test("reset-aware strategy rotates similar scores with round-robin tie breaking", async (t) => {
|
||||
const first = { id: `first-${randomUUID()}`, token: `token-first-${randomUUID()}` };
|
||||
const second = {
|
||||
id: `second-${randomUUID()}`,
|
||||
token: `token-second-${randomUUID()}`,
|
||||
};
|
||||
const reset5hAt = Math.floor((Date.now() + 2 * 3600 * 1000) / 1000);
|
||||
const reset7dAt = Math.floor((Date.now() + 3 * 24 * 3600 * 1000) / 1000);
|
||||
const quota = {
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 50, reset_at: reset5hAt },
|
||||
secondary_window: { used_percent: 50, reset_at: reset7dAt },
|
||||
},
|
||||
};
|
||||
t.after(
|
||||
installCodexQuotaMock({
|
||||
[first.token]: quota,
|
||||
[second.token]: quota,
|
||||
})
|
||||
);
|
||||
|
||||
const combo = resetAwareCombo(`reset-aware-rr-${randomUUID()}`, [first, second], {
|
||||
resetAwareTieBandPercent: 100,
|
||||
});
|
||||
|
||||
const selections = [
|
||||
await selectedConnectionFor(combo),
|
||||
await selectedConnectionFor(combo),
|
||||
await selectedConnectionFor(combo),
|
||||
];
|
||||
|
||||
assert.equal(selections.includes(first.id), true);
|
||||
assert.equal(selections.includes(second.id), true);
|
||||
});
|
||||
|
||||
test("reset-aware strategy uses registered quota fetchers for non-Codex providers", async () => {
|
||||
const provider = `quota-provider-${randomUUID()}`;
|
||||
const soon = `soon-${randomUUID()}`;
|
||||
const later = `later-${randomUUID()}`;
|
||||
const resetAtSoon = new Date(Date.now() + 24 * 3600 * 1000).toISOString();
|
||||
const resetAtLater = new Date(Date.now() + 5 * 24 * 3600 * 1000).toISOString();
|
||||
|
||||
registerQuotaFetcher(provider, async (connectionId) => {
|
||||
if (connectionId === soon) {
|
||||
return { used: 40, total: 100, percentUsed: 0.4, resetAt: resetAtSoon };
|
||||
}
|
||||
if (connectionId === later) {
|
||||
return { used: 20, total: 100, percentUsed: 0.2, resetAt: resetAtLater };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const combo = {
|
||||
name: `reset-aware-generic-${randomUUID()}`,
|
||||
strategy: "reset-aware",
|
||||
models: [soon, later].map((connectionId, index) => ({
|
||||
kind: "model",
|
||||
provider,
|
||||
providerId: provider,
|
||||
model: "balanced-model",
|
||||
connectionId,
|
||||
id: `generic-${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), soon);
|
||||
});
|
||||
|
||||
test("reset-aware strategy deduplicates quota fetches for repeated connection targets", async () => {
|
||||
const provider = `dedupe-provider-${randomUUID()}`;
|
||||
const connectionId = `shared-${randomUUID()}`;
|
||||
let fetchCount = 0;
|
||||
|
||||
registerQuotaFetcher(provider, async (id) => {
|
||||
fetchCount++;
|
||||
assert.equal(id, connectionId);
|
||||
return {
|
||||
used: 20,
|
||||
total: 100,
|
||||
percentUsed: 0.2,
|
||||
resetAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const combo = {
|
||||
name: `reset-aware-dedupe-${randomUUID()}`,
|
||||
strategy: "reset-aware",
|
||||
models: ["model-a", "model-b"].map((model, index) => ({
|
||||
kind: "model",
|
||||
provider,
|
||||
providerId: provider,
|
||||
model,
|
||||
connectionId,
|
||||
id: `dedupe-${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), connectionId);
|
||||
assert.equal(fetchCount, 1);
|
||||
});
|
||||
|
||||
test("reset-aware strategy respects API-key allowed connections during expansion", async () => {
|
||||
const provider = `limited-provider-${randomUUID()}`;
|
||||
const disallowed = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `disallowed-${randomUUID()}`,
|
||||
apiKey: "sk-disallowed",
|
||||
isActive: true,
|
||||
});
|
||||
const allowed = await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: `allowed-${randomUUID()}`,
|
||||
apiKey: "sk-allowed",
|
||||
isActive: true,
|
||||
});
|
||||
const allowedId = String(allowed.id);
|
||||
const disallowedId = String(disallowed.id);
|
||||
const fetchedConnectionIds: string[] = [];
|
||||
|
||||
registerQuotaFetcher(provider, async (connectionId) => {
|
||||
fetchedConnectionIds.push(connectionId);
|
||||
return {
|
||||
used: connectionId === disallowedId ? 60 : 20,
|
||||
total: 100,
|
||||
percentUsed: connectionId === disallowedId ? 0.6 : 0.2,
|
||||
resetAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
const combo = {
|
||||
name: `reset-aware-api-key-${randomUUID()}`,
|
||||
strategy: "reset-aware",
|
||||
models: [
|
||||
{
|
||||
kind: "model",
|
||||
provider,
|
||||
providerId: provider,
|
||||
model: "balanced-model",
|
||||
id: "limited-provider-step",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await selectedConnectionFor(combo, { apiKeyAllowedConnections: [allowedId] }),
|
||||
allowedId
|
||||
);
|
||||
assert.deepEqual(fetchedConnectionIds, [allowedId]);
|
||||
});
|
||||
|
||||
test("reset-aware strategy parses numeric reset timestamps from quota telemetry", async () => {
|
||||
const provider = `timestamp-provider-${randomUUID()}`;
|
||||
const soon = `timestamp-soon-${randomUUID()}`;
|
||||
const later = `timestamp-later-${randomUUID()}`;
|
||||
const soonResetSeconds = Math.floor((Date.now() + 24 * 3600 * 1000) / 1000);
|
||||
const laterResetMs = Date.now() + 5 * 24 * 3600 * 1000;
|
||||
|
||||
registerQuotaFetcher(provider, async (connectionId) => ({
|
||||
used: connectionId === soon ? 40 : 20,
|
||||
total: 100,
|
||||
percentUsed: connectionId === soon ? 0.4 : 0.2,
|
||||
resetAt: connectionId === soon ? soonResetSeconds : laterResetMs,
|
||||
}));
|
||||
|
||||
const combo = {
|
||||
name: `reset-aware-timestamps-${randomUUID()}`,
|
||||
strategy: "reset-aware",
|
||||
models: [soon, later].map((connectionId, index) => ({
|
||||
kind: "model",
|
||||
provider,
|
||||
providerId: provider,
|
||||
model: "balanced-model",
|
||||
connectionId,
|
||||
id: `timestamp-${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), soon);
|
||||
});
|
||||
|
||||
test("reset-aware strategy scores provider-specific weekly windows when available", async () => {
|
||||
const provider = `weekly-provider-${randomUUID()}`;
|
||||
const soon = `weekly-soon-${randomUUID()}`;
|
||||
const later = `weekly-later-${randomUUID()}`;
|
||||
const resetAtSoon = new Date(Date.now() + 24 * 3600 * 1000).toISOString();
|
||||
const resetAtLater = new Date(Date.now() + 5 * 24 * 3600 * 1000).toISOString();
|
||||
|
||||
registerQuotaFetcher(provider, async (connectionId) => {
|
||||
if (connectionId === soon) {
|
||||
return {
|
||||
used: 40,
|
||||
total: 100,
|
||||
percentUsed: 0.4,
|
||||
resetAt: resetAtSoon,
|
||||
window5h: { percentUsed: 0.1, resetAt: resetAtSoon },
|
||||
windowWeekly: { percentUsed: 0.4, resetAt: resetAtSoon },
|
||||
};
|
||||
}
|
||||
if (connectionId === later) {
|
||||
return {
|
||||
used: 20,
|
||||
total: 100,
|
||||
percentUsed: 0.2,
|
||||
resetAt: resetAtLater,
|
||||
window5h: { percentUsed: 0.1, resetAt: resetAtSoon },
|
||||
windowWeekly: { percentUsed: 0.2, resetAt: resetAtLater },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const combo = {
|
||||
name: `reset-aware-weekly-${randomUUID()}`,
|
||||
strategy: "reset-aware",
|
||||
models: [soon, later].map((connectionId, index) => ({
|
||||
kind: "model",
|
||||
provider,
|
||||
providerId: provider,
|
||||
model: "balanced-model",
|
||||
connectionId,
|
||||
id: `weekly-${index}`,
|
||||
})),
|
||||
};
|
||||
|
||||
assert.equal(await selectedConnectionFor(combo), soon);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ test("quota labels normalize session and weekly windows while preserving readabl
|
||||
});
|
||||
|
||||
test("MiniMax providers are exposed to the limits dashboard support list", () => {
|
||||
assert.ok(providerConstants.USAGE_SUPPORTED_PROVIDERS.includes("zai"));
|
||||
assert.ok(providerConstants.USAGE_SUPPORTED_PROVIDERS.includes("minimax"));
|
||||
assert.ok(providerConstants.USAGE_SUPPORTED_PROVIDERS.includes("minimax-cn"));
|
||||
});
|
||||
@@ -104,3 +105,44 @@ test("MiniMax quota payloads use generic provider parsing and stale resets still
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel(parsed[0].name), "Session");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel(parsed[1].name), "Weekly");
|
||||
});
|
||||
|
||||
test("Z.AI quota labels render 5h, weekly and monthly tool usage", () => {
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("5 Hours Quota"), "5 Hours");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("Weekly Quota"), "Weekly");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("Monthly Tools"), "Monthly Tools");
|
||||
|
||||
const future = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
const parsed = providerLimitUtils.parseQuotaData("zai", {
|
||||
quotas: {
|
||||
"5 Hours Quota": {
|
||||
used: 15,
|
||||
total: 100,
|
||||
remaining: 85,
|
||||
remainingPercentage: 85,
|
||||
resetAt: future,
|
||||
},
|
||||
"Weekly Quota": {
|
||||
used: 4,
|
||||
total: 100,
|
||||
remaining: 96,
|
||||
remainingPercentage: 96,
|
||||
resetAt: future,
|
||||
},
|
||||
"Monthly Tools": {
|
||||
used: 0,
|
||||
total: 100,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: future,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.length, 3);
|
||||
assert.equal(parsed[0].name, "5 Hours Quota");
|
||||
assert.equal(parsed[0].remainingPercentage, 85);
|
||||
assert.equal(parsed[1].name, "Weekly Quota");
|
||||
assert.equal(parsed[1].remainingPercentage, 96);
|
||||
assert.equal(parsed[2].name, "Monthly Tools");
|
||||
assert.equal(parsed[2].remainingPercentage, 100);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,23 @@ test("settings schemas accept combo configuration modes", () => {
|
||||
assert.equal(sharedSettingsSchema.safeParse({ comboConfigMode: "compact" }).success, false);
|
||||
});
|
||||
|
||||
test("settings schemas accept global Codex fast tier setting", () => {
|
||||
const payload = { codexServiceTier: { enabled: true } };
|
||||
const routeParsed = settingsRouteSchema.parse(payload);
|
||||
const sharedParsed = sharedSettingsSchema.parse(payload);
|
||||
|
||||
assert.deepEqual(routeParsed.codexServiceTier, { enabled: true });
|
||||
assert.deepEqual(sharedParsed.codexServiceTier, { enabled: true });
|
||||
assert.equal(
|
||||
settingsRouteSchema.safeParse({ codexServiceTier: { enabled: "yes" } }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
sharedSettingsSchema.safeParse({ codexServiceTier: { enabled: "yes" } }).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("settings schemas accept endpoint tunnel visibility toggles", () => {
|
||||
const payload = {
|
||||
hideEndpointCloudflaredTunnel: true,
|
||||
|
||||
@@ -150,6 +150,50 @@ test("GET /api/usage/analytics resolves Codex GPT-5.5 pricing through provider a
|
||||
assertClose(body.byModel[0].cost, 0.02);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics applies Codex Fast tier multipliers and exposes tier split", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const timestamp = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, service_tier, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("codex", "gpt-5.5", "codex-fast", 1000, 500, 1, 250, "priority", timestamp);
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, service_tier, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("codex", "gpt-5.5", "codex-standard", 1000, 500, 1, 250, "standard", timestamp);
|
||||
|
||||
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assertClose(body.summary.totalCost, 0.07);
|
||||
assert.equal(body.summary.fastRequests, 1);
|
||||
assert.equal(body.summary.standardRequests, 1);
|
||||
assertClose(body.summary.fastCost, 0.05);
|
||||
assertClose(body.summary.standardCost, 0.02);
|
||||
assert.equal(body.byServiceTier.length, 2);
|
||||
assertClose(body.byProvider[0].cost, 0.07);
|
||||
assertClose(body.byModel[0].cost, 0.07);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics applies Codex GPT-5.4 Fast multiplier", async () => {
|
||||
await localDb.updatePricing({
|
||||
codex: { "gpt-5.4": { input: 5, output: 30 } },
|
||||
});
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, service_tier, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run("codex", "gpt-5.4", "codex-fast", 1000, 500, 1, 250, "priority", new Date().toISOString());
|
||||
|
||||
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assertClose(body.summary.totalCost, 0.04);
|
||||
assertClose(body.summary.fastCost, 0.04);
|
||||
});
|
||||
|
||||
test("GET /api/usage/analytics maps Codex auto-review usage to GPT-5.5 pricing", async () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare(
|
||||
|
||||
@@ -363,6 +363,22 @@ test("computeAnalytics groups renamed API key usage by stable ID", async () => {
|
||||
assert.equal(analytics.byApiKey[0].completionTokens, 15);
|
||||
});
|
||||
|
||||
test("Codex Fast service tier applies documented GPT-5.5 and GPT-5.4 cost multipliers", async () => {
|
||||
await localDb.updatePricing({
|
||||
codex: {
|
||||
"gpt-5.5": { input: 5, output: 30 },
|
||||
"gpt-5.4": { input: 5, output: 30 },
|
||||
},
|
||||
});
|
||||
|
||||
const tokens = { input: 1000, output: 500 };
|
||||
|
||||
assert.equal(await calculateCost("codex", "gpt-5.5", tokens), 0.02);
|
||||
assert.equal(await calculateCost("codex", "gpt-5.5", tokens, { serviceTier: "priority" }), 0.05);
|
||||
assert.equal(await calculateCost("codex", "gpt-5.4-high", tokens, { serviceTier: "fast" }), 0.04);
|
||||
assert.equal(await calculateCost("openai", "gpt-5.5", tokens, { serviceTier: "priority" }), 0);
|
||||
});
|
||||
|
||||
test("recent request summaries are generated from SQLite call logs", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "log-provider",
|
||||
|
||||
@@ -28,6 +28,7 @@ async function seedUsageEntries(
|
||||
success?: boolean;
|
||||
latencyMs?: number;
|
||||
minutesAgo?: number;
|
||||
serviceTier?: string;
|
||||
}>
|
||||
) {
|
||||
for (const [i, e] of entries.entries()) {
|
||||
@@ -41,6 +42,7 @@ async function seedUsageEntries(
|
||||
success: e.success !== false,
|
||||
latencyMs: e.latencyMs || 100,
|
||||
timestamp: new Date(Date.now() - (e.minutesAgo || i) * 60 * 1000).toISOString(),
|
||||
serviceTier: e.serviceTier,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -89,6 +91,32 @@ test("getUsageDb filters by sinceIso date", async () => {
|
||||
assert.equal(result.data.history[0].provider, "new");
|
||||
});
|
||||
|
||||
test("usage history persists service tier and defaults to standard", async () => {
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "codex",
|
||||
model: "gpt-5.5",
|
||||
tokens: { input: 100, output: 50 },
|
||||
success: true,
|
||||
serviceTier: "priority",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
tokens: { input: 10, output: 5 },
|
||||
success: true,
|
||||
timestamp: new Date(Date.now() + 1).toISOString(),
|
||||
});
|
||||
|
||||
const history = await usageHistory.getUsageHistory({ provider: "codex" });
|
||||
const usageDb = await usageHistory.getUsageDb();
|
||||
|
||||
assert.equal(history.length, 1);
|
||||
assert.equal(history[0].serviceTier, "priority");
|
||||
assert.equal(usageDb.data.history[0].serviceTier, "priority");
|
||||
assert.equal(usageDb.data.history[1].serviceTier, "standard");
|
||||
});
|
||||
|
||||
test("getUsageDb provides nextCursor when rows exceed MAX_ROWS", async () => {
|
||||
const db = core.getDbInstance();
|
||||
// Insert exactly MAX_ROWS + 1 = 10001 rows so getUsageDb returns a cursor
|
||||
|
||||
@@ -856,7 +856,7 @@ test("usage service covers Codex auth failures, Kiro hard failures, Kimi no-quot
|
||||
assert.equal(qwenCatch.message, "Unable to fetch Qwen usage.");
|
||||
});
|
||||
|
||||
test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => {
|
||||
test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () => {
|
||||
const qwenMissingUrl: any = await usageService.getUsageForProvider({
|
||||
provider: "qwen",
|
||||
accessToken: "qwen-token",
|
||||
@@ -877,6 +877,15 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => {
|
||||
});
|
||||
assert.match(qoder.message, /Usage tracked per request/i);
|
||||
|
||||
const glmMissingKey: any = await usageService.getUsageForProvider({
|
||||
provider: "glm",
|
||||
apiKey: "",
|
||||
});
|
||||
assert.equal(
|
||||
glmMissingKey.message,
|
||||
"API key not available. Add a coding plan API key to view usage."
|
||||
);
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
if (String(url).includes("/api/monitor/usage/quota/limit")) {
|
||||
assert.equal((init as any).headers.Authorization, "Bearer glm-key");
|
||||
@@ -887,9 +896,24 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => {
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: "64",
|
||||
unit: 3,
|
||||
usage: 15,
|
||||
currentValue: 85,
|
||||
percentage: "15",
|
||||
nextResetTime: Date.now() + 120_000,
|
||||
},
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 6,
|
||||
percentage: "64",
|
||||
nextResetTime: Date.now() + 604_800_000,
|
||||
},
|
||||
{
|
||||
type: "TIME_LIMIT",
|
||||
unit: 5,
|
||||
percentage: "7",
|
||||
nextResetTime: Date.now() + 300_000,
|
||||
},
|
||||
{
|
||||
type: "OTHER_LIMIT",
|
||||
percentage: "10",
|
||||
@@ -910,8 +934,21 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => {
|
||||
providerSpecificData: { apiRegion: "invalid-region" },
|
||||
});
|
||||
assert.equal(glm.plan, "Pro");
|
||||
assert.equal(glm.quotas.session.used, 64);
|
||||
assert.equal(glm.quotas.session.remaining, 36);
|
||||
assert.equal(glm.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(glm.quotas["5 Hours Quota"].remaining, 85);
|
||||
assert.equal(glm.quotas["Weekly Quota"].used, 64);
|
||||
assert.equal(glm.quotas["Weekly Quota"].remaining, 36);
|
||||
assert.equal(glm.quotas["Monthly Tools"].used, 7);
|
||||
assert.equal(glm.quotas["Monthly Tools"].remaining, 93);
|
||||
|
||||
const zai: any = await usageService.getUsageForProvider({
|
||||
provider: "zai",
|
||||
apiKey: "glm-key",
|
||||
});
|
||||
assert.equal(zai.plan, "Pro");
|
||||
assert.equal(zai.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(zai.quotas["Weekly Quota"].remaining, 36);
|
||||
assert.equal(zai.quotas["Monthly Tools"].remaining, 93);
|
||||
|
||||
const glmt: any = await usageService.getUsageForProvider({
|
||||
provider: "glmt",
|
||||
@@ -919,8 +956,8 @@ test("usage service covers Qwen, Qoder, GLM and GLMT branches", async () => {
|
||||
providerSpecificData: { apiRegion: "international" },
|
||||
});
|
||||
assert.equal(glmt.plan, "Pro");
|
||||
assert.equal(glmt.quotas.session.used, 64);
|
||||
assert.equal(glmt.quotas.session.remaining, 36);
|
||||
assert.equal(glmt.quotas["5 Hours Quota"].used, 15);
|
||||
assert.equal(glmt.quotas["Weekly Quota"].remaining, 36);
|
||||
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
await assert.rejects(
|
||||
|
||||
Reference in New Issue
Block a user