fix(ai): handle markdown-fenced JSON in analyzeResume response (#3142)

Some providers (notably Anthropic via proxies) wrap JSON output in
markdown code fences (```json ... ```), causing Output.object to
throw NoObjectGeneratedError / JSONParseError.

Replace Output.object with manual JSON boundary extraction that works
regardless of fencing. Also propagate the original AISDKError as cause
in throwAiProviderGatewayError for better diagnostics.
This commit is contained in:
sdeonvacation
2026-06-17 16:57:06 +05:30
committed by GitHub
parent bc498449d3
commit 7275da7303
2 changed files with 22 additions and 11 deletions
+7 -6
View File
@@ -23,8 +23,9 @@ function isCredentialEncryptionUnavailable(error: unknown): boolean {
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */
function throwAiProviderGatewayError(cause?: unknown): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause });
}
function throwAiProviderConfigError(): never {
@@ -85,7 +86,7 @@ export const aiRouter = {
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
@@ -131,7 +132,7 @@ export const aiRouter = {
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
@@ -174,7 +175,7 @@ export const aiRouter = {
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
throw error;
}
}),
@@ -228,7 +229,7 @@ export const aiRouter = {
} catch (error) {
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume analysis structure",
+15 -5
View File
@@ -28,7 +28,7 @@ import {
} from "@reactive-resume/ai/tools/patch-proposal";
import { aiProviderSchema } from "@reactive-resume/ai/types";
import { applyResumePatches } from "@reactive-resume/resume/patch";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { supportsProviderNativeWebSearch } from "./capabilities";
import { resolveAiBaseUrl } from "./url-policy";
@@ -248,28 +248,38 @@ function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
}
/** Sends resume data to the AI provider and returns a structured analysis, parsing raw JSON from the response text. */
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
const model = getModel(input);
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
const result = await generateText({
model,
output: Output.object({ schema: resumeAnalysisOutputSchema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content:
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions. Return ONLY raw JSON, no markdown fences or explanations.",
},
],
});
if (result.output == null) {
const text = result.text;
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
const candidate = fenceMatch?.[1] ?? text;
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace === -1 || lastBrace === -1 || lastBrace < firstBrace) {
throw new Error("AI returned no structured analysis output.");
}
return resumeAnalysisSchema.parse(result.output);
const jsonString = candidate.substring(firstBrace, lastBrace + 1);
const parsed = JSON.parse(jsonString);
return resumeAnalysisSchema.parse(parsed);
}
export const aiService = {