Skip to content

feat(providers): repair double-encoded structured output from LLMs - #104

Merged
oshorefueled merged 2 commits into
mainfrom
fix/structured-output-repair
Aug 5, 2026
Merged

feat(providers): repair double-encoded structured output from LLMs#104
oshorefueled merged 2 commits into
mainfrom
fix/structured-output-repair

Conversation

@oshorefueled

@oshorefueled oshorefueled commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Why

When reviewing content against complex schemas, some LLMs (observed with Anthropic models via Bedrock) emit nested fields — like violations — as stringified JSON with unescaped internal quotes. The Vercel AI SDK's structured output parser throws NoObjectGeneratedError, and VectorLint surfaces this as an unrecoverable crash. Reviews against affected models cannot complete.

What this PR covers

  • Structured output calls that previously crashed on double-encoded/unescaped-JSON model responses now repair the output and continue reviewing.
  • Both structured output entry points (runPromptStructured and runWithTools) recover from NoObjectGeneratedError with identical repair logic.

Scope

In scope

  • src/providers/vercel-ai-provider.ts — repair helpers, shared handleNoObjectGenerated method, wiring in both catch blocks.
  • tests/vercel-ai-provider-repair.test.ts — 6 tests covering repair paths and fail-safe edge cases.

Out of scope

  • Prompt-level mitigation (schema simplification, format instructions).
  • SDK-native repairText integration (requires investigation against SDK version compatibility).

Behavior Impact

  • User-facing changes: Yes — reviews that previously crashed with NoObjectGeneratedError on affected models now complete successfully. No configuration changes required.
  • Breaking changes: No.
  • Operational impact: None — repair runs transparently in the provider layer.

Risk and Mitigations

  • Risk level: Low
  • Primary risks:
    • The state-machine quote-escaping could misclassify a real closing quote as an internal quote on unusual payloads, producing incorrect data.
    • Repair masks a model quality issue that might be better addressed at the prompt or schema level.
  • Mitigations:
    • Repaired output is validated against the Zod schema before returning; unrepairable payloads still throw.
    • Debug-level logging ([vectorlint] Structured output repaired after NoObjectGeneratedError) makes every repair observable.
    • Fail-safe edge cases tested: unparseable outer JSON rethrows, trailing backslash inside string values rethrows.
  • Rollback plan: Revert this commit — the provider returns to throwing on NoObjectGeneratedError.

API / Contract / Schema Changes

  • None.

How to test / verify

Checks run

  • npx vitest run → 353 passed (55 files)
  • npx tsc --noEmit → clean

Manual verification

  • Configure a Bedrock provider with an Anthropic model (e.g. us.anthropic.claude-haiku-4-5-20251001-v1:0) in ~/.vectorlint/config.toml.
  • Run npx tsx src/index.ts <test-doc.md> --model-call single against a document that triggers a complex schema response.
  • Before this PR: crashes with NoObjectGeneratedError. After: completes review with findings, quality scores, and token usage.

Future improvements

  • Investigate whether the Vercel AI SDK's repairText option can replace the custom repair layer.
  • Explore prompt/schema-level mitigation to prevent the double-encoding at the source.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of malformed structured JSON, including nested encoding, unescaped quotes, and control characters.
    • Structured-output and tool-calling responses can now be repaired and schema-validated when possible.
    • Usage metrics are preserved during structured-output recovery attempts.
    • Unrecoverable or invalid responses continue to return the standard structured-output error.

- Add state-machine repair layer for NoObjectGeneratedError in
  VercelAIProvider, covering both runPromptStructured and runWithTools
- Handle models that emit nested fields as stringified JSON with
  unescaped internal quotes (observed with Anthropic via Bedrock on
  complex schemas)
- Extract handleNoObjectGenerated to shared private method so both
  call paths get identical repair logic with distinct error framing
- Add 6 tests covering happy-path repair through both entrypoints,
  usage preservation, schema-fail rethrow, unparseable JSON rethrow,
  and trailing-backslash fail-safe
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@oshorefueled, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: caf54074-8f72-48d6-bdbb-6acc921eba6d

📥 Commits

Reviewing files that changed from the base of the PR and between c8fc5b3 and f55a18f.

📒 Files selected for processing (3)
  • src/providers/utils.ts
  • src/providers/vercel-ai-provider.ts
  • tests/vercel-ai-provider-repair.test.ts
📝 Walkthrough

Walkthrough

