mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(ai): gate OpenAI JSON mode on responseFormat instead of forcing it
The OpenAI client pinned `response_format: { type: "json_object" }` on every
call. The Coach tool loop's forced-final round runs with `tool_choice: "none"`
and expects plain prose, but JSON mode coerced the reply into a JSON object —
the user saw a JSON blob or an empty reply instead of the coach's answer.
Send `response_format` only when the caller opts in with `responseFormat: "json"`
and no tools are present, mirroring the Anthropic client's prefill gate. The
JSON-consuming callers that relied on the implicit mode (insight generate +
briefing retry, medication extraction, the connection-test probe) now pass the
flag explicitly; the prose Coach path and every tool round stay out of JSON
mode. Codex already omitted the flag and is unaffected.
This commit is contained in:
1 parent
745991a922
commit
7b0dcbf560
6 files changed
+91
-9
No files matched your search
@@ -72,6 +72,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
user: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
// The probe asks for a JSON object — keep the OpenAI / Codex strict
|
||||
// JSON mode it relied on before the response_format gate landed.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -682,6 +682,9 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
user: userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// The reply is parsed with `JSON.parse` below, so opt the OpenAI /
|
||||
// Codex chains into their strict JSON mode (gated on this flag).
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
result = fallback.result;
|
||||
@@ -815,6 +818,8 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
user: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
// Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
const parsedRetry = JSON.parse(retry.result.content);
|
||||
|
||||
@@ -195,6 +195,9 @@ async function handleExtract(request: NextRequest): Promise<Response> {
|
||||
// The reply body is a single small JSON object; 600 is well
|
||||
// over the empirical max we have seen on a 2k-char input.
|
||||
maxTokens: 600,
|
||||
// Parsed with `parseModelJson` (JSON.parse) below — opt the OpenAI /
|
||||
// Codex chains into their strict JSON-object mode (gated on this flag).
|
||||
responseFormat: "json",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("OpenAIClient", () => {
|
||||
singleUserTurn({
|
||||
system: "You are a doctor.",
|
||||
user: "Analyze this data.",
|
||||
responseFormat: "json",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -46,6 +47,66 @@ describe("OpenAIClient", () => {
|
||||
expect(body).not.toHaveProperty("seed");
|
||||
});
|
||||
|
||||
it("forces JSON mode only when responseFormat is json AND no tools (M-1)", async () => {
|
||||
const replyContent = (content: string) =>
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new OpenAIClient({
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o-mini",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
// 1. Prose Coach path (no responseFormat, no tools) → NO json_object. This
|
||||
// is the F1 forced-final round contract: JSON mode would corrupt prose.
|
||||
const proseFetch = replyContent("Here is a plain-prose reply.");
|
||||
vi.stubGlobal("fetch", proseFetch);
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "coach", user: "how am I doing?" }),
|
||||
);
|
||||
expect(JSON.parse(proseFetch.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
"response_format",
|
||||
);
|
||||
|
||||
// 2. responseFormat:"json" but tools present → still NO json_object (a tool
|
||||
// round must not be coerced into a JSON object).
|
||||
const toolFetch = replyContent("{}");
|
||||
vi.stubGlobal("fetch", toolFetch);
|
||||
await client.generateCompletion({
|
||||
system: "s",
|
||||
messages: [{ role: "user", content: "u" }],
|
||||
responseFormat: "json",
|
||||
tools: [
|
||||
{
|
||||
name: "t",
|
||||
description: "d",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(JSON.parse(toolFetch.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
"response_format",
|
||||
);
|
||||
|
||||
// 3. responseFormat:"json" with no tools → json_object IS sent (insight /
|
||||
// extraction callers keep strict JSON).
|
||||
const jsonFetch = replyContent('{"summary":"x"}');
|
||||
vi.stubGlobal("fetch", jsonFetch);
|
||||
await client.generateCompletion(
|
||||
singleUserTurn({ system: "s", user: "u", responseFormat: "json" }),
|
||||
);
|
||||
expect(JSON.parse(jsonFetch.mock.calls[0][1].body).response_format).toEqual(
|
||||
{ type: "json_object" },
|
||||
);
|
||||
});
|
||||
|
||||
it("threads a deterministic seed onto the request body when supplied", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -49,10 +49,17 @@ export class OpenAIClient implements AIProvider {
|
||||
// v1.20.0 — tool plumbing. The defs map onto the OpenAI `function` tool
|
||||
// wire; F1 supplies real defs and consumes the parsed `toolCalls`. No F4
|
||||
// call site sets `tools`, so the text-only body is unchanged.
|
||||
const tools =
|
||||
params.tools && params.tools.length > 0
|
||||
? buildOpenAITools(params.tools)
|
||||
: undefined;
|
||||
const hasTools = !!params.tools && params.tools.length > 0;
|
||||
const tools = hasTools ? buildOpenAITools(params.tools!) : undefined;
|
||||
|
||||
// Only force OpenAI's strict JSON mode when the caller actually consumes a
|
||||
// JSON object AND no tools are in play — mirrors the Anthropic client's
|
||||
// `usePrefill` gate. JSON mode coerces `message.content` into a valid JSON
|
||||
// object, which contradicts the Coach prose contract; the F1 tool loop's
|
||||
// forced-final round (toolChoice:"none", no tools, no responseFormat) must
|
||||
// therefore stay out of JSON mode, and every tool round is non-JSON by
|
||||
// construction. Insight/extraction callers opt in with `responseFormat:"json"`.
|
||||
const useJsonFormat = params.responseFormat === "json" && !hasTools;
|
||||
|
||||
const res = await safeFetch(
|
||||
url,
|
||||
@@ -67,7 +74,9 @@ export class OpenAIClient implements AIProvider {
|
||||
messages,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
response_format: { type: "json_object" },
|
||||
...(useJsonFormat
|
||||
? { response_format: { type: "json_object" } }
|
||||
: {}),
|
||||
// Deterministic seed for reproducible reference output; omitted
|
||||
// (undefined → dropped by JSON.stringify) when the caller does
|
||||
// not pin one (e.g. the seedless daily-briefing re-roll).
|
||||
|
||||
+5
-4
@@ -246,13 +246,14 @@ export interface CompletionParams {
|
||||
seed?: number;
|
||||
/**
|
||||
* v1.18.7 — opt the structured (JSON) surfaces into the provider's
|
||||
* strongest JSON-reliability mode. When `"json"`:
|
||||
* strongest JSON-reliability mode. When `"json"` (and no `tools`):
|
||||
* - local (Ollama / OpenAI-compatible): adds `format: "json"`.
|
||||
* - Anthropic: prefills the assistant turn with `{` so the first token
|
||||
* is forced into a JSON object.
|
||||
* The OpenAI client already pins `response_format: json_object`
|
||||
* unconditionally and is unaffected. The Coach (prose, NOT JSON) leaves
|
||||
* this unset, so its streaming path is untouched.
|
||||
* - OpenAI / Codex: sends `response_format: { type: "json_object" }`.
|
||||
* The Coach (prose, NOT JSON) leaves this unset, so no provider coerces
|
||||
* its reply into a JSON object — including the OpenAI client, which now
|
||||
* gates `response_format` on this flag rather than pinning it always.
|
||||
*
|
||||
* Mutually exclusive with `tools` on Anthropic: the `{`-prefill injects an
|
||||
* assistant turn that collides with a `tool_use` response, so the Anthropic
|
||||
|
||||
Reference in new issue
Block a user