diff --git a/docs/current-handoff.md b/docs/current-handoff.md index 8d959dc..c2baa2d 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -151,6 +151,11 @@ - An experiment-only reconstruction-only seam now returns after initial reconstruction and graph construction, before `determineGraphBackedQuestion`. Normal production/default `startCase()` behavior remains unchanged; explicit OpenAI helper selection guarantees one selected reconstruction provider call and zero downstream question-generation calls. - Zero live calls occurred while implementing and verifying this seam. Next boundary: exactly one live Terra reconstruction with no retry. +## OpenAI Responses text extraction + +- The first reconstruction-only Terra request reached inference successfully, but native-fetch parsing relied on SDK-only `output_text` and could not extract the raw response. The provider now reads documented `output[].content[].output_text` parts in response order while retaining the convenience-property path. +- No tool-call assumption was introduced; canonical schema, prompt, transport compatibility, and reasoning remain unchanged. Zero live calls occurred during this correction; next boundary is exactly one live Terra reconstruction with no retry and no Ollama call. + ## Current product architecture Three distinct routes, not a single page: diff --git a/lib/llm/provider.js b/lib/llm/provider.js index 6fc6489..fd8db45 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -69,6 +69,32 @@ function recoverJson(raw) { throw new SyntaxError("Model output could not be parsed as JSON: " + result.slice(0, 300) + "..."); } +function extractOpenAIResponseText(data) { + if (typeof data.output_text === "string" && data.output_text.length > 0) { + return data.output_text; + } + + const outputText = (data.output ?? []).flatMap((item) => + item?.type === "message" + ? (item.content ?? []).flatMap((part) => + part?.type === "output_text" && typeof part.text === "string" + ? [part.text] + : [], + ) + : [], + ); + if (outputText.length > 0) return outputText.join(""); + + const outputTypes = (data.output ?? []).map((item) => item?.type ?? "unknown"); + const contentTypes = (data.output ?? []).flatMap((item) => + (item?.content ?? []).map((part) => part?.type ?? "unknown"), + ); + const refusal = contentTypes.includes("refusal"); + throw new Error( + `OpenAI Responses API returned no output_text (output types: ${outputTypes.join(",") || "none"}; content types: ${contentTypes.join(",") || "none"}; refusal: ${refusal})`, + ); +} + let _chatSupported = null; const reconstructionJsonSchema = z.toJSONSchema(reconstructionV2Schema); const openAIReconstructionJsonSchema = createOpenAIStrictSchema( @@ -458,9 +484,11 @@ class OpenAIReconstructionProvider { } const data = await response.json(); - const outputText = data.output_text; - if (typeof outputText !== "string") { - const error = new Error("OpenAI Responses API returned no output_text"); + let outputText; + try { + outputText = extractOpenAIResponseText(data); + } catch (cause) { + const error = new Error(cause.message); error.providerApiPath = "/v1/responses"; throw error; } diff --git a/tests/llm/provider.test.js b/tests/llm/provider.test.js index 0d244bc..a861686 100644 --- a/tests/llm/provider.test.js +++ b/tests/llm/provider.test.js @@ -239,6 +239,65 @@ describe("OpenAI reconstruction provider experiment seam", () => { }); }); + it("extracts ordered output_text parts from raw Responses output", async () => { + const provider = createOpenAIReconstructionProvider({ + apiKey: "test-key", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output: [ + { type: "reasoning", content: [] }, + { + type: "message", + role: "assistant", + content: [ + { type: "output_text", text: '{"reconstruction":' }, + { type: "output_text", text: '"result"}' }, + ], + }, + ], + }), + }), + }); + + await expect(provider.generateReconstruction("prompt")).resolves.toEqual({ + response: { reconstruction: "result" }, + providerApiPath: "/v1/responses", + }); + }); + + it("preserves output_text convenience responses", async () => { + const provider = createOpenAIReconstructionProvider({ + apiKey: "test-key", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ output_text: '{"reconstruction":"result"}' }), + }), + }); + + await expect(provider.generateReconstruction("prompt")).resolves.toEqual({ + response: { reconstruction: "result" }, + providerApiPath: "/v1/responses", + }); + }); + + it("fails safely when a raw Responses result has no output text", async () => { + const provider = createOpenAIReconstructionProvider({ + apiKey: "test-key", + fetchImpl: vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output: [{ type: "message", content: [{ type: "refusal" }] }], + }), + }), + }); + + await expect(provider.generateReconstruction("prompt")).rejects.toMatchObject({ + providerApiPath: "/v1/responses", + message: expect.stringContaining("refusal: true"), + }); + }); + it("surfaces Responses API failures without inventing reconstruction content", async () => { const provider = createOpenAIReconstructionProvider({ apiKey: "test-key",