diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts index a45fcd18..26733980 100644 --- a/src/web-server/models-dev/pricing-resolver.ts +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -36,7 +36,9 @@ function normalizeId(value: string): string { return value.trim().toLowerCase(); } -function normalizeProvider(provider: string | undefined): string | undefined { +export function normalizeModelsDevProviderId( + provider: string | null | undefined +): string | undefined { if (!provider) return undefined; const normalized = normalizeId(provider); return PROVIDER_ALIASES[normalized] ?? normalized; @@ -78,7 +80,7 @@ function findProvider( registry: ModelsDevRegistry, provider: string | undefined ): ModelsDevProvider | undefined { - const normalizedProvider = normalizeProvider(provider); + const normalizedProvider = normalizeModelsDevProviderId(provider); if (!normalizedProvider) return undefined; return registry[normalizedProvider]; } diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index e140ae56..db2f5109 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -14,7 +14,7 @@ import { type MonthlyUsage, type SessionUsage, } from './types'; -import { getModelsUsed } from './model-identity'; +import { getModelsUsed, normalizeUsageProvider } from './model-identity'; // ============================================================================ // HELPER FUNCTIONS @@ -74,7 +74,7 @@ interface ModelAccumulator { } function getEntryProvider(entry: RawUsageEntry): string | undefined { - return entry.target?.trim().toLowerCase() || undefined; + return normalizeUsageProvider(entry.target); } function getEntryModelKey(entry: RawUsageEntry): string { diff --git a/src/web-server/usage/model-identity.ts b/src/web-server/usage/model-identity.ts index bd2e5ecd..41198a79 100644 --- a/src/web-server/usage/model-identity.ts +++ b/src/web-server/usage/model-identity.ts @@ -1,10 +1,16 @@ +import { normalizeModelsDevProviderId } from '../models-dev/pricing-resolver'; + export interface ProviderModelIdentity { modelName: string; provider?: string; } +export function normalizeUsageProvider(provider: string | undefined): string | undefined { + return normalizeModelsDevProviderId(provider); +} + function getProviderKey(provider: string | undefined): string { - return provider?.trim().toLowerCase() ?? ''; + return normalizeUsageProvider(provider) ?? ''; } function getModelUsageLabel(item: ProviderModelIdentity, ambiguousModelNames: Set): string { diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 403d438f..6fc5e15f 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -12,6 +12,8 @@ import { import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; import { + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, getLegacyOpenAICompatProxyPidPath, getLegacyOpenAICompatProxySessionPath, getOpenAICompatProxyPidPath, @@ -38,6 +40,37 @@ afterEach(async () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); +async function findProfileNameWithFreeAdaptivePort(prefix: string): Promise { + for (let index = 0; index < 200; index += 1) { + const profileName = `${prefix}-${index}`; + const preferredPort = resolveOpenAICompatProxyPreferredPort(profileName); + const availablePort = await getPort({ port: preferredPort, host: '127.0.0.1' }); + if (availablePort === preferredPort) { + return profileName; + } + } + + throw new Error(`No free adaptive proxy port found for ${prefix}`); +} + +async function getPortOutsideOpenAICompatAdaptiveRange(): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const rangeStart = 45_000 + attempt * 101; + const port = await getPort({ + port: getPort.makeRange(rangeStart, rangeStart + 100), + host: '127.0.0.1', + }); + if ( + port < OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START || + port > OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END + ) { + return port; + } + } + + throw new Error('No stale proxy fixture port found outside the adaptive range'); +} + describe('openai proxy daemon lifecycle', () => { it('starts, reports status, serves health/models, and stops', async () => { const port = await getPort(); @@ -444,8 +477,9 @@ describe('openai proxy daemon lifecycle', () => { }); it('returns to the adaptive canonical port after a stale fallback session', async () => { - const stalePort = await getPort(); - const settingsPath = path.join(tempDir, 'outside-range.settings.json'); + const profileName = await findProfileNameWithFreeAdaptivePort('outside-range'); + const stalePort = await getPortOutsideOpenAICompatAdaptiveRange(); + const settingsPath = path.join(tempDir, `${profileName}.settings.json`); fs.writeFileSync( settingsPath, JSON.stringify({ @@ -459,7 +493,7 @@ describe('openai proxy daemon lifecycle', () => { 'utf8' ); - const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, { + const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range', ANTHROPIC_MODEL: 'qwen3-coder', @@ -469,9 +503,9 @@ describe('openai proxy daemon lifecycle', () => { throw new Error('Expected an outside-range OpenAI-compatible profile'); } - fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true }); + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath(profileName)), { recursive: true }); fs.writeFileSync( - getOpenAICompatProxySessionPath('outside-range'), + getOpenAICompatProxySessionPath(profileName), JSON.stringify( { profileName: profile.profileName, @@ -490,7 +524,7 @@ describe('openai proxy daemon lifecycle', () => { const started = await startOpenAICompatProxy(profile); expect(started.success).toBe(true); - expect(started.port).toBe(resolveOpenAICompatProxyPreferredPort('outside-range')); + expect(started.port).toBe(resolveOpenAICompatProxyPreferredPort(profileName)); expect(started.port).not.toBe(stalePort); }); diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index 3f18f185..e15d468c 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -138,6 +138,18 @@ describe('aggregateDailyUsage', () => { expect(result[0].modelsUsed).toEqual(['gpt-5.5']); }); + + test('canonicalizes provider aliases before grouping usage', () => { + const result = aggregateDailyUsage([ + createEntry({ model: 'gpt-5.5', target: 'ghcp', inputTokens: 1000 }), + createEntry({ model: 'gpt-5.5', target: 'github-copilot', inputTokens: 2000 }), + ]); + + expect(result[0].modelsUsed).toEqual(['gpt-5.5']); + expect(result[0].modelBreakdowns).toHaveLength(1); + expect(result[0].modelBreakdowns[0].provider).toBe('github-copilot'); + expect(result[0].modelBreakdowns[0].inputTokens).toBe(3000); + }); }); // ============================================================================