The Vercel AI provider now repairs recoverable JSON output, validates repaired values against generated Zod schemas, and preserves usage metrics for structured-output and tool-calling failures. Tests cover successful recovery and unrecoverable error paths.

Changes

Vercel AI structured-output repair

Layer / File(s) Summary
JSON repair and validation pipeline
src/providers/vercel-ai-provider.ts
The provider repairs nested JSON strings, invalid quotes, control characters, and malformed trailing backslashes. Repaired values undergo generated Zod schema validation.
Provider recovery integration
src/providers/vercel-ai-provider.ts
Structured-output and tool-calling paths use shared recovery handling and retain captured usage metrics.
Repair and failure-path tests
tests/vercel-ai-provider-repair.test.ts
Vitest tests cover both entry points, successful repairs, usage behavior, schema failures, invalid JSON, and malformed input.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AI_SDK
  participant VercelAIProvider
  participant JSONRepair
  participant GeneratedZodSchema
  AI_SDK->>VercelAIProvider: return NoObjectGeneratedError
  VercelAIProvider->>JSONRepair: parse and repair raw JSON
  JSONRepair-->>VercelAIProvider: repaired value
  VercelAIProvider->>GeneratedZodSchema: validate repaired value
  GeneratedZodSchema-->>VercelAIProvider: validated output or failure
  VercelAIProvider-->>AI_SDK: repaired result or original error
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: repairing double-encoded structured output from LLMs.
Description check ✅ Passed The description clearly covers the change, rationale, scope, risks, testing, and behavior impact, although it does not use all template headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 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-repair

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.

Comment thread src/providers/vercel-ai-provider.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
tests/vercel-ai-provider-repair.test.ts (1)

68-89: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the duplicated schema fixture.

This 22-line schema literal is repeated in all six tests, at lines 68-89, 127-148, 175-196, 220-241, 270-291, and 321-342. It never varies. Extracting it to a module-level constant removes about 130 duplicated lines and makes each test's distinguishing input visible.

♻️ Proposed shared fixture

Define the constant once, after MOCK_MODEL:

const REVIEW_SCHEMA = {
  name: 'submit_review',
  schema: {
    properties: {
      score: { type: 'number' },
      violations: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            line: { type: 'number' },
            rule_quote: { type: 'string' },
            severity: { type: 'string' },
          },
          required: ['line', 'rule_quote', 'severity'],
        },
      },
    },
    required: ['score', 'violations'],
    type: 'object',
  },
} as const;

Then replace each local declaration:

     const provider = new VercelAIProvider(config);
