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

fix: preserve usage and provider cost metadata on structured output failures

When `generateText` receives a successful model response but structured output is not produced,
VoltAgent now keeps the resolved usage, finish reason, and provider metadata on the resulting
error path.

This preserves provider-reported cost data for observability spans and makes the same metadata
available to error hooks through `VoltAgentError.metadata`.
76 changes: 76 additions & 0 deletions packages/core/src/agent/agent-observability.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import * as ai from "ai";
import { MockLanguageModelV3, mockId, simulateReadableStream } from "ai/test";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import { NodeVoltAgentObservability, WebSocketEventEmitter } from "../observability";
import { SpanKind, SpanStatusCode } from "../observability/types";
import { Tool } from "../tool";
import { Agent } from "./agent";
import { createOutputGuardrail } from "./guardrail";

Expand Down Expand Up @@ -265,6 +267,80 @@ describe("Agent with Observability", () => {
unsubscribe();
});

it("should preserve provider cost when structured output generation fails after a successful model call", async () => {
const events: any[] = [];
const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => {
events.push(event);
});
Comment on lines +271 to +274
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify `any[]` event declarations in observability specs
rg -n --type=ts 'const\s+events\s*:\s*any\[\]\s*=' packages/core/src/agent/agent-observability.spec.ts

Repository: VoltAgent/voltagent

Length of output: 421


Replace any[] with a typed event shape to maintain type safety.

Line 271 uses events: any[], which violates the TypeScript-first codebase requirement. Define a specific event type instead:

Typed alternative
+      type ObservabilityEvent = {
+        type: string;
+        span?: {
+          name?: string;
+          attributes: Record<string, unknown>;
+          status?: { code: SpanStatusCode };
+        };
+      };
-      const events: any[] = [];
+      const events: ObservabilityEvent[] = [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent-observability.spec.ts` around lines 271 - 274,
Replace the untyped events: any[] with a concrete event type: define or import a
WebSocketEvent (or existing event interface used by WebSocketEventEmitter) and
declare events as WebSocketEvent[]; also type the callback parameter in
WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => ...) to accept
WebSocketEvent so the pushed values are type-checked against the emitter's event
shape (reference symbols: events, WebSocketEventEmitter.getInstance(),
onWebSocketEvent).


const tool = new Tool({
name: "echo_tool",
description: "Echo tool",
parameters: z.object({ value: z.string() }),
});
mockModel.doGenerate = async () => ({
finishReason: makeFinishReason("tool-calls"),
usage: makeProviderUsage(10, 20),
content: [],
toolCalls: [
{
toolCallId: mockId(),
toolName: "echo_tool",
args: { value: "hello" },
},
],
warnings: [],
logprobs: undefined,
providerMetadata: makeOpenRouterProviderMetadata(),
});

const agent = new Agent({
name: "cost-agent-structured-output-error",
purpose: "Testing provider cost observability on structured output failures",
instructions: "You are a cost test agent",
model: mockModel as any,
observability,
maxRetries: 0,
tools: [tool],
});

await expect(
agent.generateText("Track cost", {
output: ai.Output.object({
schema: z.object({
message: z.string(),
}),
}),
}),
).rejects.toThrow("Structured output was requested but no final output was generated");

const endSpans = events
.filter((event) => event.type === "span:end")
.map((event) => event.span);

const llmSpan = endSpans.find(
(span) =>
span.attributes["span.type"] === "llm" &&
span.attributes["llm.operation"] === "generateText",
);
expect(llmSpan).toBeDefined();
expect(llmSpan.status.code).toBe(SpanStatusCode.ERROR);
expect(llmSpan.attributes["usage.cost"]).toBe(0.0012);

const rootSpan = endSpans.find(
(span) =>
span.name === "cost-agent-structured-output-error" &&
span.attributes["entity.type"] === "agent" &&
span.attributes["span.type"] !== "llm",
);
expect(rootSpan).toBeDefined();
expect(rootSpan.status.code).toBe(SpanStatusCode.ERROR);
expect(rootSpan.attributes["usage.cost"]).toBe(0.0012);
expect(rootSpan.attributes["usage.is_byok"]).toBe(true);

unsubscribe();
});

it("should handle errors and set error status", async () => {
const events: any[] = [];
const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => {
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/agent/agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,8 @@ Use pandas and summarize findings.`.split("\n"),
});

