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
121 changes: 121 additions & 0 deletions src/providers/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* JSON structural delimiters that may legitimately follow a closing string
* quote. Used to distinguish a real closing quote from an unescaped internal
* quote when repairing model output.
*/
const JSON_STRUCTURAL_DELIMITERS = new Set([',', '}', ']', ':']);

function isJsonWhitespace(char: string): boolean {
return char === ' ' || char === '\t' || char === '\n' || char === '\r';
}

/** Escapes literal control characters as JSON escape sequences. */
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 => {
switch (char) {
case '\n': return '\\n';
case '\t': return '\\t';
case '\r': return '\\r';
case '\b': return '\\b';
case '\f': return '\\f';
default:
return '\\u' + char.charCodeAt(0).toString(16).padStart(4, '0');
}
});
}

/**
* Repairs a JSON string that contains unescaped internal quotes inside string
* values. Walks the text with a lightweight state machine: a quote inside a
* string value that is followed (after optional whitespace) by a structural
* delimiter is treated as the closing quote; any other quote is escaped as
* `\"`. Literal control characters encountered inside a string value are
* escaped too, so inter-token whitespace stays valid.
*/
function escapeInternalQuotes(value: string): string {
let out = '';
let inString = false;
for (let i = 0; i < value.length; i++) {
const char = value[i]!;
if (!inString) {
out += char;
if (char === '"') {
inString = true;
}
continue;
}

// Preserve existing escape sequences verbatim.
if (char === '\\' && i + 1 < value.length) {
out += char + value[i + 1]!;
i++;
continue;
}

if (char === '"') {
let j = i + 1;
while (j < value.length && isJsonWhitespace(value[j]!)) {
j++;
}
const isClosingQuote = j >= value.length || JSON_STRUCTURAL_DELIMITERS.has(value[j]!);
if (isClosingQuote) {
out += '"';
inString = false;
} else {
out += '\\"';
}
continue;
}

const codePoint = char.charCodeAt(0);
if (codePoint < 0x20) {
out += escapeControlChars(char);
continue;
}

out += char;
}
return out;
}

/** Tries several repair strategies on a JSON-ish string; returns the parsed value or undefined. */
function repairJsonString(value: string): unknown {
const attempts = [value, escapeControlChars(value), escapeInternalQuotes(value)];
for (const candidate of attempts) {
try {
return JSON.parse(candidate);
} catch {
// try next strategy
}
}
return undefined;
}

function looksLikeJson(value: string): boolean {
const trimmed = value.trimStart();
const startsJson = trimmed.startsWith('[') || trimmed.startsWith('{');
const endsJson = trimmed.endsWith(']') || trimmed.endsWith('}');
return startsJson && endsJson;
}

/** Recursively repairs any string values that look like double encoded JSON. */
export function repairNestedJson(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(repairNestedJson);
}
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
result[key] = repairNestedJson(child);
}
return result;
}
if (typeof value === 'string' && looksLikeJson(value)) {
const repaired = repairJsonString(value);
if (repaired !== undefined) {
return repairNestedJson(repaired);
}
}
return value;
}
71 changes: 59 additions & 12 deletions src/providers/vercel-ai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DefaultRequestBuilder, RequestBuilder } from './request-builder';
import { createNoopLogger, type Logger } from '../logging/logger';
import type { AIExecutionContext, AIObservability } from '../observability/ai-observability';
import { handleUnknownError } from '../errors';
import { repairNestedJson } from './utils';

