Skip to content

Commit fe07de4

Browse files
authored
fix(webapp): use provider-reported cost for AI generations when present (#4186)
## Summary The run page could show an AI generation cost well above what the provider actually charged, most visibly for OpenRouter and Vercel AI Gateway requests where a heavily cache-read prompt was priced at the full input rate. When the provider reports an exact per-request cost, we now use that instead of catalog pricing. ## Fix Gateway and OpenRouter include the exact per-request cost in `ai.response.providerMetadata` (`openrouter.usage.cost` / `gateway.cost`). That figure already reflects the cache-read discount and the real per-provider rate, which the catalog cannot reconstruct: cache-read counts do not arrive in `gen_ai.usage.*`, and per-model catalog prices drift from what the provider billed, in either direction. So provider-reported cost is now preferred, and the catalog is used only when no provider cost is present. Fallback routing is covered by the same change: when OpenRouter routes to a different model, `gen_ai.response.model` already carries the served model, so the cost follows the served model and the provider's own figure makes it exact. `extractProviderCost` now runs on every AI span, so it gets a cheap `"cost"` substring guard to skip the JSON parse on reasoning-model spans whose provider metadata carries large reasoning text and no cost field. Regression tests cover the cache-discount overcharge, fallback served-model pricing, gateway cost, and the catalog fallback path.
1 parent 7c5f089 commit fe07de4

4 files changed

Lines changed: 233 additions & 10 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
AI generation cost now uses the exact provider-reported cost from OpenRouter/Vercel AI Gateway when present, instead of catalog pricing, so cache-discounted and fallback-routed requests match the amount the provider actually billed.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, it, expect } from "vitest";
2+
import { enrichCreatableEvents, setLlmPricingRegistry } from "./enrichCreatableEvents.server";
3+
import type { CreateEventInput } from "../eventRepository/eventRepository.types";
4+
5+
type Registry = Parameters<typeof setLlmPricingRegistry>[0];
6+
type RegistryCost = NonNullable<ReturnType<Registry["calculateCost"]>>;
7+
8+
function registryReturning(cost: RegistryCost | null, isLoaded = true): Registry {
9+
return {
10+
isLoaded,
11+
calculateCost: () => cost,
12+
};
13+
}
14+
15+
// A catalog cost result the registry would produce. The numbers here stand in for a catalog
16+
// price that disagrees with what the provider actually billed.
17+
function catalogCost(overrides: Partial<RegistryCost> = {}): RegistryCost {
18+
return {
19+
matchedModelId: "llm_model_test",
20+
matchedModelName: "test-model",
21+
pricingTierId: "tier_standard",
22+
pricingTierName: "Standard",
23+
inputCost: 0,
24+
outputCost: 0,
25+
totalCost: 0,
26+
costDetails: {},
27+
...overrides,
28+
};
29+
}
30+
31+
function makeEvent(properties: Record<string, unknown>): CreateEventInput {
32+
return {
33+
message: "ai.generateText.doGenerate",
34+
kind: "INTERNAL",
35+
isPartial: false,
36+
properties: properties as CreateEventInput["properties"],
37+
} as unknown as CreateEventInput;
38+
}
39+
40+
function enrichOne(event: CreateEventInput): CreateEventInput {
41+
const [out] = enrichCreatableEvents([event]);
42+
return out;
43+
}
44+
45+
function costPillText(event: CreateEventInput): string | undefined {
46+
const accessory = (event.style as any)?.accessory;
47+
const items: Array<{ text: string; icon: string }> = accessory?.items ?? [];
48+
return items.find((i) => i.icon === "tabler-currency-dollar")?.text;
49+
}
50+
51+
function modelPillText(event: CreateEventInput): string | undefined {
52+
const accessory = (event.style as any)?.accessory;
53+
const items: Array<{ text: string; icon: string }> = accessory?.items ?? [];
54+
return items.find((i) => i.icon === "tabler-cube")?.text;
55+
}
56+
57+
describe("enrichLlmMetrics — provider-reported cost", () => {
58+
it("prefers the provider-reported cost over catalog pricing when a cache discount applies", () => {
59+
// OpenRouter served mimo with 25,280 of 34,374 prompt tokens as cache reads. Cache counts
60+
// only reach us via providerMetadata, so the catalog bills the full prompt at the input
61+
// rate while OpenRouter's exact bill reflects the cache discount.
62+
setLlmPricingRegistry(registryReturning(catalogCost({ totalCost: 0.01603584 })));
63+
64+
const out = enrichOne(
65+
makeEvent({
66+
"gen_ai.system": "openrouter",
67+
"gen_ai.request.model": "xiaomi/mimo-v2.5-pro",
68+
"gen_ai.response.model": "xiaomi/mimo-v2.5-pro-20260422",
69+
"gen_ai.usage.input_tokens": 34374,
70+
"gen_ai.usage.output_tokens": 1245,
71+
"ai.response.providerMetadata": JSON.stringify({
72+
openrouter: {
73+
provider: "Xiaomi",
74+
usage: { cost: 0.005130048, promptTokensDetails: { cachedTokens: 25280 } },
75+
},
76+
}),
77+
})
78+
);
79+
80+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.005130048);
81+
expect(out.properties["trigger.llm.cost_source"]).toBe("openrouter");
82+
// The catalog breakdown must not be written — it priced the wrong (full-rate) total.
83+
expect(out.properties["trigger.llm.input_cost"]).toBeUndefined();
84+
expect(out.properties["trigger.llm.matched_model"]).toBeUndefined();
85+
expect(costPillText(out)).toBe("$0.005130");
86+
87+
expect(out._llmMetrics?.totalCost).toBe(0.005130048);
88+
expect(out._llmMetrics?.costSource).toBe("openrouter");
89+
expect(out._llmMetrics?.providerCost).toBe(0.005130048);
90+
expect(out._llmMetrics?.inputCost).toBe(0);
91+
});
92+
93+
it("prices the served fallback model's provider cost, not the requested model", () => {
94+
// Requested mimo, OpenRouter routed to a gemini fallback: gen_ai.response.model carries the
95+
// SERVED model, and the provider cost is authoritative.
96+
setLlmPricingRegistry(registryReturning(catalogCost({ totalCost: 0.02 })));
97+
98+
const out = enrichOne(
99+
makeEvent({
100+
"gen_ai.system": "openrouter",
101+
"gen_ai.request.model": "xiaomi/mimo-v2.5-pro",
102+
"gen_ai.response.model": "google/gemini-3.5-flash-20260519",
103+
"gen_ai.usage.input_tokens": 5000,
104+
"gen_ai.usage.output_tokens": 800,
105+
"ai.response.providerMetadata": JSON.stringify({
106+
openrouter: { provider: "Google", usage: { cost: 0.011058 } },
107+
}),
108+
})
109+
);
110+
111+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.011058);
112+
expect(out.properties["trigger.llm.cost_source"]).toBe("openrouter");
113+
// The pill (and stored response model) reflect the served fallback model.
114+
expect(modelPillText(out)).toBe("google/gemini-3.5-flash-20260519");
115+
expect(out._llmMetrics?.requestModel).toBe("xiaomi/mimo-v2.5-pro");
116+
expect(out._llmMetrics?.responseModel).toBe("google/gemini-3.5-flash-20260519");
117+
expect(out._llmMetrics?.totalCost).toBe(0.011058);
118+
});
119+
120+
it("prefers the gateway-reported cost over catalog pricing", () => {
121+
setLlmPricingRegistry(registryReturning(catalogCost({ totalCost: 0.02 })));
122+
123+
const out = enrichOne(
124+
makeEvent({
125+
"gen_ai.system": "gateway",
126+
"gen_ai.response.model": "openai/gpt-4o",
127+
"gen_ai.usage.input_tokens": 1000,
128+
"gen_ai.usage.output_tokens": 500,
129+
"ai.response.providerMetadata": JSON.stringify({
130+
gateway: { cost: "0.0006615" },
131+
}),
132+
})
133+
);
134+
135+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.0006615);
136+
expect(out.properties["trigger.llm.cost_source"]).toBe("gateway");
137+
expect(out._llmMetrics?.costSource).toBe("gateway");
138+
});
139+
140+
it("uses provider cost even when the catalog does not match the model", () => {
141+
setLlmPricingRegistry(registryReturning(null));
142+
143+
const out = enrichOne(
144+
makeEvent({
145+
"gen_ai.system": "openrouter",
146+
"gen_ai.response.model": "some/unlisted-model",
147+
"gen_ai.usage.input_tokens": 2000,
148+
"gen_ai.usage.output_tokens": 300,
149+
"ai.response.providerMetadata": JSON.stringify({
150+
openrouter: { provider: "SomeProvider", usage: { cost: 0.00042 } },
151+
}),
152+
})
153+
);
154+
155+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.00042);
156+
expect(out.properties["trigger.llm.cost_source"]).toBe("openrouter");
157+
});
158+
159+
it("falls back to catalog pricing when no provider cost is reported", () => {
160+
setLlmPricingRegistry(
161+
registryReturning(
162+
catalogCost({
163+
matchedModelId: "llm_model_gpt4o",
164+
matchedModelName: "gpt-4o",
165+
inputCost: 0.04,
166+
outputCost: 0.01,
167+
totalCost: 0.05,
168+
costDetails: { input: 0.04, output: 0.01 },
169+
})
170+
)
171+
);
172+
173+
const out = enrichOne(
174+
makeEvent({
175+
"gen_ai.system": "openai",
176+
"gen_ai.response.model": "gpt-4o",
177+
"gen_ai.usage.input_tokens": 1000,
178+
"gen_ai.usage.output_tokens": 500,
179+
})
180+
);
181+
182+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.05);
183+
expect(out.properties["trigger.llm.input_cost"]).toBe(0.04);
184+
expect(out.properties["trigger.llm.output_cost"]).toBe(0.01);
185+
expect(out.properties["trigger.llm.matched_model"]).toBe("gpt-4o");
186+
// Registry path does not set a cost_source attribute.
187+
expect(out.properties["trigger.llm.cost_source"]).toBeUndefined();
188+
expect(out._llmMetrics?.costSource).toBe("registry");
189+
expect(out._llmMetrics?.matchedModelId).toBe("llm_model_gpt4o");
190+
});
191+
192+
it("falls back to catalog pricing when providerMetadata carries no cost field", () => {
193+
// The cheap `"cost"` guard should skip parsing and let the registry price this span.
194+
setLlmPricingRegistry(
195+
registryReturning(catalogCost({ totalCost: 0.03, matchedModelName: "claude" }))
196+
);
197+
198+
const out = enrichOne(
199+
makeEvent({
200+
"gen_ai.system": "anthropic",
201+
"gen_ai.response.model": "claude-sonnet-4-0",
202+
"gen_ai.usage.input_tokens": 1000,
203+
"gen_ai.usage.output_tokens": 500,
204+
"ai.response.providerMetadata": JSON.stringify({
205+
anthropic: { usage: { service_tier: "standard" } },
206+
}),
207+
})
208+
);
209+
210+
expect(out.properties["trigger.llm.total_cost"]).toBe(0.03);
211+
expect(out.properties["trigger.llm.cost_source"]).toBeUndefined();
212+
expect(out._llmMetrics?.costSource).toBe("registry");
213+
});
214+
});

