Update structure

This commit is contained in:
Owen
2026-08-07 14:29:48 -04:00
parent 9eafa067b9
commit ca79abc9d4
+72 -88
View File
@@ -5,22 +5,25 @@ import type { AiProviderType } from "@server/lib/aiProviderDefaults";
import type { AiUsage } from "@server/lib/aiUsageExtraction"; import type { AiUsage } from "@server/lib/aiUsageExtraction";
import logger from "@server/logger"; import logger from "@server/logger";
// config/models.json is a runtime asset (same category as config.yml or the
// MaxMind DBs) - not part of the source tree. Its shape mirrors litellm's
// public model_prices_and_context_window.json: a flat list of
// { id, name, provider, input_cost_per_token, output_cost_per_token,
// cache_read_input_token_cost, output_cost_per_reasoning_token }, where
// `provider` is litellm's provider bucket, not our AiProviderType.
const MODELS_JSON_PATH = path.join(APP_PATH, "models.json"); const MODELS_JSON_PATH = path.join(APP_PATH, "models.json");
export type AiModelPricingEntry = { export type CatalogProvider =
id: string; | "openai"
name: string; | "anthropic"
provider: string; | "gemini"
input_cost_per_token: number | null; | "vertex"
output_cost_per_token: number | null; | "azure"
cache_read_input_token_cost: number | null; | "bedrock";
output_cost_per_reasoning_token: number | null;
export type AiModelCatalogEntry = {
provider: CatalogProvider;
model: string;
pricing: {
input: number | null;
output: number | null;
cacheRead: number | null;
reasoningOutput: number | null;
};
}; };
export type AiModelPricing = { export type AiModelPricing = {
@@ -28,65 +31,48 @@ export type AiModelPricing = {
outputCostPerToken: number | null; outputCostPerToken: number | null;
cacheReadInputTokenCost: number | null; cacheReadInputTokenCost: number | null;
outputCostPerReasoningToken: number | null; outputCostPerReasoningToken: number | null;
// True when the match came from a different provider bucket than the one // True when the match came from a different catalog provider than the
// mapped to this provider's type (e.g. an openRouter/custom model id that // one mapped to this provider's type (e.g. an openRouter/custom model
// only matched by stripping a "vendor/" prefix against the whole table). // id that only matched a global search across every provider). Costs
// Costs found this way are a best-effort approximation, not a guarantee // found this way are a best-effort approximation, not a guarantee the
// the upstream provider bills at the same rate. // upstream provider bills at the same rate.
approximate: boolean; approximate: boolean;
}; };
// Which litellm provider buckets to search for each of our provider types. // Each of our provider types maps to at most one catalog provider. Provider types that proxy
// Several of our provider types (openRouter, vercelAiGateway, custom) proxy // arbitrary underlying models (openRouter, vercelAiGateway, custom) have no
// arbitrary underlying models and have no dedicated bucket in the pricing // mapping and always fall back to a global search.
// data, so they fall back to a global search across all buckets. const PROVIDER_CATALOG_MAP: Record<
const PROVIDER_PRICING_BUCKETS: Record<
Exclude<AiProviderType, "custom">, Exclude<AiProviderType, "custom">,
string[] CatalogProvider | null
> = { > = {
openai: ["openai"], openai: "openai",
anthropic: ["anthropic"], anthropic: "anthropic",
googleGemini: ["gemini"], googleGemini: "gemini",
vertexAi: [ vertexAi: "vertex",
"vertex_ai-language-models", bedrock: "bedrock",
"vertex_ai", microsoftFoundry: "azure",
"vertex_ai-anthropic_models", openRouter: null,
"vertex_ai-mistral_models", vercelAiGateway: null
"vertex_ai-deepseek_models",
"vertex_ai-ai21_models",
"vertex_ai-llama_models",
"vertex_ai-minimax_models",
"vertex_ai-moonshot_models",
"vertex_ai-zai_models",
"vertex_ai-openai_models",
"vertex_ai-qwen_models",
"vertex_ai-text-models"
],
bedrock: ["bedrock_converse", "bedrock", "bedrock_mantle"],
microsoftFoundry: ["azure", "azure_ai", "azure_text"],
openRouter: [],
vercelAiGateway: []
}; };
let modelsById: Map<string, AiModelPricingEntry[]> | null = null; let modelsByName: Map<string, AiModelCatalogEntry[]> | null = null;
function loadModels(): Map<string, AiModelPricingEntry[]> { function loadModels(): Map<string, AiModelCatalogEntry[]> {
if (modelsById) { if (modelsByName) {
return modelsById; return modelsByName;
} }
const byId = new Map<string, AiModelPricingEntry[]>(); const byName = new Map<string, AiModelCatalogEntry[]>();
try { try {
if (fs.existsSync(MODELS_JSON_PATH)) { if (fs.existsSync(MODELS_JSON_PATH)) {
const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8"); const raw = fs.readFileSync(MODELS_JSON_PATH, "utf-8");
const parsed = JSON.parse(raw) as { data: AiModelPricingEntry[] }; const parsed = JSON.parse(raw) as { data: AiModelCatalogEntry[] };
for (const entry of parsed.data ?? []) { for (const entry of parsed.data ?? []) {
for (const key of [entry.id, entry.name]) { if (!entry.model) continue;
if (!key) continue; const list = byName.get(entry.model) ?? [];
const list = byId.get(key) ?? []; list.push(entry);
list.push(entry); byName.set(entry.model, list);
byId.set(key, list);
}
} }
} else { } else {
logger.debug( logger.debug(
@@ -97,8 +83,8 @@ function loadModels(): Map<string, AiModelPricingEntry[]> {
logger.warn("Failed to load AI model pricing file", { error }); logger.warn("Failed to load AI model pricing file", { error });
} }
modelsById = byId; modelsByName = byName;
return byId; return byName;
} }
function stripVendorPrefix(modelId: string): string | null { function stripVendorPrefix(modelId: string): string | null {
@@ -110,32 +96,32 @@ function stripVendorPrefix(modelId: string): string | null {
} }
function toPricing( function toPricing(
entry: AiModelPricingEntry, entry: AiModelCatalogEntry,
approximate: boolean approximate: boolean
): AiModelPricing { ): AiModelPricing {
return { return {
inputCostPerToken: entry.input_cost_per_token, inputCostPerToken: entry.pricing.input,
outputCostPerToken: entry.output_cost_per_token, outputCostPerToken: entry.pricing.output,
cacheReadInputTokenCost: entry.cache_read_input_token_cost, cacheReadInputTokenCost: entry.pricing.cacheRead,
outputCostPerReasoningToken: entry.output_cost_per_reasoning_token, outputCostPerReasoningToken: entry.pricing.reasoningOutput,
approximate approximate
}; };
} }
function findInBuckets( function findEntry(
byId: Map<string, AiModelPricingEntry[]>, byName: Map<string, AiModelCatalogEntry[]>,
modelId: string, modelId: string,
buckets: string[] | null provider: CatalogProvider | null
): AiModelPricingEntry | null { ): AiModelCatalogEntry | null {
const candidates = [modelId, stripVendorPrefix(modelId)].filter( const candidates = [modelId, stripVendorPrefix(modelId)].filter(
(v): v is string => v != null (v): v is string => v != null
); );
for (const key of candidates) { for (const key of candidates) {
const entries = byId.get(key); const entries = byName.get(key);
if (!entries) continue; if (!entries) continue;
const match = buckets const match = provider
? entries.find((e) => buckets.includes(e.provider)) ? entries.find((e) => e.provider === provider)
: entries[0]; : entries[0];
if (match) { if (match) {
return match; return match;
@@ -145,10 +131,10 @@ function findInBuckets(
} }
/** /**
* Looks up per-token pricing for a model, scoped first to the litellm * Looks up per-token pricing for a model, scoped first to the catalog
* provider bucket(s) that correspond to our provider type, then falling * provider that corresponds to our provider type, then falling back to a
* back to a global search across all buckets (marked `approximate`) for * global search across every provider (marked `approximate`) for provider
* provider types that proxy arbitrary underlying models. * types that proxy arbitrary underlying models.
*/ */
export function getModelPricing( export function getModelPricing(
providerType: AiProviderType, providerType: AiProviderType,
@@ -158,20 +144,18 @@ export function getModelPricing(
return null; return null;
} }
const byId = loadModels(); const byName = loadModels();
const buckets = const catalogProvider =
providerType === "custom" providerType === "custom" ? null : PROVIDER_CATALOG_MAP[providerType];
? []
: PROVIDER_PRICING_BUCKETS[providerType];
if (buckets && buckets.length > 0) { if (catalogProvider) {
const scoped = findInBuckets(byId, modelId, buckets); const scoped = findEntry(byName, modelId, catalogProvider);
if (scoped) { if (scoped) {
return toPricing(scoped, false); return toPricing(scoped, false);
} }
} }
const fallback = findInBuckets(byId, modelId, null); const fallback = findEntry(byName, modelId, null);
if (fallback) { if (fallback) {
return toPricing(fallback, true); return toPricing(fallback, true);
} }
@@ -191,9 +175,9 @@ export type AiCostBreakdown = {
/** /**
* Computes a $ cost breakdown for a usage record given a model's pricing. * Computes a $ cost breakdown for a usage record given a model's pricing.
* Cache writes and reasoning tokens fall back to the normal input/output * Cache writes and reasoning tokens fall back to the normal input/output
* rate respectively when the pricing data has no dedicated rate for them * rate respectively when the catalog has no dedicated rate for them (the
* (the models.json schema here has no cache-write field at all, and only * catalog has no cache-write field at all, and only some models report a
* some models report a distinct reasoning rate). * distinct reasoning rate).
*/ */
export function calculateAiCost( export function calculateAiCost(
pricing: AiModelPricing | null, pricing: AiModelPricing | null,