mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): keep prompt-length pricing tiers from all model catalogs
models.dev loaders for opencode, opencode-go, google, minimax, and others built costs by hand and dropped tiers; they now use getModelsDevCost. OpenRouter prompt-length overrides and Vercel AI Gateway per-rate tiers are parsed into request-wide tiers. Rates a tier does not list keep the base price. OpenRouter time-of-day overrides are skipped.
This commit is contained in:
parent
f76c1db662
commit
943a10e744
5 changed files with 257 additions and 149 deletions
|
|
@ -26,6 +26,7 @@
|
|||
- Fixed Radius models disabled by an organization owner still being listed: a fetched or cached Radius catalog now replaces the shipped default catalog instead of being merged into it
|
||||
- Fixed OpenAI provider type-checking with cached catalogs that contain no classifier models
|
||||
- Fixed Anthropic browser login failing with "localhost refused to connect" when port 53692 is reserved or in use, for example by Hyper-V/WSL port exclusions on Windows: login now falls back to a free loopback port ([#10571](https://github.com/earendil-works/pi/issues/10571))
|
||||
- Fixed built-in model costs missing prompt-length pricing tiers for OpenCode, OpenCode Go, OpenRouter, Vercel AI Gateway, Google, MiniMax, and other models.dev providers, which undercounted the cost of long prompts on models such as Claude Haiku 5.5, Gemini 3.1 Pro, and GPT-5.4. OpenRouter time-of-day pricing is not modeled
|
||||
- Fixed Mistral responses that end with `finish_reason: "error"` not being retried ([#10487](https://github.com/earendil-works/pi/issues/10487))
|
||||
|
||||
## [1.0.4] - 2026-10-05
|
||||
|
|
|
|||
67
packages/ai/scripts/ai-gateway-pricing.ts
Normal file
67
packages/ai/scripts/ai-gateway-pricing.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type { ModelCost, ModelCostRates, ModelCostTier } from "../src/types.ts";
|
||||
|
||||
/** A prompt-length bracket. `min` is inclusive and `max` exclusive, both in prompt tokens. */
|
||||
export interface AiGatewayPriceTier {
|
||||
cost?: string | number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Vercel AI Gateway pricing in $/token. */
|
||||
export interface AiGatewayPricing {
|
||||
input?: string | number;
|
||||
output?: string | number;
|
||||
input_cache_read?: string | number;
|
||||
input_cache_write?: string | number;
|
||||
input_tiers?: AiGatewayPriceTier[];
|
||||
output_tiers?: AiGatewayPriceTier[];
|
||||
input_cache_read_tiers?: AiGatewayPriceTier[];
|
||||
input_cache_write_tiers?: AiGatewayPriceTier[];
|
||||
}
|
||||
|
||||
const RATE_FIELDS = [
|
||||
{ rate: "input", tiers: "input_tiers" },
|
||||
{ rate: "output", tiers: "output_tiers" },
|
||||
{ rate: "cacheRead", tiers: "input_cache_read_tiers" },
|
||||
{ rate: "cacheWrite", tiers: "input_cache_write_tiers" },
|
||||
] as const;
|
||||
|
||||
function perMillion(value: string | number | undefined): number {
|
||||
const parsed = typeof value === "number" ? value : parseFloat(value ?? "0");
|
||||
return Number.isFinite(parsed) ? Number((parsed * 1_000_000).toFixed(6)) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert AI Gateway pricing to $/million tokens. Each rate lists its own prompt-length brackets;
|
||||
* every bracket start above zero becomes a request-wide tier with the rates in effect there.
|
||||
*/
|
||||
export function getAiGatewayCost(pricing: AiGatewayPricing | undefined): ModelCost {
|
||||
const base: ModelCostRates = {
|
||||
input: perMillion(pricing?.input),
|
||||
output: perMillion(pricing?.output),
|
||||
cacheRead: perMillion(pricing?.input_cache_read),
|
||||
cacheWrite: perMillion(pricing?.input_cache_write),
|
||||
};
|
||||
|
||||
const starts = new Set<number>();
|
||||
for (const field of RATE_FIELDS) {
|
||||
for (const bracket of pricing?.[field.tiers] ?? []) {
|
||||
if (bracket.min !== undefined && bracket.min > 0) starts.add(bracket.min);
|
||||
}
|
||||
}
|
||||
|
||||
const tiers = [...starts]
|
||||
.sort((a, b) => a - b)
|
||||
.map((start): ModelCostTier => {
|
||||
const tier: ModelCostTier = { inputTokensAbove: start - 1, ...base };
|
||||
for (const field of RATE_FIELDS) {
|
||||
const bracket = pricing?.[field.tiers]?.find(
|
||||
(candidate) => (candidate.min ?? 0) <= start && (candidate.max === undefined || start < candidate.max),
|
||||
);
|
||||
if (bracket?.cost !== undefined) tier[field.rate] = perMillion(bracket.cost);
|
||||
}
|
||||
return tier;
|
||||
});
|
||||
|
||||
return tiers.length > 0 ? { ...base, tiers } : base;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSy
|
|||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { getEffortThinkingLevelMap, type ModelsDevReasoningOption } from "./models-dev-reasoning-options.ts";
|
||||
import { type AiGatewayPricing, getAiGatewayCost } from "./ai-gateway-pricing.ts";
|
||||
import { buildOpenRouterCatalog, type OpenRouterCatalog, type OpenRouterModelListItem } from "./openrouter-catalog.ts";
|
||||
import {
|
||||
CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL,
|
||||
|
|
@ -156,12 +157,7 @@ interface AiGatewayModel {
|
|||
context_window?: number;
|
||||
max_tokens?: number;
|
||||
tags?: string[];
|
||||
pricing?: {
|
||||
input?: string | number;
|
||||
output?: string | number;
|
||||
input_cache_read?: string | number;
|
||||
input_cache_write?: string | number;
|
||||
};
|
||||
pricing?: AiGatewayPricing;
|
||||
}
|
||||
|
||||
const COPILOT_STATIC_HEADERS = {
|
||||
|
|
@ -1269,27 +1265,28 @@ function roundCost(value: number): number {
|
|||
}
|
||||
|
||||
function getModelsDevCost(cost: ModelsDevModel["cost"]): ModelCost {
|
||||
const base = {
|
||||
input: cost?.input || 0,
|
||||
output: cost?.output || 0,
|
||||
cacheRead: cost?.cache_read || 0,
|
||||
cacheWrite: cost?.cache_write || 0,
|
||||
};
|
||||
// Rates a tier does not list keep the base price.
|
||||
const tiers = cost?.tiers?.flatMap((tier) => {
|
||||
const context = tier.tier;
|
||||
if (context?.type !== "context" || context.size === undefined) return [];
|
||||
return [
|
||||
{
|
||||
inputTokensAbove: context.size,
|
||||
input: tier.input || 0,
|
||||
output: tier.output || 0,
|
||||
cacheRead: tier.cache_read || 0,
|
||||
cacheWrite: tier.cache_write || 0,
|
||||
input: tier.input ?? base.input,
|
||||
output: tier.output ?? base.output,
|
||||
cacheRead: tier.cache_read ?? base.cacheRead,
|
||||
cacheWrite: tier.cache_write ?? base.cacheWrite,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
input: cost?.input || 0,
|
||||
output: cost?.output || 0,
|
||||
cacheRead: cost?.cache_read || 0,
|
||||
cacheWrite: cost?.cache_write || 0,
|
||||
...(tiers && tiers.length > 0 ? { tiers } : {}),
|
||||
};
|
||||
return tiers && tiers.length > 0 ? { ...base, tiers } : base;
|
||||
}
|
||||
|
||||
async function fetchNvidiaNimModelIds(): Promise<Map<string, string>> {
|
||||
|
|
@ -1411,11 +1408,6 @@ async function fetchAiGatewayModels(): Promise<{
|
|||
input.push("image");
|
||||
}
|
||||
|
||||
const inputCost = roundCost(toNumber(model.pricing?.input) * 1_000_000);
|
||||
const outputCost = roundCost(toNumber(model.pricing?.output) * 1_000_000);
|
||||
const cacheReadCost = roundCost(toNumber(model.pricing?.input_cache_read) * 1_000_000);
|
||||
const cacheWriteCost = roundCost(toNumber(model.pricing?.input_cache_write) * 1_000_000);
|
||||
|
||||
models.push({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
|
|
@ -1425,12 +1417,7 @@ async function fetchAiGatewayModels(): Promise<{
|
|||
reasoning: tags.includes("reasoning"),
|
||||
input,
|
||||
compat: { allowEmptySignature: true },
|
||||
cost: {
|
||||
input: inputCost,
|
||||
output: outputCost,
|
||||
cacheRead: cacheReadCost,
|
||||
cacheWrite: cacheWriteCost,
|
||||
},
|
||||
cost: getAiGatewayCost(model.pricing),
|
||||
contextWindow: model.context_window || 4096,
|
||||
maxTokens: model.max_tokens || 4096,
|
||||
});
|
||||
|
|
@ -1485,12 +1472,7 @@ function processZaiModels(data: ModelsDevCatalog): Model<Api>[] {
|
|||
reasoning: m.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: supportsImage ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: referenceCost?.input || 0,
|
||||
output: referenceCost?.output || 0,
|
||||
cacheRead: referenceCost?.cache_read || 0,
|
||||
cacheWrite: referenceCost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(referenceCost),
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
thinkingFormat: "zai",
|
||||
|
|
@ -1592,12 +1574,7 @@ function processBasetenModels(provider: ModelsDevProvider | undefined): Model<Ap
|
|||
reasoning,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: supportsImageInput ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: model.cost?.input || 0,
|
||||
output: model.cost?.output || 0,
|
||||
cacheRead: model.cost?.cache_read || 0,
|
||||
cacheWrite: model.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(model.cost),
|
||||
compat,
|
||||
contextWindow: model.limit?.context || 4096,
|
||||
maxTokens: model.limit?.output || 4096,
|
||||
|
|
@ -1630,12 +1607,7 @@ function processGoogleModels(data: ModelsDevCatalog): Model<Api>[] {
|
|||
reasoning: source.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: source.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: source.cost?.input || 0,
|
||||
output: source.cost?.output || 0,
|
||||
cacheRead: source.cost?.cache_read || 0,
|
||||
cacheWrite: source.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(source.cost),
|
||||
contextWindow: source.limit?.context || 4096,
|
||||
maxTokens: source.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -1720,12 +1692,7 @@ function processFireworksModels(provider: ModelsDevProvider | undefined): Model<
|
|||
provider: "fireworks",
|
||||
reasoning: model.reasoning === true,
|
||||
input,
|
||||
cost: {
|
||||
input: model.cost?.input || 0,
|
||||
output: model.cost?.output || 0,
|
||||
cacheRead: model.cost?.cache_read || 0,
|
||||
cacheWrite: model.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(model.cost),
|
||||
contextWindow: model.limit?.context || 4096,
|
||||
maxTokens: model.limit?.output || 4096,
|
||||
};
|
||||
|
|
@ -1832,12 +1799,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -1863,12 +1825,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -1890,12 +1847,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://api.groq.com/openai/v1",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -1917,12 +1869,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -1944,12 +1891,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: CLOUDFLARE_WORKERS_AI_BASE_URL,
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
compat: { sendSessionAffinityHeaders: true },
|
||||
|
|
@ -2006,12 +1948,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl,
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
...(compat ? { compat } : {}),
|
||||
|
|
@ -2041,12 +1978,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL,
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
compat: { sendSessionAffinityHeaders: true },
|
||||
|
|
@ -2092,12 +2024,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://api.meta.ai/v1",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -2152,12 +2079,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl: "https://router.huggingface.co/v1",
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
compat: {
|
||||
supportsDeveloperRole: false,
|
||||
},
|
||||
|
|
@ -2191,12 +2113,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
headers: { ...NVIDIA_HEADERS },
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities.input.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
compat: NVIDIA_OPENAI_COMPAT,
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
|
|
@ -2224,12 +2141,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
reasoning,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
compat: getTogetherCompat(modelId, reasoning),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
|
|
@ -2341,12 +2253,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
reasoning: m.reasoning === true,
|
||||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
...(compat ? { compat } : {}),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
|
|
@ -2430,12 +2337,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
baseUrl,
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -2590,12 +2492,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
compat: xiaomiCompat,
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
@ -2662,12 +2559,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
|
||||
reasoning: m.reasoning === true,
|
||||
input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"],
|
||||
cost: {
|
||||
input: m.cost?.input || 0,
|
||||
output: m.cost?.output || 0,
|
||||
cacheRead: m.cost?.cache_read || 0,
|
||||
cacheWrite: m.cost?.cache_write || 0,
|
||||
},
|
||||
cost: getModelsDevCost(m.cost),
|
||||
contextWindow: m.limit?.context || 4096,
|
||||
maxTokens: m.limit?.output || 4096,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ClassifierModel, ImageModel, Model, ModelCost } from "../src/types.ts";
|
||||
import type { ClassifierModel, ImageModel, Model, ModelCost, ModelCostTier } from "../src/types.ts";
|
||||
import { getOpenRouterThinkingLevelMap, type OpenRouterReasoningMetadata } from "./openrouter-reasoning-options.ts";
|
||||
|
||||
export interface OpenRouterModelListItem {
|
||||
|
|
@ -11,6 +11,7 @@ export interface OpenRouterModelListItem {
|
|||
completion?: string;
|
||||
input_cache_read?: string;
|
||||
input_cache_write?: string;
|
||||
overrides?: OpenRouterPricingOverride[];
|
||||
};
|
||||
top_provider?: {
|
||||
context_length?: number;
|
||||
|
|
@ -20,6 +21,21 @@ export interface OpenRouterModelListItem {
|
|||
reasoning?: OpenRouterReasoningMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A conditional price. `min_prompt_tokens` selects prompt-length pricing; `utc_*` fields select
|
||||
* time-of-day or weekday pricing. Missing rates keep the base price.
|
||||
*/
|
||||
export interface OpenRouterPricingOverride {
|
||||
min_prompt_tokens?: number;
|
||||
utc_start?: number;
|
||||
utc_end?: number;
|
||||
utc_days?: string[];
|
||||
prompt?: string;
|
||||
completion?: string;
|
||||
input_cache_read?: string;
|
||||
input_cache_write?: string;
|
||||
}
|
||||
|
||||
export interface OpenRouterCatalog {
|
||||
chat: Model<"anthropic-messages" | "openai-completions">[];
|
||||
images: ImageModel<"openrouter-images">[];
|
||||
|
|
@ -36,14 +52,41 @@ function modalities(values: string[] | undefined): ("text" | "image")[] {
|
|||
);
|
||||
}
|
||||
|
||||
// Convert pricing from $/token to $/million tokens
|
||||
function perMillion(value: string | undefined, fallback: number): number {
|
||||
return value ? roundCost(parseFloat(value) * 1_000_000) : fallback;
|
||||
}
|
||||
|
||||
function cost(model: OpenRouterModelListItem): ModelCost {
|
||||
// Convert pricing from $/token to $/million tokens
|
||||
return {
|
||||
input: roundCost(parseFloat(model.pricing?.prompt || "0") * 1_000_000),
|
||||
output: roundCost(parseFloat(model.pricing?.completion || "0") * 1_000_000),
|
||||
cacheRead: roundCost(parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000),
|
||||
cacheWrite: roundCost(parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000),
|
||||
const pricing = model.pricing;
|
||||
const base = {
|
||||
input: perMillion(pricing?.prompt, 0),
|
||||
output: perMillion(pricing?.completion, 0),
|
||||
cacheRead: perMillion(pricing?.input_cache_read, 0),
|
||||
cacheWrite: perMillion(pricing?.input_cache_write, 0),
|
||||
};
|
||||
// Prompt-length overrides become request-wide tiers. Time-of-day overrides are skipped
|
||||
// because ModelCost cannot express them.
|
||||
const tiers = (pricing?.overrides ?? []).flatMap((override): ModelCostTier[] => {
|
||||
if (
|
||||
override.min_prompt_tokens === undefined ||
|
||||
override.utc_start !== undefined ||
|
||||
override.utc_end !== undefined ||
|
||||
override.utc_days !== undefined
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
inputTokensAbove: override.min_prompt_tokens,
|
||||
input: perMillion(override.prompt, base.input),
|
||||
output: perMillion(override.completion, base.output),
|
||||
cacheRead: perMillion(override.input_cache_read, base.cacheRead),
|
||||
cacheWrite: perMillion(override.input_cache_write, base.cacheWrite),
|
||||
},
|
||||
];
|
||||
});
|
||||
return tiers.length > 0 ? { ...base, tiers } : base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
105
packages/ai/test/model-cost-tiers.test.ts
Normal file
105
packages/ai/test/model-cost-tiers.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { getAiGatewayCost } from "../scripts/ai-gateway-pricing.ts";
|
||||
import { buildOpenRouterCatalog, type OpenRouterModelListItem } from "../scripts/openrouter-catalog.ts";
|
||||
|
||||
function openRouterChatCost(pricing: OpenRouterModelListItem["pricing"]) {
|
||||
const catalog = buildOpenRouterCatalog(
|
||||
[{ id: "anthropic/claude-haiku-5.5", name: "Claude Haiku 5.5", supported_parameters: ["tools"], pricing }],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
return catalog.chat[0]?.cost;
|
||||
}
|
||||
|
||||
describe("OpenRouter pricing overrides", () => {
|
||||
it("turns prompt-length overrides into tiers", () => {
|
||||
expect(
|
||||
openRouterChatCost({
|
||||
prompt: "0.0000001",
|
||||
completion: "0.0000005",
|
||||
input_cache_read: "0.00000001",
|
||||
input_cache_write: "0.000000125",
|
||||
overrides: [
|
||||
{
|
||||
min_prompt_tokens: 100000,
|
||||
prompt: "0.0000005",
|
||||
completion: "0.0000025",
|
||||
input_cache_read: "0.00000005",
|
||||
input_cache_write: "0.000000625",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
input: 0.1,
|
||||
output: 0.5,
|
||||
cacheRead: 0.01,
|
||||
cacheWrite: 0.125,
|
||||
tiers: [{ inputTokensAbove: 100000, input: 0.5, output: 2.5, cacheRead: 0.05, cacheWrite: 0.625 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps base rates missing from an override", () => {
|
||||
expect(
|
||||
openRouterChatCost({
|
||||
prompt: "0.000002",
|
||||
completion: "0.000012",
|
||||
input_cache_read: "0.0000002",
|
||||
input_cache_write: "0.000000375",
|
||||
overrides: [{ min_prompt_tokens: 200000, prompt: "0.000004", completion: "0.000018" }],
|
||||
})?.tiers,
|
||||
).toEqual([{ inputTokensAbove: 200000, input: 4, output: 18, cacheRead: 0.2, cacheWrite: 0.375 }]);
|
||||
});
|
||||
|
||||
it("skips time-of-day overrides", () => {
|
||||
expect(
|
||||
openRouterChatCost({
|
||||
prompt: "0.000000132",
|
||||
completion: "0.000000528",
|
||||
overrides: [
|
||||
{ utc_start: 0, utc_end: 1600, prompt: "0.000000132", completion: "0.000000528" },
|
||||
{ utc_days: ["saturday"], min_prompt_tokens: 1000, prompt: "0.0000001" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ input: 0.132, output: 0.528, cacheRead: 0, cacheWrite: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Vercel AI Gateway pricing tiers", () => {
|
||||
it("turns bracket starts into tiers with the rates in effect there", () => {
|
||||
expect(
|
||||
getAiGatewayCost({
|
||||
input: "0.000001",
|
||||
output: "0.000005",
|
||||
input_cache_read: "0.0000002",
|
||||
input_tiers: [
|
||||
{ cost: "0.000001", min: 0, max: 32001 },
|
||||
{ cost: "0.0000018", min: 32001, max: 128001 },
|
||||
{ cost: "0.000003", min: 128001 },
|
||||
],
|
||||
output_tiers: [
|
||||
{ cost: "0.000005", min: 0, max: 32001 },
|
||||
{ cost: "0.000009", min: 32001, max: 128001 },
|
||||
{ cost: "0.000015", min: 128001 },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
input: 1,
|
||||
output: 5,
|
||||
cacheRead: 0.2,
|
||||
cacheWrite: 0,
|
||||
tiers: [
|
||||
{ inputTokensAbove: 32000, input: 1.8, output: 9, cacheRead: 0.2, cacheWrite: 0 },
|
||||
{ inputTokensAbove: 128000, input: 3, output: 15, cacheRead: 0.2, cacheWrite: 0 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns base rates without tiers", () => {
|
||||
expect(getAiGatewayCost({ input: "0.000003", output: 0.000015 })).toEqual({
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue