Skip to content

Commit 39493de

Browse files
authored
feat: [ENG-2928] Tracking usage for new model registry (#4676)
* new usage processor for new model registry * cleaner type * type get usage processor * fix * update snapshots and add usage processing tests * cost calculation functions with tests * remoe comments * fix test * store and pull attempt * nits * nit comment * rename * throw on failed usage parsing on successful gateway attempt * indent * safer timeout on reading raw response * rm comment * refactor to only pull response body once
1 parent 8b7bebe commit 39493de

File tree

15 files changed

+792
-42
lines changed

15 files changed

+792
-42
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`OpenAIUsageProcessor usage processing snapshot 1`] = `
4+
{
5+
"cached-response": {
6+
"data": {
7+
"cacheDetails": {
8+
"cachedInput": 1152,
9+
},
10+
"input": 96,
11+
"output": 10,
12+
},
13+
"error": null,
14+
},
15+
"stream-response": {
16+
"data": {
17+
"input": 1248,
18+
"output": 10,
19+
},
20+
"error": null,
21+
},
22+
}
23+
`;
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { ModelUsage } from "../../cost/usage/types";
2+
import type { ModelProviderName } from "../../cost/models/providers";
3+
import { modelCostBreakdownFromRegistry } from "../../cost/costCalc";
4+
5+
describe("modelCostBreakdownFromRegistry", () => {
6+
it("should calculate cost for basic GPT-4o usage", () => {
7+
const modelUsage: ModelUsage = {
8+
input: 1000,
9+
output: 500,
10+
};
11+
12+
const breakdown = modelCostBreakdownFromRegistry({
13+
modelUsage,
14+
model: "gpt-4o",
15+
provider: "openai" as ModelProviderName,
16+
});
17+
18+
expect(breakdown).not.toBeNull();
19+
if (breakdown) {
20+
// GPT-4o pricing: $0.0025 per 1K input, $0.01 per 1K output
21+
// Expected: 1000 * 0.0025/1000 + 500 * 0.01/1000
22+
// = 0.0025 + 0.005 = 0.0075
23+
expect(breakdown.totalCost).toBe(0.0075);
24+
}
25+
});
26+
27+
it("should calculate cost for Claude with cache", () => {
28+
const modelUsage: ModelUsage = {
29+
input: 1500,
30+
output: 1000,
31+
cacheDetails: {
32+
cachedInput: 500,
33+
write5m: 100,
34+
write1h: 50,
35+
},
36+
};
37+
38+
const breakdown = modelCostBreakdownFromRegistry({
39+
modelUsage,
40+
model: "claude-3.5-sonnet-v2",
41+
provider: "anthropic" as ModelProviderName,
42+
});
43+
44+
expect(breakdown).not.toBeNull();
45+
if (breakdown) {
46+
// Claude pricing: $0.003 per 1K input, $0.015 per 1K output
47+
// Cache multipliers: cachedInput: 0.1, write5m: 1.25, write1h: 2.0
48+
// Expected calculation:
49+
// - Regular input: 1500 * 0.003/1000 = 0.0045
50+
// - Cached input: 500 * 0.003/1000 * 0.1 = 0.00015
51+
// - Cache write 5m: 100 * 0.003/1000 * 1.25 = 0.000375
52+
// - Cache write 1h: 50 * 0.003/1000 * 2.0 = 0.0003
53+
// - Output: 1000 * 0.015/1000 = 0.015
54+
// Total: 0.0045 + 0.00015 + 0.000375 + 0.0003 + 0.015 = 0.020325
55+
expect(breakdown.totalCost).toBeCloseTo(0.020325, 10);
56+
}
57+
});
58+
59+
it("should return null for non-existent model", () => {
60+
const modelUsage: ModelUsage = {
61+
input: 100,
62+
output: 50,
63+
};
64+
65+
const breakdown = modelCostBreakdownFromRegistry({
66+
modelUsage,
67+
model: "non-existent-model",
68+
provider: "unknown" as ModelProviderName,
69+
});
70+
71+
expect(breakdown).toBeNull();
72+
});
73+
74+
it("should return 0 for empty usage", () => {
75+
const modelUsage: ModelUsage = {
76+
input: 0,
77+
output: 0,
78+
};
79+
80+
const breakdown = modelCostBreakdownFromRegistry({
81+
modelUsage,
82+
model: "gpt-4o",
83+
provider: "openai" as ModelProviderName,
84+
});
85+
86+
expect(breakdown).not.toBeNull();
87+
if (breakdown) {
88+
expect(breakdown.totalCost).toBe(0);
89+
}
90+
});
91+
92+
it("should calculate cost breakdown correctly", () => {
93+
const modelUsage: ModelUsage = {
94+
input: 800,
95+
output: 500,
96+
cacheDetails: {
97+
cachedInput: 200,
98+
},
99+
};
100+
101+
const breakdown = modelCostBreakdownFromRegistry({
102+
modelUsage,
103+
model: "gpt-4o",
104+
provider: "openai" as ModelProviderName,
105+
});
106+
107+
expect(breakdown).not.toBeNull();
108+
if (breakdown) {
109+
// GPT-4o pricing: $0.0025 per 1K input, $0.01 per 1K output
110+
// Cache multiplier for cached input: 0.5
111+
expect(breakdown.inputCost).toBe(800 * 0.0025 / 1000);
112+
expect(breakdown.cachedInputCost).toBe(200 * 0.0025 / 1000 * 0.5);
113+
expect(breakdown.outputCost).toBe(500 * 0.01 / 1000);
114+
expect(breakdown.totalCost).toBe(breakdown.inputCost + breakdown.cachedInputCost + breakdown.outputCost);
115+
}
116+
});
117+
118+
it("should handle audio tokens for Gemini", () => {
119+
const modelUsage: ModelUsage = {
120+
input: 1000,
121+
output: 500,
122+
audio: 200,
123+
};
124+
125+
const breakdown = modelCostBreakdownFromRegistry({
126+
modelUsage,
127+
model: "gemini-2.5-flash",
128+
provider: "google-ai-studio" as ModelProviderName,
129+
});
130+
131+
expect(breakdown).not.toBeNull();
132+
if (breakdown) {
133+
expect(breakdown.audioCost).toBeGreaterThan(0);
134+
expect(breakdown.totalCost).toBeGreaterThan(0);
135+
}
136+
});
137+
138+
it("should handle web search for Grok", () => {
139+
const modelUsage: ModelUsage = {
140+
input: 1000,
141+
output: 500,
142+
web_search: 5,
143+
};
144+
145+
const breakdown = modelCostBreakdownFromRegistry({
146+
modelUsage,
147+
model: "grok-3",
148+
provider: "xai" as ModelProviderName,
149+
});
150+
151+
expect(breakdown).not.toBeNull();
152+
if (breakdown) {
153+
expect(breakdown.webSearchCost).toBe(5 * 0.025);
154+
}
155+
});
156+
157+
it("should handle images for Gemini", () => {
158+
const modelUsage: ModelUsage = {
159+
input: 500,
160+
output: 200,
161+
image: 3,
162+
};
163+
164+
const breakdown = modelCostBreakdownFromRegistry({
165+
modelUsage,
166+
model: "gemini-2.5-flash",
167+
provider: "vertex" as ModelProviderName,
168+
});
169+
170+
expect(breakdown).not.toBeNull();
171+
if (breakdown) {
172+
// Gemini image price: $0.001238 per image
173+
expect(breakdown.imageCost).toBe(3 * 0.001238);
174+
}
175+
});
176+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"id": "chatcmpl-CDYL3CqkVL6FBFfhB2tTsnMgwShUR",
3+
"object": "chat.completion",
4+
"created": 1757346297,
5+
"model": "gpt-4o-2024-08-06",
6+
"choices": [
7+
{
8+
"index": 0,
9+
"message": {
10+
"role": "assistant",
11+
"content": "Hello! How can I assist you today?",
12+
"refusal": null,
13+
"annotations": []
14+
},
15+
"logprobs": null,
16+
"finish_reason": "stop"
17+
}
18+
],
19+
"usage": {
20+
"prompt_tokens": 1248,
21+
"completion_tokens": 10,
22+
"total_tokens": 1258,
23+
"prompt_tokens_details": {
24+
"cached_tokens": 1152,
25+
"audio_tokens": 0
26+
},
27+
"completion_tokens_details": {
28+
"reasoning_tokens": 0,
29+
"audio_tokens": 0,
30+
"accepted_prediction_tokens": 0,
31+
"rejected_prediction_tokens": 0
32+
}
33+
},
34+
"service_tier": "default",
35+
"system_fingerprint": "fp_f33640a400"
36+
}
37+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"mOg9lfxO2hD6ra"}
2+
3+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"or0Gvzs1Kvx"}
4+
5+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"M0FpDo6EbYZ5Orf"}
6+
7+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" How"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FBWYXZkuD1dS"}
8+
9+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"58OQJ4RLM1cD"}
10+
11+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ROvCbFebzDtaxg"}
12+
13+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" assist"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"mS05oYfyl"}
14+
15+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"CCV0ABejZdq1"}
16+
17+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":" today"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"VWsCuMl6YH"}
18+
19+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"KSh4A88SQR5QntO"}
20+
21+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"BOeJlkS3pX"}
22+
23+
data: {"id":"chatcmpl-CDYHKJaUtey5GfGKB4TquZ09VuER2","object":"chat.completion.chunk","created":1757346066,"model":"gpt-4o-2024-08-06","service_tier":"default","system_fingerprint":"fp_f33640a400","choices":[],"usage":{"prompt_tokens":1248,"completion_tokens":10,"total_tokens":1258,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"UJyZNZ13R3y"}
24+
25+
data: [DONE]
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect } from "@jest/globals";
2+
import { OpenAIUsageProcessor } from "@helicone-package/cost/usage/openAIUsageProcessor";
3+
import { getUsageProcessor } from "@helicone-package/cost/usage/getUsageProcessor";
4+
import * as fs from "fs";
5+
import * as path from "path";
6+
7+
describe("getUsageProcessor", () => {
8+
it("should return OpenAIUsageProcessor for openai provider", () => {
9+
const processor = getUsageProcessor("openai");
10+
expect(processor).toBeInstanceOf(OpenAIUsageProcessor);
11+
});
12+
13+
it("should throw error for unsupported provider", () => {
14+
expect(() => {
15+
getUsageProcessor("unsupported-provider" as any);
16+
}).toThrow("Usage processor not found for provider: unsupported-provider");
17+
});
18+
});
19+
20+
describe("OpenAIUsageProcessor", () => {
21+
const processor = new OpenAIUsageProcessor();
22+
23+
it("should parse real GPT-4o response with cached tokens", async () => {
24+
const responseData = fs.readFileSync(
25+
path.join(__dirname, "testData", "gpt4o-response-cached.txt"),
26+
"utf-8"
27+
);
28+
29+
const result = await processor.parse({
30+
responseBody: responseData,
31+
isStream: false
32+
});
33+
34+
expect(result.error).toBeNull();
35+
expect(result.data).toEqual({
36+
input: 96,
37+
output: 10,
38+
cacheDetails: {
39+
cachedInput: 1152
40+
}
41+
});
42+
});
43+
44+
it("should parse real GPT-4o stream response", async () => {
45+
const streamData = fs.readFileSync(
46+
path.join(__dirname, "testData", "gpt4o-stream-response.txt"),
47+
"utf-8"
48+
);
49+
50+
const result = await processor.parse({
51+
responseBody: streamData,
52+
isStream: true
53+
});
54+
55+
expect(result.error).toBeNull();
56+
expect(result.data).toEqual({
57+
input: 1248,
58+
output: 10
59+
});
60+
});
61+
62+
it("usage processing snapshot", async () => {
63+
const testCases = [
64+
{
65+
name: "cached-response",
66+
data: fs.readFileSync(path.join(__dirname, "testData", "gpt4o-response-cached.txt"), "utf-8"),
67+
isStream: false
68+
},
69+
{
70+
name: "stream-response",
71+
data: fs.readFileSync(path.join(__dirname, "testData", "gpt4o-stream-response.txt"), "utf-8"),
72+
isStream: true
73+
}
74+
];
75+
76+
const results: Record<string, any> = {};
77+
78+
for (const testCase of testCases) {
79+
const result = await processor.parse({
80+
responseBody: testCase.data,
81+
isStream: testCase.isStream
82+
});
83+
results[testCase.name] = result;
84+
}
85+
86+
expect(results).toMatchSnapshot();
87+
});
88+
});

