From 0df66f6d63d2d5f3bffbe86bb3ea7b08fae4d58d Mon Sep 17 00:00:00 2001 From: synth Date: Sat, 1 Aug 2026 12:56:23 -0400 Subject: [PATCH] fix(anthropic): honor configured model pricing (#117275) --- extensions/anthropic/index.test.ts | 65 ++++++++++++++++++++++++ extensions/anthropic/register.runtime.ts | 15 ++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/extensions/anthropic/index.test.ts b/extensions/anthropic/index.test.ts index c7ba2b39e371..8bf5f373ad68 100644 --- a/extensions/anthropic/index.test.ts +++ b/extensions/anthropic/index.test.ts @@ -1,4 +1,5 @@ // Anthropic tests cover index plugin behavior. +import { calculateCost, type Usage } from "openclaw/plugin-sdk/llm"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, @@ -859,6 +860,70 @@ describe("anthropic provider replay hooks", () => { expect(normalized?.cost).toEqual((resolved as ProviderRuntimeModel).cost); }); + it.each(["claude-sonnet-5", "claude-opus-5"])( + "uses operator-configured %s pricing for assistant usage", + async (modelId) => { + const provider = await registerSingleProviderPlugin(anthropicPlugin); + const configuredCost = { input: 777, output: 888, cacheRead: 999, cacheWrite: 666 }; + const config: NonNullable = { + models: { + providers: { + anthropic: { + baseUrl: "https://api.anthropic.com", + models: [ + { + id: modelId, + name: modelId, + reasoning: true, + input: ["text", "image"], + cost: configuredCost, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + }; + const discoveredModel = provider.resolveDynamicModel?.({ + config, + provider: "anthropic", + modelId, + modelRegistry: createModelRegistry([]), + } as ProviderResolveDynamicModelContext); + expect(discoveredModel).toBeDefined(); + + const configuredModel = { + ...(discoveredModel as ProviderRuntimeModel), + cost: configuredCost, + }; + const resolvedModel = + provider.normalizeResolvedModel?.({ + config, + provider: "anthropic", + modelId, + model: configuredModel, + } as never) ?? configuredModel; + const usage: Usage = { + input: 1_000, + output: 1_000, + cacheRead: 1_000, + cacheWrite: 1_000, + totalTokens: 4_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + + calculateCost(resolvedModel as Parameters[0], usage); + + expect(resolvedModel.cost).toEqual(configuredCost); + expect(usage.cost.input).toBeCloseTo(0.777); + expect(usage.cost.output).toBeCloseTo(0.888); + expect(usage.cost.cacheRead).toBeCloseTo(0.999); + expect(usage.cost.cacheWrite).toBeCloseTo(0.666); + expect(usage.cost.total).toBeCloseTo(3.33); + }, + ); + it("resolves Claude Mythos 5 with its direct-only mandatory-adaptive contract", async () => { const provider = await registerSingleProviderPlugin(anthropicPlugin); const resolved = provider.resolveDynamicModel?.({ diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index ac4ee48f8bd5..cb231c5741f5 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -631,10 +631,11 @@ function supportsAnthropicNativeMaxEffort(modelId: string): boolean { return supportsClaudeNativeMaxEffort({ id: modelId }) || isAnthropicMythosPreviewModel(modelId); } -function hasConfiguredModelContextOverride( +function hasConfiguredModelOverride( config: ProviderNormalizeResolvedModelContext["config"], provider: string, modelId: string, + override: "context" | "cost", ): boolean { const providers = config?.models?.providers; if (!providers || typeof providers !== "object") { @@ -657,8 +658,10 @@ function hasConfiguredModelContextOverride( continue; } if ( - (typeof model?.contextTokens === "number" && model.contextTokens > 0) || - (typeof model?.contextWindow === "number" && model.contextWindow > 0) + override === "cost" + ? model?.cost !== undefined + : (typeof model?.contextTokens === "number" && model.contextTokens > 0) || + (typeof model?.contextWindow === "number" && model.contextWindow > 0) ) { return true; } @@ -681,7 +684,7 @@ function applyAnthropicFixedContextWindow(params: { if (fixedContextWindow === undefined) { return undefined; } - if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) { + if (hasConfiguredModelOverride(params.config, params.provider, params.modelId, "context")) { return undefined; } const exactContextWindow = isAnthropicExact1MClaude5Model(params.contractModelId); @@ -878,8 +881,10 @@ function normalizeAnthropicResolvedModel( contractModelId, model: thinkingLevelModel, }) ?? thinkingLevelModel; + // Provider catalog defaults must not replace explicit operator pricing. const pricingModel = - normalizeLowercaseStringOrEmpty(ctx.provider) === PROVIDER_ID + normalizeLowercaseStringOrEmpty(ctx.provider) === PROVIDER_ID && + !hasConfiguredModelOverride(ctx.config, ctx.provider, ctx.modelId, "cost") ? (applyAnthropicOpus5Cost({ modelId: contractModelId, model: contextWindowModel,