it("should throw a descriptive error when structured output is missing", async () => {
const onEnd = vi.fn();
const onError = vi.fn();
const tool = new Tool({
name: "echo_tool",
description: "Echo tool",
Expand All @@ -814,6 +816,7 @@ Use pandas and summarize findings.`.split("\n"),
model: mockModel as any,
tools: [tool],
maxRetries: 0,
hooks: { onEnd, onError },
});

const toolCall = {
Expand Down Expand Up @@ -852,6 +855,14 @@ Use pandas and summarize findings.`.split("\n"),
timestamp: new Date(),
messages: [],
},
providerMetadata: {
openrouter: {
usage: {
cost: 0.0012,
isByok: true,
},
},
},
steps: [
{
text: "Tool call completed.",
Expand Down Expand Up @@ -906,6 +917,33 @@ Use pandas and summarize findings.`.split("\n"),
stage: "response_parsing",
code: "STRUCTURED_OUTPUT_NOT_GENERATED",
});
expect(onEnd).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "STRUCTURED_OUTPUT_NOT_GENERATED",
stage: "response_parsing",
metadata: expect.objectContaining({
finishReason: "tool-calls",
usage: expect.objectContaining({
inputTokens: 12,
outputTokens: 6,
totalTokens: 18,
}),
providerMetadata: expect.objectContaining({
openrouter: expect.any(Object),
}),
}),
}),
}),
);
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "STRUCTURED_OUTPUT_NOT_GENERATED",
stage: "response_parsing",
}),
}),
);
});
});

Expand Down
74 changes: 69 additions & 5 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,40 @@ const extractOpenRouterUsageCost = (providerMetadata: unknown): OpenRouterUsageC
return Object.values(result).some((value) => value !== undefined) ? result : undefined;
};

type GenerationErrorDetails = {
usage?: LanguageModelUsage;
providerMetadata?: unknown;
finishReason?: string;
};

const toLanguageModelUsage = (value: unknown): LanguageModelUsage | undefined =>
isPlainObject(value) ? (value as LanguageModelUsage) : undefined;

const extractGenerationErrorDetails = (error: unknown): GenerationErrorDetails => {
const metadata = isRecord(error) && isPlainObject(error.metadata) ? error.metadata : undefined;
const originalError = isRecord(error) ? error.originalError : undefined;

const usage = firstDefined(
isRecord(error) ? toLanguageModelUsage(error.usage) : undefined,
metadata ? toLanguageModelUsage(metadata.usage) : undefined,
isRecord(originalError) ? toLanguageModelUsage(originalError.usage) : undefined,
);

const providerMetadata = firstDefined(
metadata?.providerMetadata,
isRecord(error) ? error.providerMetadata : undefined,
isRecord(originalError) ? originalError.providerMetadata : undefined,
);

const finishReason = firstNonBlank(
isRecord(error) ? error.finishReason : undefined,
metadata?.finishReason,
isRecord(originalError) ? originalError.finishReason : undefined,
);

return { usage, providerMetadata, finishReason };
};

const isAssistantContentPart = (value: unknown): boolean => {
if (!isRecord(value)) {
return false;
Expand Down Expand Up @@ -1268,7 +1302,7 @@ export class Agent {
}),
);

this.ensureStructuredOutputGenerated({
await this.ensureStructuredOutputGenerated({
result: response,
output,
tools,
Expand All @@ -1286,7 +1320,13 @@ export class Agent {

return response;
} catch (error) {
finalizeLLMSpan(SpanStatusCode.ERROR, { message: (error as Error).message });
const errorDetails = extractGenerationErrorDetails(error);
finalizeLLMSpan(SpanStatusCode.ERROR, {
message: (error as Error).message,
usage: errorDetails.usage,
finishReason: errorDetails.finishReason,
providerMetadata: errorDetails.providerMetadata,
});
throw error;
}
},
Expand Down Expand Up @@ -3541,15 +3581,15 @@ export class Agent {
};
}

private ensureStructuredOutputGenerated<
private async ensureStructuredOutputGenerated<
TOOLS extends ToolSet,
OUTPUT extends OutputSpec,
>(params: {
result: GenerateTextResult<TOOLS, OUTPUT>;
output: OUTPUT | undefined;
tools: Record<string, any>;
maxSteps: number;
}): void {
}): Promise<void> {
const { result, output, tools, maxSteps } = params;
if (!output) {
return;
Expand All @@ -3571,6 +3611,13 @@ export class Agent {
const stepCount = result.steps?.length ?? 0;
const finishReason = result.finishReason ?? "unknown";
const reachedMaxSteps = stepCount >= maxSteps;
const providerMetadata = (result as { providerMetadata?: unknown }).providerMetadata;
const providerUsage = result.usage ? await Promise.resolve(result.usage) : undefined;
const usageForFinish = resolveFinishUsage({
providerMetadata,
usage: providerUsage,
totalUsage: (result as { totalUsage?: LanguageModelUsage }).totalUsage,
});

const guidance =
configuredToolCount > 0 || toolCalls.length > 0
Expand All @@ -3593,6 +3640,11 @@ export class Agent {
maxSteps,
configuredToolCount,
toolCallCount: toolCalls.length,
usage: usageForFinish ? JSON.parse(safeStringify(usageForFinish)) : undefined,
providerMetadata:
providerMetadata !== undefined
? JSON.parse(safeStringify(providerMetadata))
: undefined,
},
},
);
Expand Down Expand Up @@ -7412,7 +7464,19 @@ export class Agent {
throw oc.cancellationError;
}

const voltagentError = createVoltAgentError(error);
const voltagentError = isVoltAgentError(error) ? error : createVoltAgentError(error);
const errorDetails = extractGenerationErrorDetails(voltagentError);

if (errorDetails.usage || errorDetails.providerMetadata !== undefined) {
this.recordRootSpanUsageAndProviderCost(
oc.traceContext,
errorDetails.usage,
errorDetails.providerMetadata,
);
}
if (errorDetails.finishReason) {
oc.traceContext.setFinishReason(errorDetails.finishReason);
}

oc.traceContext.end("error", error);

Expand Down
Loading
Loading