diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 59054efc..4513d36c 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -686,6 +686,14 @@ const PRICING_REGISTRY: Record = { }, }; +const MODEL_PRICING_ALIASES: Record = { + // Keep catalog-only IDs on explicit priced equivalents. + 'qwen3-coder': 'qwen3-coder-plus', + 'qwen3-235b': 'qwen3-max', + 'qwen3-vl-plus': 'qwen3.5-plus', + 'qwen3-32b': 'qwen3.5-plus', +}; + // Default pricing for unknown models const UNKNOWN_MODEL_PRICING: ModelPricing = { inputPerMillion: 3.0, @@ -704,39 +712,76 @@ const UNKNOWN_MODEL_PRICING: ModelPricing = { */ function normalizeModelName(model: string): string { // Remove provider prefixes (e.g., "anthropic/claude-..." -> "claude-...") - const normalized = model.toLowerCase().replace(/^[^/]+\//, ''); + const normalized = model + .trim() + .toLowerCase() + .replace(/^[^/]+\//, ''); return normalized; } +const NORMALIZED_PRICING_REGISTRY: Record = Object.entries( + PRICING_REGISTRY +).reduce>((acc, [key, pricing]) => { + acc[normalizeModelName(key)] = pricing; + return acc; +}, {}); + +function getLookupCandidates(model: string): string[] { + const normalized = normalizeModelName(model); + const baseModel = normalized.split(':')[0]; + + return baseModel === normalized ? [normalized] : [normalized, baseModel]; +} + +function getDirectOrAliasPricing(model: string): ModelPricing | undefined { + const directPricing = PRICING_REGISTRY[model]; + if (directPricing !== undefined) { + return directPricing; + } + + for (const candidate of getLookupCandidates(model)) { + const normalizedPricing = NORMALIZED_PRICING_REGISTRY[candidate]; + if (normalizedPricing !== undefined) { + return normalizedPricing; + } + + const alias = MODEL_PRICING_ALIASES[candidate]; + if (alias !== undefined) { + const aliasPricing = NORMALIZED_PRICING_REGISTRY[alias]; + if (aliasPricing !== undefined) { + return aliasPricing; + } + } + } + + return undefined; +} + /** * Get pricing for a model with fuzzy matching fallback * @param model - Model name (exact or with provider prefix) * @returns ModelPricing for the model or fallback pricing */ export function getModelPricing(model: string): ModelPricing { - // Try exact match first - if (PRICING_REGISTRY[model]) { - return PRICING_REGISTRY[model]; + const directOrAliasPricing = getDirectOrAliasPricing(model); + if (directOrAliasPricing !== undefined) { + return directOrAliasPricing; } - // Try normalized match - const normalized = normalizeModelName(model); - if (PRICING_REGISTRY[normalized]) { - return PRICING_REGISTRY[normalized]; - } - - // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - if (normalized.endsWith(key) || key.endsWith(normalized)) { - return pricing; + for (const candidate of getLookupCandidates(model)) { + // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + if (candidate.endsWith(key) || key.endsWith(candidate)) { + return pricing; + } } - } - // Try partial matching for model families - for (const [key, pricing] of Object.entries(PRICING_REGISTRY)) { - // Match by model family prefix - if (normalized.startsWith(key.split('-').slice(0, 2).join('-'))) { - return pricing; + // Try partial matching for model families + for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { + // Match by model family prefix + if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) { + return pricing; + } } } @@ -773,8 +818,5 @@ export function getKnownModels(): string[] { * Check if a model has custom pricing (not using fallback) */ export function hasCustomPricing(model: string): boolean { - return ( - PRICING_REGISTRY[model] !== undefined || - PRICING_REGISTRY[normalizeModelName(model)] !== undefined - ); + return getDirectOrAliasPricing(model) !== undefined; } diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 3b182b8b..30efdc59 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -41,6 +41,41 @@ describe('model-pricing', () => { // Should match via normalization }); + it('should resolve lowercase MiniMax model IDs to custom pricing', () => { + const pricing = getModelPricing('minimax-m2.5'); + expect(pricing.inputPerMillion).toBe(0.3); + expect(pricing.outputPerMillion).toBe(1.2); + }); + + it('should resolve provider-prefixed MiniMax model IDs to custom pricing', () => { + const pricing = getModelPricing('minimax/MiniMax-M2.5'); + expect(pricing.inputPerMillion).toBe(0.3); + expect(pricing.outputPerMillion).toBe(1.2); + }); + + it('should use updated MiniMax-M2.1-lightning input pricing', () => { + const pricing = getModelPricing('MiniMax-M2.1-lightning'); + expect(pricing.inputPerMillion).toBe(0.6); + }); + + it('should not use fallback pricing for known Qwen catalog IDs', () => { + const fallback = getModelPricing('unknown-model-xyz'); + const catalogIds = ['qwen3-235b', 'qwen3-vl-plus', 'qwen3-32b']; + + for (const model of catalogIds) { + const pricing = getModelPricing(model); + expect(pricing).not.toEqual(fallback); + } + }); + + it('should map qwen3-coder to deterministic custom pricing', () => { + const pricing = getModelPricing('qwen3-coder'); + const canonical = getModelPricing('qwen3-coder-plus'); + + expect(pricing).toEqual(canonical); + expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz')); + }); + it('should return different pricing for different model tiers', () => { const sonnet = getModelPricing('claude-sonnet-4-5'); const opus = getModelPricing('claude-opus-4-5-20251101'); @@ -134,6 +169,10 @@ describe('model-pricing', () => { expect(hasCustomPricing('glm-4.6')).toBe(true); }); + it('should return true for deterministic qwen3-coder alias', () => { + expect(hasCustomPricing('qwen3-coder')).toBe(true); + }); + it('should return false for unknown models', () => { expect(hasCustomPricing('unknown-model-xyz')).toBe(false); });