mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-11 15:08:36 +02:00
Update structure
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user