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
5 changes: 5 additions & 0 deletions .changeset/consistent-usage-totals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@voltagent/core": patch
---

Use aggregate finish usage consistently across providers.
4 changes: 2 additions & 2 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1161,7 +1161,7 @@ Use pandas and summarize findings.`.split("\n"),
expect(parts[1]).toEqual(expect.objectContaining({ type: "text-delta", id: "text-1" }));
});

it("uses last-step usage for finish events when provider is anthropic", async () => {
it("keeps aggregate total usage for finish events when provider is anthropic", async () => {
const agent = new Agent({
name: "TestAgent",
instructions: "You are a helpful assistant",
Expand Down Expand Up @@ -1223,7 +1223,7 @@ Use pandas and summarize findings.`.split("\n"),
}

const finishPart = parts.find((part) => part.type === "finish");
expect(finishPart?.totalUsage).toEqual(lastStepUsage);
expect(finishPart?.totalUsage).toEqual(summedUsage);
});

it("keeps fullStream intact after probe for ReadableStream-based providers", async () => {
Expand Down
20 changes: 10 additions & 10 deletions packages/core/src/utils/usage-normalizer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const toAsync = async function* <T>(items: T[]): AsyncIterable<T> {
};

describe("resolveFinishUsage", () => {
it("prefers last-step usage when provider metadata indicates anthropic", () => {
it("prefers total usage when provider metadata indicates anthropic", () => {
const lastStepUsage: LanguageModelUsage = {
inputTokens: 5,
outputTokens: 6,
Expand All @@ -35,10 +35,10 @@ describe("resolveFinishUsage", () => {
totalUsage,
});

expect(resolved).toBe(lastStepUsage);
expect(resolved).toBe(totalUsage);
});

it("prefers last-step usage when usage includes cache fields", () => {
it("prefers total usage when usage includes cache fields", () => {
const lastStepUsage = {
inputTokens: 5,
outputTokens: 6,
Expand All @@ -56,7 +56,7 @@ describe("resolveFinishUsage", () => {
totalUsage,
});

expect(resolved).toBe(lastStepUsage);
expect(resolved).toBe(totalUsage);
});

it("prefers total usage when provider is not anthropic", () => {
Expand Down Expand Up @@ -98,7 +98,7 @@ describe("resolveFinishUsage", () => {
});

describe("normalizeFinishUsageStream", () => {
it("overrides finish totalUsage with last-step usage for anthropic streams", async () => {
it("keeps finish totalUsage unchanged for anthropic streams", async () => {
const lastStepUsage: LanguageModelUsage = {
inputTokens: 10,
outputTokens: 5,
Expand All @@ -121,10 +121,10 @@ describe("normalizeFinishUsageStream", () => {

const normalized = await collectStream(normalizeFinishUsageStream(toAsync(parts)));

expect(normalized[2].totalUsage).toEqual(lastStepUsage);
expect(normalized[2].totalUsage).toEqual(totalUsage);
});

it("uses finish metadata to override totalUsage when finish-step metadata is missing", async () => {
it("keeps finish totalUsage unchanged when finish-step metadata is missing", async () => {
const lastStepUsage: LanguageModelUsage = {
inputTokens: 9,
outputTokens: 4,
Expand All @@ -147,10 +147,10 @@ describe("normalizeFinishUsageStream", () => {

const normalized = await collectStream(normalizeFinishUsageStream(toAsync(parts)));

expect(normalized[1].totalUsage).toEqual(lastStepUsage);
expect(normalized[1].totalUsage).toEqual(totalUsage);
});

it("overrides totalUsage when cache fields indicate anthropic usage", async () => {
it("keeps finish totalUsage unchanged when cache fields are present", async () => {
const lastStepUsage = {
inputTokens: 8,
outputTokens: 4,
Expand All @@ -169,7 +169,7 @@ describe("normalizeFinishUsageStream", () => {

const normalized = await collectStream(normalizeFinishUsageStream(toAsync(parts)));

expect(normalized[1].totalUsage).toEqual(lastStepUsage);
expect(normalized[1].totalUsage).toEqual(totalUsage);
});

it("keeps finish totalUsage unchanged for non-anthropic streams", async () => {
Expand Down
52 changes: 1 addition & 51 deletions packages/core/src/utils/usage-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,69 +13,19 @@ type StreamPartWithUsage = {
providerMetadata?: unknown;
};

const shouldUseLastStepUsage = (providerMetadata: unknown, usage?: LanguageModelUsage): boolean => {
if (providerMetadata && typeof providerMetadata === "object") {
if (Object.prototype.hasOwnProperty.call(providerMetadata, "anthropic")) {
return true;
}
}

const raw = (usage as { raw?: Record<string, unknown> } | undefined)?.raw;
if (raw && typeof raw === "object") {
if (
Object.prototype.hasOwnProperty.call(raw, "cache_creation_input_tokens") ||
Object.prototype.hasOwnProperty.call(raw, "cache_read_input_tokens")
) {
return true;
}
}

return false;
};

export const resolveFinishUsage = (input: FinishUsageInput): LanguageModelUsage | undefined => {
const { providerMetadata, usage, totalUsage } = input;
const { usage, totalUsage } = input;
if (!usage && !totalUsage) {
return undefined;
}

if (shouldUseLastStepUsage(providerMetadata, usage ?? totalUsage)) {
return usage ?? totalUsage;
}

return totalUsage ?? usage;
};

export async function* normalizeFinishUsageStream<T extends StreamPartWithUsage>(
baseStream: AsyncIterable<T>,
): AsyncIterable<T> {
let lastStepUsage: LanguageModelUsage | undefined;
let useLastStepUsage = false;

for await (const part of baseStream) {
if (part.type === "finish-step") {
lastStepUsage = part.usage;
if (!useLastStepUsage) {
useLastStepUsage = shouldUseLastStepUsage(part.providerMetadata, lastStepUsage);
}
}

if (part.type === "finish" && !useLastStepUsage) {
if (shouldUseLastStepUsage(part.providerMetadata, lastStepUsage)) {
useLastStepUsage = true;
if (part.usage) {
lastStepUsage = part.usage;
}
}
}

if (part.type === "finish" && useLastStepUsage && lastStepUsage) {
if (part.totalUsage !== undefined) {
yield { ...part, totalUsage: lastStepUsage };
continue;
}
}

yield part;
}
}
Loading