Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 33 additions & 32 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"overrides": {
"miniflare": {
"sharp": "0.35.5",
"undici": "7.29.1"
}
},
Expand Down
2 changes: 1 addition & 1 deletion packages/collector/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cgaravitoq/aiusage",
"version": "0.5.1",
"version": "0.5.2",
"description": "Report local ccusage token usage to an aiusage instance",
"type": "module",
"bin": {
Expand Down
114 changes: 114 additions & 0 deletions packages/collector/src/mapping.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,42 @@ describe("mapAntigravitySteps", () => {
["2026-09-13", "gemini-unknown", 204],
]);
});

it("prices a model the table only lists on OpenRouter", () => {
const pro = {
cacheCreate: 0,
cacheRead: 2e-7,
input: 2e-6,
output: 1.2e-5,
};
const listed = new Map([
...prices,
["openrouter/google/gemini-3.9-pro", pro],
]);
const step: AntigravityStep = {
at: new Date("2026-09-13T12:31:00.000Z"),
cacheRead: 10,
input: 100,
model: "gemini-3.9-pro",
output: 20,
};

expect(mapAntigravitySteps([step], "Europe/Madrid", listed)).toEqual({
days: [
{
cache_create: 0,
cache_read: 10,
cost_usd: costOf(pro, { cacheRead: 10, input: 100, output: 20 }),
date: "2026-09-13",
input: 100,
model: "gemini-3.9-pro",
output: 20,
provider: "antigravity",
},
],
warnings: [],
});
});
});

describe("mapDevinSteps", () => {
Expand Down Expand Up @@ -483,4 +519,82 @@ describe("mapDevinSteps", () => {
warnings: ["devin: no price for swe-2"],
});
});

it("dots a dashed minor version when only the dotted name has a price", () => {
const flash = {
cacheCreate: 0,
cacheRead: 1.5e-8,
input: 4.4e-8,
output: 3e-7,
};
const glm = {
cacheCreate: 0,
cacheRead: 3e-8,
input: 1.5e-7,
output: 5e-7,
};
const resold = {
cacheCreate: 0,
cacheRead: 6e-9,
input: 3e-7,
output: 1.2e-6,
};
const listed = new Map([
...prices,
["azure_ai/deepseek-v4.1-flash", resold],
["openrouter/deepseek/deepseek-v4.1-flash", flash],
["openrouter/z-ai/glm-5.3-flash", glm],
]);
const measured: DevinStep[] = [
{ ...steps[0], model: "claude-fable-5-1-xhigh" },
{
at,
cacheCreate: 0,
cacheRead: 14315904,
input: 817896,
model: "deepseek-v4-1-flash",
output: 44989,
},
{
at,
cacheCreate: 0,
cacheRead: 2000,
input: 900,
model: "glm-5-3-flash-high",
output: 70,
},
{
at,
cacheCreate: 0,
cacheRead: 0,
input: 300,
model: "swe-2-max",
output: 12,
},
];

const mapped = mapDevinSteps(measured, "Europe/Madrid", listed);

expect(mapped.days.map((day) => [day.model, day.cost_usd])).toEqual([
[
"claude-fable-5-1",
costOf(fable, {
cacheCreate: 1100211,
cacheRead: 55834239,
input: 772,
output: 245921,
}),
],
[
"deepseek-v4.1-flash",
costOf(flash, { cacheRead: 14315904, input: 817896, output: 44989 }),
],
[
"glm-5.3-flash",
costOf(glm, { cacheRead: 2000, input: 900, output: 70 }),
],
["swe-2-max", 0],
]);
expect(mapped.warnings).toEqual(["devin: no price for swe-2-max"]);
});
});
34 changes: 26 additions & 8 deletions packages/collector/src/mapping.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import type { AntigravityStep } from "./antigravity";
import { type CcusageDaily, calendarDate } from "./ccusage";
import type { DevinStep } from "./devin";
import { costOf, type ModelPrice, type PriceTable } from "./pricing";
import { costOf, type ModelPrice, type PriceTable, priceFor } from "./pricing";
import type { UsageDay } from "./usage";

export const antigravityProvider = "antigravity";
export const devinProvider = "devin";

