Gateway: cache OpenRouter pricing for configured models
This commit is contained in:
parent
de22f822e0
commit
61c9cc812c
188
src/gateway/model-pricing-cache.test.ts
Normal file
188
src/gateway/model-pricing-cache.test.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { modelKey } from "../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
__resetGatewayModelPricingCacheForTest,
|
||||
collectConfiguredModelPricingRefs,
|
||||
getCachedGatewayModelPricing,
|
||||
refreshGatewayModelPricingCache,
|
||||
} from "./model-pricing-cache.js";
|
||||
|
||||
describe("model-pricing-cache", () => {
|
||||
beforeEach(() => {
|
||||
__resetGatewayModelPricingCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetGatewayModelPricingCacheForTest();
|
||||
});
|
||||
|
||||
it("collects configured model refs across defaults, aliases, overrides, and media tools", () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "gpt", fallbacks: ["anthropic/claude-sonnet-4-6"] },
|
||||
imageModel: { primary: "google/gemini-3-pro" },
|
||||
compaction: { model: "opus" },
|
||||
heartbeat: { model: "xai/grok-4" },
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "gpt" },
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "router",
|
||||
model: { primary: "openrouter/anthropic/claude-opus-4-6" },
|
||||
subagents: { model: { primary: "openrouter/auto" } },
|
||||
heartbeat: { model: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: {
|
||||
modelByChannel: {
|
||||
slack: {
|
||||
C123: "gpt",
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
gmail: { model: "anthropic/claude-opus-4-6" },
|
||||
mappings: [{ model: "zai/glm-5" }],
|
||||
},
|
||||
tools: {
|
||||
subagents: { model: { primary: "anthropic/claude-haiku-4-5" } },
|
||||
media: {
|
||||
models: [{ provider: "google", model: "gemini-2.5-pro" }],
|
||||
image: {
|
||||
models: [{ provider: "xai", model: "grok-4" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
tts: {
|
||||
summaryModel: "openai/gpt-5.4",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const refs = collectConfiguredModelPricingRefs(config).map((ref) =>
|
||||
modelKey(ref.provider, ref.model),
|
||||
);
|
||||
|
||||
expect(refs).toEqual(
|
||||
expect.arrayContaining([
|
||||
"openai/gpt-5.4",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"google/gemini-3-pro-preview",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"xai/grok-4",
|
||||
"openrouter/anthropic/claude-opus-4-6",
|
||||
"openrouter/auto",
|
||||
"zai/glm-5",
|
||||
"anthropic/claude-haiku-4-5",
|
||||
"google/gemini-2.5-pro",
|
||||
]),
|
||||
);
|
||||
expect(new Set(refs).size).toBe(refs.length);
|
||||
});
|
||||
|
||||
it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "router",
|
||||
model: { primary: "openrouter/anthropic/claude-sonnet-4-6" },
|
||||
},
|
||||
],
|
||||
},
|
||||
hooks: {
|
||||
mappings: [{ model: "xai/grok-4" }],
|
||||
},
|
||||
tools: {
|
||||
subagents: { model: { primary: "zai/glm-5" } },
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "anthropic/claude-opus-4.6",
|
||||
pricing: {
|
||||
prompt: "0.000005",
|
||||
completion: "0.000025",
|
||||
input_cache_read: "0.0000005",
|
||||
input_cache_write: "0.00000625",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-sonnet-4.6",
|
||||
pricing: {
|
||||
prompt: "0.000003",
|
||||
completion: "0.000015",
|
||||
input_cache_read: "0.0000003",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "x-ai/grok-4",
|
||||
pricing: {
|
||||
prompt: "0.000002",
|
||||
completion: "0.00001",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "z-ai/glm-5",
|
||||
pricing: {
|
||||
prompt: "0.000001",
|
||||
completion: "0.000004",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
|
||||
await refreshGatewayModelPricingCache({ config, fetchImpl });
|
||||
|
||||
expect(
|
||||
getCachedGatewayModelPricing({ provider: "anthropic", model: "claude-opus-4-6" }),
|
||||
).toEqual({
|
||||
input: 5,
|
||||
output: 25,
|
||||
cacheRead: 0.5,
|
||||
cacheWrite: 6.25,
|
||||
});
|
||||
expect(
|
||||
getCachedGatewayModelPricing({
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
}),
|
||||
).toEqual({
|
||||
input: 3,
|
||||
output: 15,
|
||||
cacheRead: 0.3,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4" })).toEqual({
|
||||
input: 2,
|
||||
output: 10,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
expect(getCachedGatewayModelPricing({ provider: "zai", model: "glm-5" })).toEqual({
|
||||
input: 1,
|
||||
output: 4,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
469
src/gateway/model-pricing-cache.ts
Normal file
469
src/gateway/model-pricing-cache.ts
Normal file
@ -0,0 +1,469 @@
|
||||
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
normalizeModelRef,
|
||||
parseModelRef,
|
||||
resolveModelRefFromString,
|
||||
type ModelRef,
|
||||
} from "../agents/model-selection.js";
|
||||
import { normalizeGoogleModelId } from "../agents/models-config.providers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
|
||||
export type CachedModelPricing = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
|
||||
type OpenRouterPricingEntry = {
|
||||
id: string;
|
||||
pricing: CachedModelPricing;
|
||||
};
|
||||
|
||||
type ModelListLike = string | { primary?: string; fallbacks?: string[] } | undefined;
|
||||
|
||||
type OpenRouterModelPayload = {
|
||||
id?: unknown;
|
||||
pricing?: unknown;
|
||||
};
|
||||
|
||||
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
||||
const CACHE_TTL_MS = 24 * 60 * 60_000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const PROVIDER_ALIAS_TO_OPENROUTER: Record<string, string> = {
|
||||
"google-gemini-cli": "google",
|
||||
kimi: "moonshotai",
|
||||
"kimi-coding": "moonshotai",
|
||||
moonshot: "moonshotai",
|
||||
moonshotai: "moonshotai",
|
||||
"openai-codex": "openai",
|
||||
qwen: "qwen",
|
||||
"qwen-portal": "qwen",
|
||||
xai: "x-ai",
|
||||
zai: "z-ai",
|
||||
};
|
||||
const WRAPPER_PROVIDERS = new Set([
|
||||
"cloudflare-ai-gateway",
|
||||
"kilocode",
|
||||
"openrouter",
|
||||
"vercel-ai-gateway",
|
||||
]);
|
||||
|
||||
const log = createSubsystemLogger("gateway").child("model-pricing");
|
||||
|
||||
let cachedPricing = new Map<string, CachedModelPricing>();
|
||||
let cachedAt = 0;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let inFlightRefresh: Promise<void> | null = null;
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (!refreshTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
|
||||
function listLikePrimary(value: ModelListLike): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
const trimmed = value?.primary?.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function listLikeFallbacks(value: ModelListLike): string[] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value.fallbacks)
|
||||
? value.fallbacks
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseNumberString(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toPricePerMillion(value: number | null): number {
|
||||
if (value === null || value < 0 || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return value * 1_000_000;
|
||||
}
|
||||
|
||||
function parseOpenRouterPricing(value: unknown): CachedModelPricing | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const pricing = value as Record<string, unknown>;
|
||||
const prompt = parseNumberString(pricing.prompt);
|
||||
const completion = parseNumberString(pricing.completion);
|
||||
if (prompt === null || completion === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
input: toPricePerMillion(prompt),
|
||||
output: toPricePerMillion(completion),
|
||||
cacheRead: toPricePerMillion(parseNumberString(pricing.input_cache_read)),
|
||||
cacheWrite: toPricePerMillion(parseNumberString(pricing.input_cache_write)),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeOpenRouterProvider(provider: string): string {
|
||||
const normalized = normalizeModelRef(provider, "placeholder").provider;
|
||||
return PROVIDER_ALIAS_TO_OPENROUTER[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function canonicalizeOpenRouterLookupId(id: string): string {
|
||||
const trimmed = id.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash === -1) {
|
||||
return trimmed;
|
||||
}
|
||||
const provider = canonicalizeOpenRouterProvider(trimmed.slice(0, slash));
|
||||
let model = trimmed.slice(slash + 1).trim();
|
||||
if (!model) {
|
||||
return provider;
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
model = model
|
||||
.replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-")
|
||||
.replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3");
|
||||
}
|
||||
if (provider === "google") {
|
||||
model = normalizeGoogleModelId(model);
|
||||
}
|
||||
return `${provider}/${model}`;
|
||||
}
|
||||
|
||||
function buildOpenRouterExactCandidates(ref: ModelRef): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const canonicalProvider = canonicalizeOpenRouterProvider(ref.provider);
|
||||
const canonicalFullId = canonicalizeOpenRouterLookupId(modelKey(canonicalProvider, ref.model));
|
||||
if (canonicalFullId) {
|
||||
candidates.add(canonicalFullId);
|
||||
}
|
||||
|
||||
if (canonicalProvider === "anthropic") {
|
||||
const slash = canonicalFullId.indexOf("/");
|
||||
const model = slash === -1 ? canonicalFullId : canonicalFullId.slice(slash + 1);
|
||||
const dotted = model
|
||||
.replace(/^claude-(\d+)-(\d+)-/u, "claude-$1.$2-")
|
||||
.replace(/^claude-([a-z]+)-(\d+)-(\d+)$/u, "claude-$1-$2.$3");
|
||||
candidates.add(`${canonicalProvider}/${dotted}`);
|
||||
}
|
||||
|
||||
if (WRAPPER_PROVIDERS.has(ref.provider) && ref.model.includes("/")) {
|
||||
const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER);
|
||||
if (nestedRef) {
|
||||
for (const candidate of buildOpenRouterExactCandidates(nestedRef)) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(candidates).filter(Boolean);
|
||||
}
|
||||
|
||||
function addResolvedModelRef(params: {
|
||||
raw: string | undefined;
|
||||
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
|
||||
refs: Map<string, ModelRef>;
|
||||
}): void {
|
||||
const raw = params.raw?.trim();
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
aliasIndex: params.aliasIndex,
|
||||
});
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeModelRef(resolved.ref.provider, resolved.ref.model);
|
||||
params.refs.set(modelKey(normalized.provider, normalized.model), normalized);
|
||||
}
|
||||
|
||||
function addModelListLike(params: {
|
||||
value: ModelListLike;
|
||||
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
|
||||
refs: Map<string, ModelRef>;
|
||||
}): void {
|
||||
addResolvedModelRef({
|
||||
raw: listLikePrimary(params.value),
|
||||
aliasIndex: params.aliasIndex,
|
||||
refs: params.refs,
|
||||
});
|
||||
for (const fallback of listLikeFallbacks(params.value)) {
|
||||
addResolvedModelRef({
|
||||
raw: fallback,
|
||||
aliasIndex: params.aliasIndex,
|
||||
refs: params.refs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addProviderModelPair(params: {
|
||||
provider: string | undefined;
|
||||
model: string | undefined;
|
||||
refs: Map<string, ModelRef>;
|
||||
}): void {
|
||||
const provider = params.provider?.trim();
|
||||
const model = params.model?.trim();
|
||||
if (!provider || !model) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeModelRef(provider, model);
|
||||
params.refs.set(modelKey(normalized.provider, normalized.model), normalized);
|
||||
}
|
||||
|
||||
export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] {
|
||||
const refs = new Map<string, ModelRef>();
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: config,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
});
|
||||
|
||||
addModelListLike({ value: config.agents?.defaults?.model, aliasIndex, refs });
|
||||
addModelListLike({ value: config.agents?.defaults?.imageModel, aliasIndex, refs });
|
||||
addModelListLike({ value: config.agents?.defaults?.pdfModel, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.agents?.defaults?.compaction?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.agents?.defaults?.heartbeat?.model, aliasIndex, refs });
|
||||
addModelListLike({ value: config.tools?.subagents?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.messages?.tts?.summaryModel, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.hooks?.gmail?.model, aliasIndex, refs });
|
||||
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
addModelListLike({ value: agent.model, aliasIndex, refs });
|
||||
addModelListLike({ value: agent.subagents?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: agent.heartbeat?.model, aliasIndex, refs });
|
||||
}
|
||||
|
||||
for (const mapping of config.hooks?.mappings ?? []) {
|
||||
addResolvedModelRef({ raw: mapping.model, aliasIndex, refs });
|
||||
}
|
||||
|
||||
for (const channelMap of Object.values(config.channels?.modelByChannel ?? {})) {
|
||||
if (!channelMap || typeof channelMap !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const raw of Object.values(channelMap)) {
|
||||
addResolvedModelRef({
|
||||
raw: typeof raw === "string" ? raw : undefined,
|
||||
aliasIndex,
|
||||
refs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addResolvedModelRef({ raw: config.tools?.web?.search?.gemini?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.tools?.web?.search?.grok?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.tools?.web?.search?.kimi?.model, aliasIndex, refs });
|
||||
addResolvedModelRef({ raw: config.tools?.web?.search?.perplexity?.model, aliasIndex, refs });
|
||||
|
||||
for (const entry of config.tools?.media?.models ?? []) {
|
||||
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });
|
||||
}
|
||||
for (const entry of config.tools?.media?.image?.models ?? []) {
|
||||
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });
|
||||
}
|
||||
for (const entry of config.tools?.media?.audio?.models ?? []) {
|
||||
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });
|
||||
}
|
||||
for (const entry of config.tools?.media?.video?.models ?? []) {
|
||||
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });
|
||||
}
|
||||
|
||||
return Array.from(refs.values());
|
||||
}
|
||||
|
||||
async function fetchOpenRouterPricingCatalog(
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<Map<string, OpenRouterPricingEntry>> {
|
||||
const response = await fetchImpl(OPENROUTER_MODELS_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter /models failed: HTTP ${response.status}`);
|
||||
}
|
||||
const payload = (await response.json()) as { data?: unknown };
|
||||
const entries = Array.isArray(payload.data) ? payload.data : [];
|
||||
const catalog = new Map<string, OpenRouterPricingEntry>();
|
||||
for (const entry of entries) {
|
||||
const obj = entry as OpenRouterModelPayload;
|
||||
const id = typeof obj.id === "string" ? obj.id.trim() : "";
|
||||
const pricing = parseOpenRouterPricing(obj.pricing);
|
||||
if (!id || !pricing) {
|
||||
continue;
|
||||
}
|
||||
catalog.set(id, { id, pricing });
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
function resolveCatalogPricingForRef(params: {
|
||||
ref: ModelRef;
|
||||
catalogById: Map<string, OpenRouterPricingEntry>;
|
||||
catalogByNormalizedId: Map<string, OpenRouterPricingEntry>;
|
||||
}): CachedModelPricing | undefined {
|
||||
for (const candidate of buildOpenRouterExactCandidates(params.ref)) {
|
||||
const exact = params.catalogById.get(candidate);
|
||||
if (exact) {
|
||||
return exact.pricing;
|
||||
}
|
||||
}
|
||||
for (const candidate of buildOpenRouterExactCandidates(params.ref)) {
|
||||
const normalized = canonicalizeOpenRouterLookupId(candidate);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
const match = params.catalogByNormalizedId.get(normalized);
|
||||
if (match) {
|
||||
return match.pricing;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function scheduleRefresh(params: { config: OpenClawConfig; fetchImpl: typeof fetch }): void {
|
||||
clearRefreshTimer();
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
void refreshGatewayModelPricingCache(params).catch((error: unknown) => {
|
||||
log.warn(`pricing refresh failed: ${String(error)}`);
|
||||
});
|
||||
}, CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
export async function refreshGatewayModelPricingCache(params: {
|
||||
config: OpenClawConfig;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<void> {
|
||||
if (inFlightRefresh) {
|
||||
return await inFlightRefresh;
|
||||
}
|
||||
const fetchImpl = params.fetchImpl ?? fetch;
|
||||
inFlightRefresh = (async () => {
|
||||
const refs = collectConfiguredModelPricingRefs(params.config);
|
||||
if (refs.length === 0) {
|
||||
cachedPricing = new Map();
|
||||
cachedAt = Date.now();
|
||||
clearRefreshTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const catalogById = await fetchOpenRouterPricingCatalog(fetchImpl);
|
||||
const catalogByNormalizedId = new Map<string, OpenRouterPricingEntry>();
|
||||
for (const entry of catalogById.values()) {
|
||||
const normalizedId = canonicalizeOpenRouterLookupId(entry.id);
|
||||
if (!normalizedId || catalogByNormalizedId.has(normalizedId)) {
|
||||
continue;
|
||||
}
|
||||
catalogByNormalizedId.set(normalizedId, entry);
|
||||
}
|
||||
|
||||
const nextPricing = new Map<string, CachedModelPricing>();
|
||||
for (const ref of refs) {
|
||||
const pricing = resolveCatalogPricingForRef({
|
||||
ref,
|
||||
catalogById,
|
||||
catalogByNormalizedId,
|
||||
});
|
||||
if (!pricing) {
|
||||
continue;
|
||||
}
|
||||
nextPricing.set(modelKey(ref.provider, ref.model), pricing);
|
||||
}
|
||||
|
||||
cachedPricing = nextPricing;
|
||||
cachedAt = Date.now();
|
||||
scheduleRefresh({ config: params.config, fetchImpl });
|
||||
})();
|
||||
|
||||
try {
|
||||
await inFlightRefresh;
|
||||
} finally {
|
||||
inFlightRefresh = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function startGatewayModelPricingRefresh(params: {
|
||||
config: OpenClawConfig;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): () => void {
|
||||
void refreshGatewayModelPricingCache(params).catch((error: unknown) => {
|
||||
log.warn(`pricing bootstrap failed: ${String(error)}`);
|
||||
});
|
||||
return () => {
|
||||
clearRefreshTimer();
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedGatewayModelPricing(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}): CachedModelPricing | undefined {
|
||||
const provider = params.provider?.trim();
|
||||
const model = params.model?.trim();
|
||||
if (!provider || !model) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeModelRef(provider, model);
|
||||
return cachedPricing.get(modelKey(normalized.provider, normalized.model));
|
||||
}
|
||||
|
||||
export function getGatewayModelPricingCacheMeta(): {
|
||||
cachedAt: number;
|
||||
ttlMs: number;
|
||||
size: number;
|
||||
} {
|
||||
return {
|
||||
cachedAt,
|
||||
ttlMs: CACHE_TTL_MS,
|
||||
size: cachedPricing.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function __resetGatewayModelPricingCacheForTest(): void {
|
||||
cachedPricing = new Map();
|
||||
cachedAt = 0;
|
||||
clearRefreshTimer();
|
||||
inFlightRefresh = null;
|
||||
}
|
||||
|
||||
export function __setGatewayModelPricingForTest(
|
||||
entries: Array<{ provider: string; model: string; pricing: CachedModelPricing }>,
|
||||
): void {
|
||||
cachedPricing = new Map(
|
||||
entries.map((entry) => {
|
||||
const normalized = normalizeModelRef(entry.provider, entry.model);
|
||||
return [modelKey(normalized.provider, normalized.model), entry.pricing] as const;
|
||||
}),
|
||||
);
|
||||
cachedAt = Date.now();
|
||||
}
|
||||
@ -74,6 +74,7 @@ import {
|
||||
type GatewayUpdateAvailableEventPayload,
|
||||
} from "./events.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
|
||||
import { NodeRegistry } from "./node-registry.js";
|
||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||
import { createChannelManager } from "./server-channels.js";
|
||||
@ -856,6 +857,11 @@ export async function startGatewayServer(
|
||||
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||
}
|
||||
|
||||
const stopModelPricingRefresh =
|
||||
!minimalTestGateway && process.env.VITEST !== "1"
|
||||
? startGatewayModelPricingRefresh({ config: cfgAtStart })
|
||||
: () => {};
|
||||
|
||||
// Recover pending outbound deliveries from previous crash/restart.
|
||||
if (!minimalTestGateway) {
|
||||
void (async () => {
|
||||
@ -1160,6 +1166,7 @@ export async function startGatewayServer(
|
||||
skillsChangeUnsub();
|
||||
authRateLimiter?.dispose();
|
||||
browserAuthRateLimiter.dispose();
|
||||
stopModelPricingRefresh();
|
||||
channelHealthMonitor?.stop();
|
||||
clearSecretsRuntimeSnapshot();
|
||||
await close(opts);
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
__resetGatewayModelPricingCacheForTest,
|
||||
__setGatewayModelPricingForTest,
|
||||
} from "../gateway/model-pricing-cache.js";
|
||||
import {
|
||||
__resetUsageFormatCachesForTest,
|
||||
estimateUsageCost,
|
||||
formatTokenCount,
|
||||
formatUsd,
|
||||
@ -8,6 +16,27 @@ import {
|
||||
} from "./usage-format.js";
|
||||
|
||||
describe("usage-format", () => {
|
||||
const originalAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
let agentDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-format-"));
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
__resetUsageFormatCachesForTest();
|
||||
__resetGatewayModelPricingCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = originalAgentDir;
|
||||
}
|
||||
__resetUsageFormatCachesForTest();
|
||||
__resetGatewayModelPricingCacheForTest();
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("formats token counts", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(1234)).toBe("1.2k");
|
||||
@ -76,7 +105,66 @@ describe("usage-format", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses configured pricing when present", () => {
|
||||
it("prefers models.json pricing over openclaw config and cached pricing", async () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
cost: { input: 20, output: 21, cacheRead: 22, cacheWrite: 23 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "models.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
providers: {
|
||||
openai: {
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
cost: { input: 10, output: 11, cacheRead: 12, cacheWrite: 13 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
__setGatewayModelPricingForTest([
|
||||
{
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
pricing: { input: 30, output: 31, cacheRead: 32, cacheWrite: 33 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveModelCostConfig({
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
config,
|
||||
}),
|
||||
).toEqual({
|
||||
input: 10,
|
||||
output: 11,
|
||||
cacheRead: 12,
|
||||
cacheWrite: 13,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to openclaw config pricing when models.json is absent", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
@ -92,6 +180,14 @@ describe("usage-format", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
__setGatewayModelPricingForTest([
|
||||
{
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-6",
|
||||
pricing: { input: 3, output: 4, cacheRead: 0.3, cacheWrite: 0.4 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveModelCostConfig({
|
||||
provider: "anthropic",
|
||||
@ -105,4 +201,26 @@ describe("usage-format", () => {
|
||||
cacheWrite: 1.9,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to cached gateway pricing when no configured cost exists", () => {
|
||||
__setGatewayModelPricingForTest([
|
||||
{
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
pricing: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveModelCostConfig({
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
}),
|
||||
).toEqual({
|
||||
input: 2.5,
|
||||
output: 15,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
|
||||
import { modelKey, normalizeModelRef, normalizeProviderId } from "../agents/model-selection.js";
|
||||
import type { NormalizedUsage } from "../agents/usage.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelProviderConfig } from "../config/types.models.js";
|
||||
import { getCachedGatewayModelPricing } from "../gateway/model-pricing-cache.js";
|
||||
|
||||
export type ModelCostConfig = {
|
||||
input: number;
|
||||
@ -16,6 +22,14 @@ export type UsageTotals = {
|
||||
total?: number;
|
||||
};
|
||||
|
||||
type ModelsJsonCostCache = {
|
||||
path: string;
|
||||
mtimeMs: number;
|
||||
entries: Map<string, ModelCostConfig>;
|
||||
};
|
||||
|
||||
let modelsJsonCostCache: ModelsJsonCostCache | null = null;
|
||||
|
||||
export function formatTokenCount(value?: number): string {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return "0";
|
||||
@ -48,19 +62,99 @@ export function formatUsd(value?: number): string | undefined {
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function toResolvedModelKey(params: { provider?: string; model?: string }): string | null {
|
||||
const provider = params.provider?.trim();
|
||||
const model = params.model?.trim();
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeModelRef(provider, model);
|
||||
return modelKey(normalized.provider, normalized.model);
|
||||
}
|
||||
|
||||
function buildProviderCostIndex(
|
||||
providers: Record<string, ModelProviderConfig> | undefined,
|
||||
): Map<string, ModelCostConfig> {
|
||||
const entries = new Map<string, ModelCostConfig>();
|
||||
if (!providers) {
|
||||
return entries;
|
||||
}
|
||||
for (const [providerKey, providerConfig] of Object.entries(providers)) {
|
||||
const normalizedProvider = normalizeProviderId(providerKey);
|
||||
for (const model of providerConfig?.models ?? []) {
|
||||
const normalized = normalizeModelRef(normalizedProvider, model.id);
|
||||
entries.set(modelKey(normalized.provider, normalized.model), model.cost);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function loadModelsJsonCostIndex(): Map<string, ModelCostConfig> {
|
||||
const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json");
|
||||
try {
|
||||
const stat = fs.statSync(modelsPath);
|
||||
if (
|
||||
modelsJsonCostCache &&
|
||||
modelsJsonCostCache.path === modelsPath &&
|
||||
modelsJsonCostCache.mtimeMs === stat.mtimeMs
|
||||
) {
|
||||
return modelsJsonCostCache.entries;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf8")) as {
|
||||
providers?: Record<string, ModelProviderConfig>;
|
||||
};
|
||||
const entries = buildProviderCostIndex(parsed.providers);
|
||||
modelsJsonCostCache = {
|
||||
path: modelsPath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
entries,
|
||||
};
|
||||
return entries;
|
||||
} catch {
|
||||
const empty = new Map<string, ModelCostConfig>();
|
||||
modelsJsonCostCache = {
|
||||
path: modelsPath,
|
||||
mtimeMs: -1,
|
||||
entries: empty,
|
||||
};
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
function findConfiguredProviderCost(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): ModelCostConfig | undefined {
|
||||
const key = toResolvedModelKey(params);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
return buildProviderCostIndex(params.config?.models?.providers).get(key);
|
||||
}
|
||||
|
||||
export function resolveModelCostConfig(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
config?: OpenClawConfig;
|
||||
}): ModelCostConfig | undefined {
|
||||
const provider = params.provider?.trim();
|
||||
const model = params.model?.trim();
|
||||
if (!provider || !model) {
|
||||
const key = toResolvedModelKey(params);
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const providers = params.config?.models?.providers ?? {};
|
||||
const entry = providers[provider]?.models?.find((item) => item.id === model);
|
||||
return entry?.cost;
|
||||
|
||||
const modelsJsonCost = loadModelsJsonCostIndex().get(key);
|
||||
if (modelsJsonCost) {
|
||||
return modelsJsonCost;
|
||||
}
|
||||
|
||||
const configuredCost = findConfiguredProviderCost(params);
|
||||
if (configuredCost) {
|
||||
return configuredCost;
|
||||
}
|
||||
|
||||
return getCachedGatewayModelPricing(params);
|
||||
}
|
||||
|
||||
const toNumber = (value: number | undefined): number =>
|
||||
@ -89,3 +183,7 @@ export function estimateUsageCost(params: {
|
||||
}
|
||||
return total / 1_000_000;
|
||||
}
|
||||
|
||||
export function __resetUsageFormatCachesForTest(): void {
|
||||
modelsJsonCostCache = null;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user