mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
fix(usage): harden models.dev analytics integration
This commit is contained in:
1 parent
a38c1a75ba
commit
a538627933
9 files changed
+161
-46
No files matched your search
@@ -9,6 +9,8 @@ const CACHE_FILE_NAME = 'models-dev-registry-cache.json';
|
|||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const LIVE_FETCH_TIMEOUT_MS = 3000;
|
const LIVE_FETCH_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
|
let pendingBackgroundRefresh: Promise<ModelsDevRegistry | null> | null = null;
|
||||||
|
|
||||||
export interface RegistryCacheReadOptions {
|
export interface RegistryCacheReadOptions {
|
||||||
allowStale?: boolean;
|
allowStale?: boolean;
|
||||||
now?: number;
|
now?: number;
|
||||||
@@ -131,3 +133,17 @@ export async function refreshModelsDevRegistry(
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function startModelsDevRegistryRefresh(
|
||||||
|
options: RegistryRefreshOptions = {}
|
||||||
|
): Promise<ModelsDevRegistry | null> {
|
||||||
|
if (!pendingBackgroundRefresh) {
|
||||||
|
pendingBackgroundRefresh = refreshModelsDevRegistry(options)
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
pendingBackgroundRefresh = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return pendingBackgroundRefresh;
|
||||||
|
}
|
||||||
@@ -34,7 +34,8 @@ import {
|
|||||||
} from './cliproxy-usage-syncer';
|
} from './cliproxy-usage-syncer';
|
||||||
import { scanCodexNativeUsageEntries } from './codex-native-usage-collector';
|
import { scanCodexNativeUsageEntries } from './codex-native-usage-collector';
|
||||||
import { scanDroidNativeUsageEntries } from './droid-native-usage-collector';
|
import { scanDroidNativeUsageEntries } from './droid-native-usage-collector';
|
||||||
import { refreshModelsDevRegistry } from '../models-dev/registry-cache';
|
import { startModelsDevRegistryRefresh } from '../models-dev/registry-cache';
|
||||||
|
import { getModelsUsed, getProviderModelKey } from './model-identity';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Multi-Instance Support - Aggregate usage from CCS profiles
|
// Multi-Instance Support - Aggregate usage from CCS profiles
|
||||||
@@ -112,10 +113,6 @@ function getHourlyRequestCount(hour: HourlyUsage): number {
|
|||||||
return hour.requestCount ?? hour.modelBreakdowns.length;
|
return hour.requestCount ?? hour.modelBreakdowns.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelBreakdownKey(breakdown: { modelName: string; provider?: string }): string {
|
|
||||||
return `${breakdown.provider ?? ''}\u0000${breakdown.modelName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge daily usage data from multiple sources
|
* Merge daily usage data from multiple sources
|
||||||
* Combines entries with same date by aggregating tokens
|
* Combines entries with same date by aggregating tokens
|
||||||
@@ -133,14 +130,11 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
|||||||
existing.cacheCreationTokens += day.cacheCreationTokens;
|
existing.cacheCreationTokens += day.cacheCreationTokens;
|
||||||
existing.cacheReadTokens += day.cacheReadTokens;
|
existing.cacheReadTokens += day.cacheReadTokens;
|
||||||
existing.totalCost += day.totalCost;
|
existing.totalCost += day.totalCost;
|
||||||
// Merge unique models
|
|
||||||
const modelSet = new Set([...existing.modelsUsed, ...day.modelsUsed]);
|
|
||||||
existing.modelsUsed = Array.from(modelSet);
|
|
||||||
// Merge model breakdowns by aggregating same modelName
|
// Merge model breakdowns by aggregating same modelName
|
||||||
for (const breakdown of day.modelBreakdowns) {
|
for (const breakdown of day.modelBreakdowns) {
|
||||||
const breakdownKey = getModelBreakdownKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(b) => getModelBreakdownKey(b) === breakdownKey
|
(b) => getProviderModelKey(b) === breakdownKey
|
||||||
);
|
);
|
||||||
if (existingBreakdown) {
|
if (existingBreakdown) {
|
||||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||||
@@ -152,12 +146,14 @@ export function mergeDailyData(sources: DailyUsage[][]): DailyUsage[] {
|
|||||||
existing.modelBreakdowns.push({ ...breakdown });
|
existing.modelBreakdowns.push({ ...breakdown });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
|
||||||
} else {
|
} else {
|
||||||
// Clone to avoid mutating original
|
// Clone to avoid mutating original
|
||||||
|
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
||||||
dateMap.set(day.date, {
|
dateMap.set(day.date, {
|
||||||
...day,
|
...day,
|
||||||
modelsUsed: [...day.modelsUsed],
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns: day.modelBreakdowns.map((b) => ({ ...b })),
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,12 +177,10 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
|||||||
existing.cacheCreationTokens += month.cacheCreationTokens;
|
existing.cacheCreationTokens += month.cacheCreationTokens;
|
||||||
existing.cacheReadTokens += month.cacheReadTokens;
|
existing.cacheReadTokens += month.cacheReadTokens;
|
||||||
existing.totalCost += month.totalCost;
|
existing.totalCost += month.totalCost;
|
||||||
const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]);
|
|
||||||
existing.modelsUsed = Array.from(modelSet);
|
|
||||||
for (const breakdown of month.modelBreakdowns) {
|
for (const breakdown of month.modelBreakdowns) {
|
||||||
const breakdownKey = getModelBreakdownKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(item) => getModelBreakdownKey(item) === breakdownKey
|
(item) => getProviderModelKey(item) === breakdownKey
|
||||||
);
|
);
|
||||||
if (existingBreakdown) {
|
if (existingBreakdown) {
|
||||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||||
@@ -198,11 +192,13 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] {
|
|||||||
existing.modelBreakdowns.push({ ...breakdown });
|
existing.modelBreakdowns.push({ ...breakdown });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
|
||||||
} else {
|
} else {
|
||||||
|
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
||||||
monthMap.set(month.month, {
|
monthMap.set(month.month, {
|
||||||
...month,
|
...month,
|
||||||
modelsUsed: [...month.modelsUsed],
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns: month.modelBreakdowns.map((breakdown) => ({ ...breakdown })),
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,13 +224,11 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
|||||||
existing.cacheReadTokens += hour.cacheReadTokens;
|
existing.cacheReadTokens += hour.cacheReadTokens;
|
||||||
existing.totalCost += hour.totalCost;
|
existing.totalCost += hour.totalCost;
|
||||||
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
||||||
const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]);
|
|
||||||
existing.modelsUsed = Array.from(modelSet);
|
|
||||||
// Merge model breakdowns
|
// Merge model breakdowns
|
||||||
for (const breakdown of hour.modelBreakdowns) {
|
for (const breakdown of hour.modelBreakdowns) {
|
||||||
const breakdownKey = getModelBreakdownKey(breakdown);
|
const breakdownKey = getProviderModelKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.find(
|
const existingBreakdown = existing.modelBreakdowns.find(
|
||||||
(b) => getModelBreakdownKey(b) === breakdownKey
|
(b) => getProviderModelKey(b) === breakdownKey
|
||||||
);
|
);
|
||||||
if (existingBreakdown) {
|
if (existingBreakdown) {
|
||||||
existingBreakdown.inputTokens += breakdown.inputTokens;
|
existingBreakdown.inputTokens += breakdown.inputTokens;
|
||||||
@@ -246,11 +240,13 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] {
|
|||||||
existing.modelBreakdowns.push({ ...breakdown });
|
existing.modelBreakdowns.push({ ...breakdown });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
existing.modelsUsed = getModelsUsed(existing.modelBreakdowns);
|
||||||
} else {
|
} else {
|
||||||
|
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
||||||
hourMap.set(hour.hour, {
|
hourMap.set(hour.hour, {
|
||||||
...hour,
|
...hour,
|
||||||
modelsUsed: [...hour.modelsUsed],
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })),
|
modelBreakdowns,
|
||||||
requestCount: getHourlyRequestCount(hour),
|
requestCount: getHourlyRequestCount(hour),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -356,9 +352,9 @@ async function refreshFromSource(): Promise<{
|
|||||||
monthly: MonthlyUsage[];
|
monthly: MonthlyUsage[];
|
||||||
session: SessionUsage[];
|
session: SessionUsage[];
|
||||||
}> {
|
}> {
|
||||||
// Refresh model metadata before cost derivation. This is best-effort and
|
// Keep model metadata warming off the analytics request path. Current
|
||||||
// falls back to stale cache/static pricing when models.dev is unavailable.
|
// refreshes use cached/static pricing; the background result helps future runs.
|
||||||
await refreshModelsDevRegistry();
|
void startModelsDevRegistryRefresh();
|
||||||
|
|
||||||
// Try to sync CLIProxy snapshot before reading it.
|
// Try to sync CLIProxy snapshot before reading it.
|
||||||
// Non-fatal: syncer handles unavailability and stale fallback.
|
// Non-fatal: syncer handles unavailability and stale fallback.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher';
|
import type { CliproxyUsageApiResponse, CliproxyRequestDetail } from '../../cliproxy/stats-fetcher';
|
||||||
import { calculateCost } from '../model-pricing';
|
import { calculateCost } from '../model-pricing';
|
||||||
import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types';
|
import type { ModelBreakdown, DailyUsage, HourlyUsage, MonthlyUsage } from './types';
|
||||||
|
import { getModelsUsed } from './model-identity';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INTERNAL HELPERS
|
// INTERNAL HELPERS
|
||||||
@@ -257,10 +258,6 @@ function sumField(breakdowns: ModelBreakdown[], field: keyof ModelBreakdown): nu
|
|||||||
return breakdowns.reduce((acc, b) => acc + (b[field] as number), 0);
|
return breakdowns.reduce((acc, b) => acc + (b[field] as number), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelsUsed(breakdowns: ModelBreakdown[]): string[] {
|
|
||||||
return [...new Set(breakdowns.map((breakdown) => breakdown.modelName))];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TRANSFORMS
|
// TRANSFORMS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type MonthlyUsage,
|
type MonthlyUsage,
|
||||||
type SessionUsage,
|
type SessionUsage,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { getModelsUsed } from './model-identity';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
@@ -91,10 +92,6 @@ function createModelAccumulator(entry: RawUsageEntry): ModelAccumulator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelsUsed(modelMap: Map<string, ModelAccumulator>): string[] {
|
|
||||||
return [...new Set(Array.from(modelMap.values()).map((acc) => acc.modelName))];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DAILY AGGREGATION
|
// DAILY AGGREGATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -173,7 +170,7 @@ export function aggregateDailyUsage(
|
|||||||
cacheReadTokens: totalCacheRead,
|
cacheReadTokens: totalCacheRead,
|
||||||
cost: totalCost,
|
cost: totalCost,
|
||||||
totalCost,
|
totalCost,
|
||||||
modelsUsed: getModelsUsed(modelMap),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -262,7 +259,7 @@ export function aggregateHourlyUsage(
|
|||||||
cacheReadTokens: totalCacheRead,
|
cacheReadTokens: totalCacheRead,
|
||||||
cost: totalCost,
|
cost: totalCost,
|
||||||
totalCost,
|
totalCost,
|
||||||
modelsUsed: getModelsUsed(modelMap),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
requestCount: hourEntries.length,
|
requestCount: hourEntries.length,
|
||||||
});
|
});
|
||||||
@@ -351,7 +348,7 @@ export function aggregateMonthlyUsage(
|
|||||||
cacheCreationTokens: totalCacheCreation,
|
cacheCreationTokens: totalCacheCreation,
|
||||||
cacheReadTokens: totalCacheRead,
|
cacheReadTokens: totalCacheRead,
|
||||||
totalCost,
|
totalCost,
|
||||||
modelsUsed: getModelsUsed(modelMap),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -469,7 +466,7 @@ export function aggregateSessionUsage(
|
|||||||
totalCost,
|
totalCost,
|
||||||
lastActivity,
|
lastActivity,
|
||||||
versions: Array.from(versions),
|
versions: Array.from(versions),
|
||||||
modelsUsed: getModelsUsed(modelMap),
|
modelsUsed: getModelsUsed(modelBreakdowns),
|
||||||
modelBreakdowns,
|
modelBreakdowns,
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
getLastFetchTimestamp,
|
getLastFetchTimestamp,
|
||||||
refreshUsageCache,
|
refreshUsageCache,
|
||||||
} from './aggregator';
|
} from './aggregator';
|
||||||
|
import { getModelsUsed } from './model-identity';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@@ -716,7 +717,6 @@ export async function handleMonthly(
|
|||||||
cacheCreationTokens: number;
|
cacheCreationTokens: number;
|
||||||
cacheReadTokens: number;
|
cacheReadTokens: number;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
modelsUsed: Set<string>;
|
|
||||||
modelBreakdowns: Map<
|
modelBreakdowns: Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -741,7 +741,6 @@ export async function handleMonthly(
|
|||||||
cacheCreationTokens: 0,
|
cacheCreationTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
totalCost: 0,
|
totalCost: 0,
|
||||||
modelsUsed: new Set<string>(),
|
|
||||||
modelBreakdowns: new Map(),
|
modelBreakdowns: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -750,9 +749,6 @@ export async function handleMonthly(
|
|||||||
existing.cacheCreationTokens += day.cacheCreationTokens;
|
existing.cacheCreationTokens += day.cacheCreationTokens;
|
||||||
existing.cacheReadTokens += day.cacheReadTokens;
|
existing.cacheReadTokens += day.cacheReadTokens;
|
||||||
existing.totalCost += day.totalCost;
|
existing.totalCost += day.totalCost;
|
||||||
for (const model of day.modelsUsed) {
|
|
||||||
existing.modelsUsed.add(model);
|
|
||||||
}
|
|
||||||
for (const breakdown of day.modelBreakdowns) {
|
for (const breakdown of day.modelBreakdowns) {
|
||||||
const breakdownKey = getBreakdownKey(breakdown);
|
const breakdownKey = getBreakdownKey(breakdown);
|
||||||
const existingBreakdown = existing.modelBreakdowns.get(breakdownKey) ?? {
|
const existingBreakdown = existing.modelBreakdowns.get(breakdownKey) ?? {
|
||||||
@@ -783,8 +779,8 @@ export async function handleMonthly(
|
|||||||
cacheCreationTokens: month.cacheCreationTokens,
|
cacheCreationTokens: month.cacheCreationTokens,
|
||||||
cacheReadTokens: month.cacheReadTokens,
|
cacheReadTokens: month.cacheReadTokens,
|
||||||
totalCost: month.totalCost,
|
totalCost: month.totalCost,
|
||||||
modelsUsed: Array.from(month.modelsUsed),
|
|
||||||
modelBreakdowns: Array.from(month.modelBreakdowns.values()),
|
modelBreakdowns: Array.from(month.modelBreakdowns.values()),
|
||||||
|
modelsUsed: getModelsUsed(Array.from(month.modelBreakdowns.values())),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.month.localeCompare(b.month));
|
.sort((a, b) => a.month.localeCompare(b.month));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export interface ProviderModelIdentity {
|
||||||
|
modelName: string;
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderKey(provider: string | undefined): string {
|
||||||
|
return provider?.trim().toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelUsageLabel(item: ProviderModelIdentity, ambiguousModelNames: Set<string>): string {
|
||||||
|
const provider = getProviderKey(item.provider);
|
||||||
|
if (provider && ambiguousModelNames.has(item.modelName)) {
|
||||||
|
return `${provider}/${item.modelName}`;
|
||||||
|
}
|
||||||
|
return item.modelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderModelKey(item: ProviderModelIdentity): string {
|
||||||
|
return `${getProviderKey(item.provider)}\u0000${item.modelName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelsUsed(items: ProviderModelIdentity[]): string[] {
|
||||||
|
const providersByModel = new Map<string, Set<string>>();
|
||||||
|
for (const item of items) {
|
||||||
|
const providers = providersByModel.get(item.modelName) ?? new Set<string>();
|
||||||
|
providers.add(getProviderKey(item.provider));
|
||||||
|
providersByModel.set(item.modelName, providers);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ambiguousModelNames = new Set(
|
||||||
|
Array.from(providersByModel.entries())
|
||||||
|
.filter(([, providers]) => providers.size > 1)
|
||||||
|
.map(([modelName]) => modelName)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...new Set(items.map((item) => getModelUsageLabel(item, ambiguousModelNames)))];
|
||||||
|
}
|
||||||
@@ -395,13 +395,13 @@ describe('openai proxy daemon lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the existing proxy running if replacement startup fails', async () => {
|
it('keeps the existing proxy running if replacement startup fails', async () => {
|
||||||
const firstPort = await getPort();
|
|
||||||
const busyServer = Bun.serve({
|
const busyServer = Bun.serve({
|
||||||
port: 0,
|
port: 0,
|
||||||
hostname: '127.0.0.1',
|
hostname: '127.0.0.1',
|
||||||
fetch: () => new Response('busy'),
|
fetch: () => new Response('busy'),
|
||||||
});
|
});
|
||||||
const occupiedPort = busyServer.port;
|
const occupiedPort = busyServer.port;
|
||||||
|
const firstPort = await getPort({ exclude: [occupiedPort] });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settingsPath = path.join(tempDir, 'rollback.settings.json');
|
const settingsPath = path.join(tempDir, 'rollback.settings.json');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import {
|
import {
|
||||||
aggregateDailyUsage,
|
aggregateDailyUsage,
|
||||||
|
aggregateHourlyUsage,
|
||||||
aggregateMonthlyUsage,
|
aggregateMonthlyUsage,
|
||||||
aggregateSessionUsage,
|
aggregateSessionUsage,
|
||||||
} from '../../src/web-server/data-aggregator';
|
} from '../../src/web-server/data-aggregator';
|
||||||
@@ -107,6 +108,36 @@ describe('aggregateDailyUsage', () => {
|
|||||||
const result = aggregateDailyUsage(entries, 'test-source');
|
const result = aggregateDailyUsage(entries, 'test-source');
|
||||||
expect(result[0].source).toBe('test-source');
|
expect(result[0].source).toBe('test-source');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps provider identity when same model appears under multiple providers', () => {
|
||||||
|
const entries: RawUsageEntry[] = [
|
||||||
|
createEntry({ model: 'gpt-5.5', target: 'openai' }),
|
||||||
|
createEntry({ model: 'gpt-5.5', target: 'github-copilot' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const daily = aggregateDailyUsage(entries);
|
||||||
|
const hourly = aggregateHourlyUsage(entries);
|
||||||
|
const monthly = aggregateMonthlyUsage(entries);
|
||||||
|
const session = aggregateSessionUsage(entries);
|
||||||
|
|
||||||
|
expect(daily[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']);
|
||||||
|
expect(hourly[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']);
|
||||||
|
expect(monthly[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']);
|
||||||
|
expect(session[0].modelsUsed).toEqual(['openai/gpt-5.5', 'github-copilot/gpt-5.5']);
|
||||||
|
expect(daily[0].modelBreakdowns.map((item) => item.provider)).toEqual([
|
||||||
|
'openai',
|
||||||
|
'github-copilot',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves model-only modelsUsed entries when provider is unambiguous', () => {
|
||||||
|
const result = aggregateDailyUsage([
|
||||||
|
createEntry({ model: 'gpt-5.5', target: 'openai' }),
|
||||||
|
createEntry({ model: 'gpt-5.5', target: 'openai' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result[0].modelsUsed).toEqual(['gpt-5.5']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getCachedModelsDevRegistry,
|
getCachedModelsDevRegistry,
|
||||||
refreshModelsDevRegistry,
|
refreshModelsDevRegistry,
|
||||||
setCachedModelsDevRegistry,
|
setCachedModelsDevRegistry,
|
||||||
|
startModelsDevRegistryRefresh,
|
||||||
} from '../../src/web-server/models-dev/registry-cache';
|
} from '../../src/web-server/models-dev/registry-cache';
|
||||||
|
|
||||||
describe('models.dev registry cache', () => {
|
describe('models.dev registry cache', () => {
|
||||||
@@ -90,4 +91,48 @@ describe('models.dev registry cache', () => {
|
|||||||
fs.writeFileSync(path.join(getCcsDir(), 'models-dev-registry-cache.json'), '{not json');
|
fs.writeFileSync(path.join(getCcsDir(), 'models-dev-registry-cache.json'), '{not json');
|
||||||
expect(getCachedModelsDevRegistry({ allowStale: true })).toBeNull();
|
expect(getCachedModelsDevRegistry({ allowStale: true })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts and coalesces background refreshes without requiring callers to await', async () => {
|
||||||
|
let fetchCalls = 0;
|
||||||
|
let resolveResponse: (response: Response) => void = () => undefined;
|
||||||
|
const responsePromise = new Promise<Response>((resolve) => {
|
||||||
|
resolveResponse = resolve;
|
||||||
|
});
|
||||||
|
const fetchImpl: typeof fetch = async () => {
|
||||||
|
fetchCalls += 1;
|
||||||
|
return responsePromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = startModelsDevRegistryRefresh({
|
||||||
|
force: true,
|
||||||
|
fetchImpl,
|
||||||
|
now: () => 456,
|
||||||
|
});
|
||||||
|
const second = startModelsDevRegistryRefresh({
|
||||||
|
force: true,
|
||||||
|
fetchImpl,
|
||||||
|
now: () => 789,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toBe(second);
|
||||||
|
expect(fetchCalls).toBe(1);
|
||||||
|
expect(getCachedModelsDevRegistry({ allowStale: true })).toBeNull();
|
||||||
|
|
||||||
|
resolveResponse(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
openai: {
|
||||||
|
id: 'openai',
|
||||||
|
models: {
|
||||||
|
'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await first;
|
||||||
|
expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.id).toBe('openai');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user