packages/cost/costCalc.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import { costOfPrompt } from "./index";
2+
import type { ModelUsage } from "./usage/types";
3+
import type { ModelProviderName } from "./models/providers";
4+
import { calculateModelCostBreakdown, CostBreakdown } from "./models/calculate-cost";
25

6+
// since costs in clickhouse are multiplied by the multiplier
7+
// divide to get real cost in USD in dollars
38
export const COST_PRECISION_MULTIPLIER = 1_000_000_000;
49

10+
/**
11+
* LEGACY: Calculate model cost using the old cost registry format
12+
* This function uses the legacy cost registry in /providers/mappings
13+
* @deprecated Use modelCostFromRegistry for new implementations
14+
*/
515
export function modelCost(
616
params: {
717
provider: string;
@@ -37,3 +47,19 @@ export function modelCost(
3747
}) ?? 0
3848
);
3949
}
50+
51+
export function modelCostBreakdownFromRegistry(params: {
52+
modelUsage: ModelUsage;
53+
provider: ModelProviderName;
54+
model: string;
55+
requestCount?: number;
56+
}): CostBreakdown | null {
57+
const breakdown = calculateModelCostBreakdown({
58+
modelUsage: params.modelUsage,
59+
model: params.model,
60+
provider: params.provider,
61+
requestCount: params.requestCount,
62+
});
63+
64+
return breakdown;
65+
}

0 commit comments

Comments
 (0)