const agentModelPrefix = /^\[[^\]]*\]\s*/;
const effortSuffix = /-(low|medium|high|xhigh)$/;
const gptMinorVersion = /^gpt-(\d+)-(\d+)-/;

const litellmModel = (devinModel: string): string =>
devinModel.replace(effortSuffix, "").replace(gptMinorVersion, "gpt-$1.$2-");
const dashedMinorVersion = /(\d+)-(\d+)(?=-|$)/g;

function litellmModel(devinModel: string, prices: PriceTable): string {
const name = devinModel.replace(effortSuffix, "");
const dotted = name.replace(dashedMinorVersion, "$1.$2");
return priceFor(prices, name) === undefined &&
priceFor(prices, dotted) !== undefined
? dotted
: name;
}

const rowKey = (day: UsageDay): string =>
`${day.date}\u0000${day.provider}\u0000${day.model}`;
Expand Down Expand Up @@ -151,7 +157,10 @@ export function mapAntigravitySteps(
steps,
antigravityProvider,
timezone,
(model) => prices.get(model) ?? prices.get(`gemini/${model}`),
(model) =>
prices.get(model) ??
prices.get(`gemini/${model}`) ??
priceFor(prices, model),
);
}

Expand All @@ -160,11 +169,20 @@ export function mapDevinSteps(
timezone: string,
prices: PriceTable,
): MappedDays {
const names = new Map(
[...new Set(steps.map((step) => step.model))].map((model) => [
model,
litellmModel(model, prices),
]),
);
return mapLocalSteps(
steps.map((step) => ({ ...step, model: litellmModel(step.model) })),
steps.map((step) => ({
...step,
model: names.get(step.model) ?? step.model,
})),
devinProvider,
timezone,
(model) => prices.get(model),
(model) => priceFor(prices, model),
);
}

Expand Down
36 changes: 36 additions & 0 deletions packages/collector/src/pricing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
litellmPricesUrl,
loadPrices,
parseLitellmPrices,
priceFor,
} from "./pricing";

const sample = JSON.stringify({
Expand Down Expand Up @@ -110,6 +111,41 @@ describe("costOf", () => {
});
});

describe("priceFor", () => {
const listed = {
cacheCreate: 0,
cacheRead: 1.5e-8,
input: 4.4e-8,
output: 3e-7,
};
const resold = {
cacheCreate: 0,
cacheRead: 6e-9,
input: 3e-7,
output: 1.2e-6,
};
const own = { cacheCreate: 0, cacheRead: 2e-8, input: 1e-7, output: 4e-7 };
const table = new Map([
["azure_ai/deepseek-v4.1-flash", resold],
["openrouter/deepseek/deepseek-v4.1-flash", listed],
["glm-5.3-flash", own],
["openrouter/z-ai/glm-5.3-flash", listed],
]);

it("prefers the bare LiteLLM name", () => {
expect(priceFor(table, "glm-5.3-flash")).toBe(own);
});

it("falls back to the OpenRouter listing of a model LiteLLM only keys by reseller", () => {
expect(priceFor(table, "deepseek-v4.1-flash")).toBe(listed);
});

it("finds nothing for a model no listing names", () => {
expect(priceFor(table, "swe-2-max")).toBeUndefined();
expect(priceFor(table, "deepseek")).toBeUndefined();
});
});

describe("loadPrices", () => {
it("fetches LiteLLM and writes the cache when there is none", async () => {
const calls: string[] = [];
Expand Down
21 changes: 21 additions & 0 deletions packages/collector/src/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ export function parseLitellmPrices(source: string): PriceTable {
return table;
}

// LiteLLM keys some models only by reseller (azure_ai/, prism/, openrouter/),
// and the resellers disagree by up to 7x; OpenRouter passes the vendor's list
// price through, so it stands in for a bare name LiteLLM does not carry.
const openrouterModel = /^openrouter\/[^/]+\/(.+)$/;

export function priceFor(
table: PriceTable,
model: string,
): ModelPrice | undefined {
const exact = table.get(model);
if (exact !== undefined) {
return exact;
}
for (const [key, price] of table) {
if (openrouterModel.exec(key)?.[1] === model) {
return price;
}
}
return undefined;
}

export function costOf(price: ModelPrice, usage: PricedUsage): number {
return (
usage.input * price.input +
Expand Down
Loading