Skip to content

fix(core): preserve usage and cost metadata on structured output failures#1163

Merged
omeraplak merged 3 commits intomainfrom
fix/structured-output-cost-metadata
Mar 19, 2026
Merged

fix(core): preserve usage and cost metadata on structured output failures#1163
omeraplak merged 3 commits intomainfrom
fix/structured-output-cost-metadata

Conversation

@omeraplak
Copy link
Copy Markdown
Member

@omeraplak omeraplak commented Mar 19, 2026

PR Checklist

Please check if your PR fulfills the following requirements:

Bugs / Features

What is the current behavior?

When generateText gets a successful model response but fails to produce structured output,
VoltAgent throws a structured output error without preserving the resolved usage or
provider-reported cost metadata on the error path.

What is the new behavior?

Structured output failures after a successful model response now preserve usage, finish reason,
and provider metadata in VoltAgentError.metadata, and the same provider cost metadata is kept
on the failed LLM/root observability spans.

fixes (issue)

N/A

Notes for reviewers

  • Added coverage for the structured output failure path in both agent.spec.ts and
    agent-observability.spec.ts.
  • Verified with:
    pnpm vitest run src/agent/agent.spec.ts src/agent/agent-observability.spec.ts

Summary by cubic

Preserves usage, finish reason, and provider cost when structured output fails after a successful model call in @voltagent/core. Also fixes the structured output check to be async so spans and hooks record the correct cost.

  • Bug Fixes

    • Preserve usage, finish reason, and provider metadata on structured output errors (VoltAgentError.metadata).
    • Finalize failed LLM and root spans with the same usage/cost and finish reason, extracted from nested errors and provider metadata.
    • Make structured output validation async and await it; propagate metadata to onEnd/onError hooks. Tests cover failure path and observability.
  • Dependencies

    • Updated lockfile; bumped tsx to 4.21.0 across workspaces (no runtime changes).

Written for commit a2b2017. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • When structured output generation fails, usage totals, finish reason, and provider cost/metadata are preserved and exposed to error handlers and traces for consistent observability.
  • Tests
    • Added and updated tests to verify preserved usage, finish reason, provider metadata, and error hook/tracing behavior on structured-output failures.
  • Chores
    • Added a release note entry documenting the fix for a patch release.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: a2b2017

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@voltagent/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 19, 2026

Deploying voltagent with  Cloudflare Pages  Cloudflare Pages

Latest commit: a2b2017
Status: ✅  Deploy successful!
Preview URL: https://7812c373.voltagent.pages.dev
Branch Preview URL: https://fix-structured-output-cost-m.voltagent.pages.dev

View logs

@joggrbot

This comment has been minimized.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Agent.generateText now preserves and propagates resolved usage, finish reason, and provider cost/metadata when structured-output generation fails, exposing this metadata via VoltAgentError.metadata and including it in observability traces and error hooks.

Changes

Cohort / File(s) Summary
Changeset Documentation
\.changeset/soft-cost-errors.md
Added changelog entry describing preservation of usage, finish reason, and provider cost on structured-output error paths (exposed via VoltAgentError.metadata).
Agent Core Logic
packages/core/src/agent/agent.ts
Added GenerationErrorDetails and helpers to extract normalized usage, providerMetadata, and finishReason; made ensureStructuredOutputGenerated async and populate error metadata; updated generateText to await validation and attach extracted usage/finishReason/providerMetadata to LLM/root spans and errors; improved handleError to avoid double-wrapping and to set trace/root-span usage and finish reason from extracted details.
Observability Tests
packages/core/src/agent/agent-observability.spec.ts
New test verifying structured-output missing path preserves provider cost/usage in LLM and root spans (ends with SpanStatusCode.ERROR) and that usage fields are preserved across spans.
Agent Tests
packages/core/src/agent/agent.spec.ts
Updated structured-output-missing test to register onEnd/onError hooks and assert they receive error payloads including code: "STRUCTURED_OUTPUT_NOT_GENERATED", stage: "response_parsing", finishReason, usage, and providerMetadata.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Agent
  participant Model
  participant Tracing
  participant Hooks

  Client->>Agent: generateText(output: structured)
  Agent->>Model: doGenerate(...)
  Model-->>Agent: success (finishReason: tool-calls, providerMetadata, usage)
  Agent->>Agent: ensureStructuredOutputGenerated (await)
  alt no structured output produced
    Agent->>Tracing: end llm span with error + usage/finishReason/providerMetadata
    Agent->>Tracing: set root span usage/finishReason/providerMetadata
    Agent->>Hooks: onError(error with VoltAgentError.metadata)
    Agent->>Hooks: onEnd(error payload with metadata)
    Agent-->>Client: reject with VoltAgentError
  else structured output produced
    Agent-->>Client: resolve with structured output
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 Hopping through logs with a careful twitch,