apps/webapp/app/v3/utils/enrichCreatableEvents.server.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,17 @@ function enrichLlmMetrics(event: CreateEventInput): void {
8989
{ text: formatTokenCount(totalTokens), icon: "tabler-hash" },
9090
];
9191

92-
// Try cost enrichment if the registry is loaded.
93-
// The registry handles prefix stripping (e.g. "mistral/mistral-large-3" → "mistral-large-3")
94-
// for gateway/openrouter models automatically in its match() method.
92+
// Provider-reported cost (gateway/openrouter) is the exact per-request bill and already
93+
// reflects cache-read discounts and the real per-provider rate, so prefer it and only fall
94+
// back to catalog pricing when it is absent. The registry handles prefix stripping (e.g.
95+
// "mistral/mistral-large-3" → "mistral-large-3") for gateway/openrouter models in match().
96+
const providerCost = extractProviderCost(props);
97+
9598
let cost: ReturnType<NonNullable<typeof _registry>["calculateCost"]> | null = null;
96-
if (_registry?.isLoaded) {
99+
if (!providerCost && _registry?.isLoaded) {
97100
cost = _registry.calculateCost(responseModel, usageDetails);
98101
}
99102

100-
// Fallback: extract cost from provider metadata (gateway/openrouter report per-request cost)
101-
let providerCost: { totalCost: number; source: string } | null = null;
102-
if (!cost) {
103-
providerCost = extractProviderCost(props);
104-
}
105-
106103
if (cost) {
107104
// Add trigger.llm.* attributes to the span from our pricing registry
108105
event.properties = {
@@ -342,6 +339,11 @@ function extractProviderCost(
342339
const rawMeta = props["ai.response.providerMetadata"];
343340
if (typeof rawMeta !== "string") return null;
344341

342+
// Cheap guard: providerMetadata can be large for reasoning models (it carries the full
343+
// reasoning_details text), and this now runs on every AI span. Skip the JSON parse when
344+
// there is no cost field to find.
345+
if (!rawMeta.includes('"cost"')) return null;
346+
345347
let meta: Record<string, unknown>;
346348
try {
347349
meta = JSON.parse(rawMeta) as Record<string, unknown>;

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default defineConfig({
1313
"test/**/*.test.ts",
1414
"app/v3/runOpsMigration/**/*.test.ts",
1515
"app/v3/runStore.server.test.ts",
16+
"app/v3/utils/**/*.test.ts",
1617
"app/v3/services/bulk/**/*.test.ts",
1718
"app/runEngine/concerns/**/*.test.ts",
1819
"app/runEngine/services/**/*.test.ts",

0 commit comments

Comments
 (0)