-    const schema = {
-      name: 'submit_review',
-      schema: {
-        properties: {
-          score: { type: 'number' },
-          violations: {
-            type: 'array',
-            items: {
-              type: 'object',
-              properties: {
-                line: { type: 'number' },
-                rule_quote: { type: 'string' },
-                severity: { type: 'string' },
-              },
-              required: ['line', 'rule_quote', 'severity'],
-            },
-          },
-        },
-        required: ['score', 'violations'],
-        type: 'object',
-      },
-    };
 
     const result = await provider.runWithTools({
       systemPrompt: 'system',
       prompt: 'prompt',
       tools: {},
-      schema,
+      schema: REVIEW_SCHEMA,
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/vercel-ai-provider-repair.test.ts` around lines 68 - 89, Extract the
duplicated schema fixture into a module-level REVIEW_SCHEMA constant immediately
after MOCK_MODEL, preserving its structure and marking it as immutable. Replace
all six identical local schema declarations in the tests with references to
REVIEW_SCHEMA so each test retains only its distinguishing input.
src/providers/vercel-ai-provider.ts (3)

33-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Suppress no-control-regex inline.

ESLint reports an error on this regular expression. The control-character range is intentional here, so the lint run fails on correct code. Add a targeted disable comment.

♻️ Proposed lint suppression
 function escapeControlChars(value: string): string {
+  // eslint-disable-next-line no-control-regex -- control characters are the intended match
   return value.replace(/[\x00-\x1f]/g, char => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/vercel-ai-provider.ts` at line 33, Add a targeted ESLint
suppression for no-control-regex directly on the intentional control-character
regular expression in the value.replace callback, preserving the existing
sanitization behavior and avoiding broader lint-rule disables.

Source: Linters/SAST tools


100-111: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplify the return type to unknown.

unknown | undefined collapses to unknown, because undefined is already assignable to unknown. The annotation suggests a discriminated result that the type system does not provide. The same pattern appears on tryRepairStructuredOutput at line 426.

Consider a discriminated result instead, so callers cannot confuse a repaired undefined with a failure.

♻️ Proposed type change
-function repairJsonString(value: string): unknown | undefined {
+function repairJsonString(value: string): { ok: true; value: unknown } | { ok: false } {
   const attempts = [value, escapeControlChars(value), escapeInternalQuotes(value)];
   for (const candidate of attempts) {
     try {
-      return JSON.parse(candidate);
+      return { ok: true, value: JSON.parse(candidate) };
     } catch {
       // try next strategy
     }
   }
-  return undefined;
+  return { ok: false };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/vercel-ai-provider.ts` around lines 100 - 111, Update
repairJsonString and tryRepairStructuredOutput to return unknown rather than the
redundant unknown | undefined annotation. Preserve their existing runtime
behavior, and if callers must distinguish a successfully parsed undefined value
from repair failure, introduce an explicit discriminated result instead of
relying on the return type.

400-417: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Throw a domain error here.

Reuse the repository error hierarchy for this failure instead of throw new Error(...). A VectorlintError/ConfigError-based domain error fits better than a bare Error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/vercel-ai-provider.ts` around lines 400 - 417, Update
handleNoObjectGenerated to throw the repository’s domain-specific
VectorlintError or ConfigError instead of a bare Error when structured-output
repair fails. Preserve the existing descriptive message and rawText details, and
follow the established error hierarchy and constructor usage elsewhere in the
provider.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/vercel-ai-provider.ts`:
- Around line 414-416: Update the structured-output failure throw near the
rawText interpolation to truncate rawText to 500 characters, matching the
existing truncation used by the sibling failure paths in this file. Keep the
error context and message otherwise unchanged.

In `@tests/vercel-ai-provider-repair.test.ts`:
- Around line 110-124: Rename the test around MOCK_GENERATE_TEXT to describe
that usage remains undefined after repair, matching the result. Remove the
unused Error intersection cast and usage property declaration from the
NoObjectGeneratedError setup. Strengthen the assertions to verify the repair
path actually executed, while preserving the existing undefined result.

---

Nitpick comments:
In `@src/providers/vercel-ai-provider.ts`:
- Line 33: Add a targeted ESLint suppression for no-control-regex directly on
the intentional control-character regular expression in the value.replace
callback, preserving the existing sanitization behavior and avoiding broader
lint-rule disables.
- Around line 100-111: Update repairJsonString and tryRepairStructuredOutput to
return unknown rather than the redundant unknown | undefined annotation.
Preserve their existing runtime behavior, and if callers must distinguish a
successfully parsed undefined value from repair failure, introduce an explicit
discriminated result instead of relying on the return type.
- Around line 400-417: Update handleNoObjectGenerated to throw the repository’s
domain-specific VectorlintError or ConfigError instead of a bare Error when
structured-output repair fails. Preserve the existing descriptive message and
rawText details, and follow the established error hierarchy and constructor
usage elsewhere in the provider.

In `@tests/vercel-ai-provider-repair.test.ts`:
- Around line 68-89: Extract the duplicated schema fixture into a module-level
REVIEW_SCHEMA constant immediately after MOCK_MODEL, preserving its structure
and marking it as immutable. Replace all six identical local schema declarations
in the tests with references to REVIEW_SCHEMA so each test retains only its
distinguishing input.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd205771-f9c4-474c-aa2e-1001742fe76d

📥 Commits

Reviewing files that changed from the base of the PR and between aa6e792 and c8fc5b3.

📒 Files selected for processing (2)
  • src/providers/vercel-ai-provider.ts
  • tests/vercel-ai-provider-repair.test.ts

Comment thread src/providers/vercel-ai-provider.ts
Comment thread tests/vercel-ai-provider-repair.test.ts Outdated
- Move JSON repair functions to src/providers/utils.ts, keeping
  vercel-ai-provider.ts focused on provider logic
- Truncate rawText in error message to 500 chars, matching sibling
  failure paths
- Simplify return types from unknown | undefined to unknown
- Add eslint-disable for intentional control-char regex
- Extract duplicated schema fixture to module-level REVIEW_SCHEMA
- Fix misleading test name and remove dead Error cast
- Strengthen usage test with data assertion
@oshorefueled
oshorefueled merged commit d8a3165 into main Aug 5, 2026
5 checks passed
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