I tuck away cost and the finish which,
When outputs hide and errors prance,
Metadata stays for each observability glance,
A little hop, no data lost—what a stitch! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preserving usage and cost metadata during structured output failures.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description check ✅ Passed The PR description comprehensively addresses all required template sections with clear before/after behavior, test coverage details, and changeset documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/structured-output-cost-metadata
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can use TruffleHog to scan for secrets in your code with verification capabilities.

Add a TruffleHog config file (e.g. trufflehog-config.yml, trufflehog.yml) to your project to customize detectors and scanning behavior. The tool runs only when a config file is present.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 4 files

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
packages/core/src/agent/agent.spec.ts (1)

939-946: Also assert preserved metadata on onError payload.

onEnd validates metadata propagation, but onError currently checks only code/stage. Adding metadata checks here will guard the full error-hook contract.

Suggested assertion hardening
       expect(onError).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.objectContaining({
+                  usage: expect.objectContaining({
+                    cost: 0.0012,
+                    isByok: true,
+                  }),
+                }),
+              }),
+            }),
           }),
         }),
       );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent.spec.ts` around lines 939 - 946, The test
currently asserts only code/stage on the onError payload; update the expectation
for onError (the expect(onError).toHaveBeenCalledWith(...) block) to also assert
that metadata is preserved by adding an objectContaining for the metadata field
(e.g., expect.objectContaining({ metadata: expect.objectContaining({ ...the same
keys/assertions used in the onEnd check... }) })). Locate the onError assertion
in agent.spec.ts and mirror the metadata assertions used in the onEnd validation
so the error hook contract is fully covered.
packages/core/src/agent/agent-observability.spec.ts (1)

327-339: Assert full provider cost metadata on failure-path spans.

This test currently checks usage.cost on LLM and root spans, but not all propagated fields. Add usage.is_byok and usage.cost_details.* assertions to lock in complete metadata preservation.

🤖 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 327 - 339,
Update the test to assert that the full provider cost metadata is preserved on
failure-path spans by adding assertions for usage.is_byok and usage.cost_details
on both the LLM span (llmSpan) and the root agent span (rootSpan) — specifically
check that llmSpan.attributes["usage.is_byok"] is true and
llmSpan.attributes["usage.cost_details"] matches the expected cost_details
object (and likewise for rootSpan.attributes["usage.is_byok"] and
rootSpan.attributes["usage.cost_details"]) so the test locks in complete
metadata preservation across spans.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/agent/agent-observability.spec.ts`:
- Around line 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).

---

Nitpick comments:
In `@packages/core/src/agent/agent-observability.spec.ts`:
- Around line 327-339: Update the test to assert that the full provider cost
metadata is preserved on failure-path spans by adding assertions for
usage.is_byok and usage.cost_details on both the LLM span (llmSpan) and the root
agent span (rootSpan) — specifically check that
llmSpan.attributes["usage.is_byok"] is true and
llmSpan.attributes["usage.cost_details"] matches the expected cost_details
object (and likewise for rootSpan.attributes["usage.is_byok"] and
rootSpan.attributes["usage.cost_details"]) so the test locks in complete
metadata preservation across spans.

In `@packages/core/src/agent/agent.spec.ts`:
- Around line 939-946: The test currently asserts only code/stage on the onError
payload; update the expectation for onError (the
expect(onError).toHaveBeenCalledWith(...) block) to also assert that metadata is
preserved by adding an objectContaining for the metadata field (e.g.,
expect.objectContaining({ metadata: expect.objectContaining({ ...the same
keys/assertions used in the onEnd check... }) })). Locate the onError assertion
in agent.spec.ts and mirror the metadata assertions used in the onEnd validation
so the error hook contract is fully covered.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fa53e0e1-f315-4203-9fd2-39806073377e

📥 Commits

Reviewing files that changed from the base of the PR and between bc59b3e and 8ad7a89.

📒 Files selected for processing (4)
  • .changeset/soft-cost-errors.md
  • packages/core/src/agent/agent-observability.spec.ts
  • packages/core/src/agent/agent.spec.ts
  • packages/core/src/agent/agent.ts

Comment on lines +271 to +274
const events: any[] = [];
const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => {
events.push(event);
});
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).

@omeraplak omeraplak merged commit 6f14c4d into main Mar 19, 2026
24 checks passed
@omeraplak omeraplak deleted the fix/structured-output-cost-metadata branch March 19, 2026 20:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant