mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(usage): account for codex fast tier analytics
This commit is contained in:
@@ -82,6 +82,10 @@ import {
|
||||
import { getCacheMetrics } from "@/lib/db/settings.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";
|
||||
|
||||
@@ -981,6 +985,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",
|
||||
@@ -995,6 +1014,7 @@ export async function handleChatCore({
|
||||
connectionId: connectionId || undefined,
|
||||
apiKeyId: apiKeyInfo?.id || undefined,
|
||||
apiKeyName: apiKeyInfo?.name || undefined,
|
||||
serviceTier: effectiveServiceTier,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -1084,7 +1104,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,
|
||||
@@ -1404,6 +1426,7 @@ export async function handleChatCore({
|
||||
: resolveStreamFlag(body?.stream, acceptHeader);
|
||||
const settings = await getCachedSettings();
|
||||
credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings);
|
||||
effectiveServiceTier = resolveEffectiveServiceTier(body);
|
||||
setGeminiThoughtSignatureMode(settings.antigravitySignatureCacheMode);
|
||||
const semanticCacheEnabled = settings.semanticCacheEnabled !== false;
|
||||
|
||||
@@ -1441,7 +1464,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,
|
||||
@@ -1896,7 +1921,8 @@ export async function handleChatCore({
|
||||
effectiveModel ?? "",
|
||||
{
|
||||
input: tokensSaved,
|
||||
}
|
||||
},
|
||||
{ serviceTier: effectiveServiceTier }
|
||||
);
|
||||
insertCompressionAnalyticsRow({
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -2945,6 +2971,7 @@ export async function handleChatCore({
|
||||
providerUrl = result.url;
|
||||
providerHeaders = result.headers;
|
||||
finalBody = result.transformedBody;
|
||||
effectiveServiceTier = resolveEffectiveServiceTier(finalBody);
|
||||
claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
|
||||
targetFormat,
|
||||
finalBody,
|
||||
@@ -3685,6 +3712,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);
|
||||
});
|
||||
@@ -3817,7 +3845,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";
|
||||
@@ -4047,6 +4077,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);
|
||||
});
|
||||
@@ -4065,7 +4096,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);
|
||||
})
|
||||
|
||||
@@ -2855,8 +2855,8 @@ 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">
|
||||
@@ -2865,8 +2865,9 @@ export default function ProviderDetailPage() {
|
||||
checked={codexGlobalFastServiceTier}
|
||||
onChange={handleToggleCodexGlobalFastServiceTier}
|
||||
disabled={savingCodexGlobalFastServiceTier}
|
||||
label="Fast"
|
||||
className="rounded-lg border border-border bg-bg-subtle px-2 py-1"
|
||||
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>
|
||||
)}
|
||||
@@ -2898,42 +2899,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 ? (
|
||||
|
||||
@@ -57,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) {
|
||||
@@ -66,6 +67,7 @@ function getStatusDisplay(
|
||||
{t("connected", { count: connected })}
|
||||
</Badge>
|
||||
);
|
||||
if (afterConnected) parts.push(afterConnected);
|
||||
}
|
||||
if (error > 0) {
|
||||
const errText = errorCode
|
||||
@@ -98,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"),
|
||||
@@ -184,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"
|
||||
@@ -207,14 +220,6 @@ export default function ProviderCard({
|
||||
{t("expiringSoonBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{providerId === "codex" && stats.codexFastActive && (
|
||||
<Badge variant="default" size="sm" title="Codex Fast tier is active">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="material-symbols-outlined text-[10px]">bolt</span>
|
||||
Fast
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{isCompatible && (
|
||||
<Badge variant="default" size="sm">
|
||||
{provider.apiType === "responses" ? t("responses") : t("chat")}
|
||||
|
||||
@@ -35,7 +35,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 {
|
||||
@@ -55,6 +56,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})`;
|
||||
}
|
||||
@@ -195,6 +205,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,
|
||||
@@ -205,13 +216,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 {
|
||||
@@ -331,6 +350,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,
|
||||
@@ -338,7 +358,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
|
||||
`
|
||||
)
|
||||
@@ -384,6 +404,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,
|
||||
@@ -396,9 +417,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>>;
|
||||
@@ -409,6 +429,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,
|
||||
@@ -416,7 +437,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>>;
|
||||
@@ -447,6 +468,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,
|
||||
@@ -455,7 +477,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>>;
|
||||
@@ -493,6 +515,7 @@ export async function GET(request: Request) {
|
||||
COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName,
|
||||
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,
|
||||
@@ -502,7 +525,28 @@ export async function GET(request: Request) {
|
||||
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
|
||||
FROM usage_history
|
||||
${apiKeyWhereClause}
|
||||
GROUP BY api_key_id, api_key_name, LOWER(provider), LOWER(model)
|
||||
GROUP BY api_key_id, api_key_name, 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>>;
|
||||
@@ -584,6 +628,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(
|
||||
@@ -648,14 +697,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,
|
||||
@@ -663,23 +709,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);
|
||||
@@ -781,6 +863,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) => ({
|
||||
@@ -816,6 +948,7 @@ export async function GET(request: Request) {
|
||||
byProvider,
|
||||
byApiKey,
|
||||
byAccount,
|
||||
byServiceTier,
|
||||
weeklyPattern,
|
||||
weeklyTokens,
|
||||
weeklyCounts,
|
||||
|
||||
@@ -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,
|
||||
@@ -260,6 +261,7 @@ const SCHEMA_SQL = `
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
src/lib/db/migrations/051_usage_history_service_tier.sql
Normal file
4
src/lib/db/migrations/051_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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -81,7 +81,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) >= ?
|
||||
|
||||
@@ -98,7 +99,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 < ?
|
||||
|
||||
@@ -193,6 +195,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);
|
||||
@@ -205,7 +208,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";
|
||||
@@ -311,10 +312,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",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -331,6 +332,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",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
@@ -351,6 +358,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}
|
||||
|
||||
@@ -966,6 +966,70 @@ export function ModelTable({ byModel, summary }) {
|
||||
[sortBy]
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...(byModel || [])];
|
||||
arr.sort((a, b) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
ProviderCostDonut,
|
||||
ModelOverTimeChart,
|
||||
ProviderTable,
|
||||
ServiceTierBreakdown,
|
||||
} from "./charts";
|
||||
|
||||
export { default as ApiKeyFilterDropdown } from "./ApiKeyFilterDropdown";
|
||||
|
||||
@@ -150,21 +150,56 @@ 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(
|
||||
`INSERT INTO usage_history (provider, model, connection_id, tokens_input, tokens_output, success, latency_ms, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
"codex",
|
||||
"codex-auto-review",
|
||||
"codex-conn",
|
||||
1000,
|
||||
500,
|
||||
1,
|
||||
250,
|
||||
new Date().toISOString()
|
||||
);
|
||||
).run("codex", "codex-auto-review", "codex-conn", 1000, 500, 1, 250, new Date().toISOString());
|
||||
|
||||
const response = await analyticsRoute.GET(makeRequest("http://localhost/api/usage/analytics"));
|
||||
const body = await response.json();
|
||||
|
||||
@@ -275,6 +275,22 @@ test("getUsageStats aggregates totals, buckets, pending requests, and cost break
|
||||
assert.equal(recentBucketTotal, 1);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user