/**
* Conservative default step cap for a single bounded tool-calling run when the
Expand Down Expand Up @@ -81,6 +82,8 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
}
}

let usage: { inputTokens: number; outputTokens: number } | undefined;

try {
const reviewer = this.extractContextValue(context, 'reviewerName', 'reviewer');
const rule = this.extractContextValue(context, 'ruleName', 'rule');
Expand Down Expand Up @@ -118,7 +121,7 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
});
}

const usage = result.usage ? {
usage = result.usage ? {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
} : undefined;
Expand All @@ -136,11 +139,9 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
}
return llmResult;
} catch (e: unknown) {
if (NoObjectGeneratedError.isInstance(e)) {
const rawText = e instanceof Error && 'text' in e ? String(e.text) : 'unknown';
throw new Error(
`LLM failed to generate valid structured output. Raw text: ${rawText}`
);
const repaired = this.handleNoObjectGenerated(e, zodSchema, usage);
if (repaired !== undefined) {
return { data: repaired.data as T, ...(repaired.usage ? { usage: repaired.usage } : {}) };
}
const err = e instanceof Error ? e : new Error(String(e));
throw new Error(`Vercel AI SDK call failed: ${err.message}`);
Expand Down Expand Up @@ -180,6 +181,8 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
});
}

let usage: { inputTokens: number; outputTokens: number } | undefined;

try {
const result = await generateText({
model: this.config.model,
Expand Down Expand Up @@ -221,7 +224,7 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
});
}

const usage = result.usage
usage = result.usage
? {
inputTokens: result.usage.inputTokens ?? 0,
outputTokens: result.usage.outputTokens ?? 0,
Expand All @@ -241,11 +244,9 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
}
return llmResult;
} catch (e: unknown) {
if (NoObjectGeneratedError.isInstance(e)) {
const rawText = e instanceof Error && 'text' in e ? String(e.text) : 'unknown';
throw new Error(
`LLM failed to generate valid structured output. Raw text: ${rawText}`
);
const repaired = this.handleNoObjectGenerated(e, zodSchema, usage);
if (repaired !== undefined) {
return { data: repaired.data as T, ...(repaired.usage ? { usage: repaired.usage } : {}) };
}
const err = e instanceof Error ? e : new Error(String(e));
throw new Error(`Vercel AI SDK tool-calling call failed: ${err.message}`);
Expand All @@ -269,6 +270,52 @@ export class VercelAIProvider implements LLMProvider, ToolCallingModelClient {
}
}

/**
* Shared handling for NoObjectGeneratedError: attempts structured-output
* repair on the failed call's raw text, logging a debug line on success.
* Returns `{ data, usage? }` when repair succeeds, rethrows when repair is
* not possible, and returns `undefined` when `e` is some other error so the
* caller can rethrow with its own call-failed framing.
*/
private handleNoObjectGenerated(
e: unknown,
zodSchema: z.ZodType,
usage: { inputTokens: number; outputTokens: number } | undefined,
): { data: unknown; usage?: { inputTokens: number; outputTokens: number } } | undefined {
if (!NoObjectGeneratedError.isInstance(e)) {
return undefined;
}
const rawText = e instanceof Error && 'text' in e ? String(e.text) : 'unknown';
const repaired = this.tryRepairStructuredOutput(rawText, zodSchema);
if (repaired !== undefined) {
this.logger.debug('[vectorlint] Structured output repaired after NoObjectGeneratedError');
return { data: repaired, ...(usage ? { usage } : {}) };
}
throw new Error(
`LLM failed to generate valid structured output. Raw text: ${rawText.slice(0, 500)}`
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* Attempts to repair a raw text payload that failed structured generation.
* Models sometimes emit a field as a JSON string (double encoding) with
* unescaped internal quotes; this walks the parsed object, repairs such
* values, and validates the result against the schema. Returns the validated
* data, or `undefined` if repair is not possible.
*/
private tryRepairStructuredOutput(rawText: string, zodSchema: z.ZodType): unknown {
let outer: unknown;
try {
outer = JSON.parse(rawText);
} catch {
return undefined;
}

const repaired = repairNestedJson(outer);
const result = zodSchema.safeParse(repaired);
return result.success ? result.data : undefined;
}

private extractContextValue(
context: import('./request-builder').ReviewCallContext | undefined,
...keys: string[]
Expand Down
Loading
Loading