feat(ai): force JSON on the non-OpenAI provider chains

Add an opt-in `responseFormat: "json"` to CompletionParams that the
structured surfaces (comprehensive briefing + per-metric status cards)
set. On local (Ollama / OpenAI-compatible) it adds `format: "json"`; on
Anthropic it prefills the assistant turn with `{` and re-prepends the
brace to the reply, so the first emitted token is already inside a JSON
object. Both cut the first-pass JSON-failure rate that fence-stripping
otherwise has to absorb.

OpenAI already pins `response_format: json_object` and is unchanged. The
period narrative (plain text) and the Coach (streaming prose) leave the
flag unset, so their non-JSON paths are untouched.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:01:27 +02:00
1 parent a460c74e6c
commit a61b7fe4ad
8 files changed
+149 -3

No files matched your search

@@ -53,6 +53,65 @@ describe("AnthropicClient", () => {
expect(body.messages[0].content.toLowerCase()).toContain("json");
});
it("prefills the assistant turn with `{` and re-prepends it for JSON surfaces", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
// Model continues from the prefilled `{`, so its text omits it.
content: [{ type: "text", text: '"summary":"ok"}' }],
usage: { input_tokens: 5, output_tokens: 5 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new AnthropicClient({
apiKey: "sk-ant-test",
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
});
// The returned content is the complete object (the `{` re-prepended).
expect(result.content).toBe('{"summary":"ok"}');
expect(JSON.parse(result.content)).toEqual({ summary: "ok" });
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.messages).toHaveLength(2);
expect(body.messages[1]).toEqual({ role: "assistant", content: "{" });
});
it("does NOT prefill for the prose (non-JSON) path", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
content: [{ type: "text", text: "Just some prose." }],
usage: { input_tokens: 1, output_tokens: 1 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new AnthropicClient({
apiKey: "sk-ant-test",
model: "claude-3-5-sonnet-latest",
});
const result = await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
});
expect(result.content).toBe("Just some prose.");
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.messages).toHaveLength(1);
expect(body.messages[0].role).toBe("user");
});
it("uses custom baseUrl when provided", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
+31
View File
@@ -55,6 +55,37 @@ describe("LocalOpenAICompatibleClient", () => {
// CRUCIAL: do not send response_format because many local servers reject it.
expect(body).not.toHaveProperty("response_format");
// No JSON opt-in → no `format` field.
expect(body).not.toHaveProperty("format");
});
it("sends Ollama `format: json` only when the caller opts into JSON", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '{"summary":"x"}' } }],
usage: { total_tokens: 3 },
}),
});
vi.stubGlobal("fetch", mockFetch);
const client = new LocalOpenAICompatibleClient({
apiKey: null,
model: "llama3:8b",
baseUrl: "http://localhost:11434/v1",
});
await client.generateCompletion({
systemPrompt: "s",
userPrompt: "u",
responseFormat: "json",
seed: 99,
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.format).toBe("json");
expect(body.seed).toBe(99);
});
it("omits Authorization header when no API key is provided", async () => {
+26 -3
View File
@@ -48,12 +48,26 @@ export class AnthropicClient implements AIProvider {
// NOTE: Anthropic's Messages API has no `seed` parameter, so
// `params.seed` is intentionally not forwarded here — output on this
// provider is non-deterministic regardless of the pinned seed.
//
// v1.18.7 — JSON-reliability prefill. For the structured surfaces
// (`responseFormat: "json"`) we seed the assistant turn with a bare
// `{`. Anthropic continues from that token, so the first emitted
// character is already inside a JSON object — this drops the
// first-pass "prose preamble before the JSON" failure the
// fence-stripping net otherwise has to catch. We re-prepend the `{`
// to the returned text below so the caller sees a complete object.
body: JSON.stringify({
model: this.config.model,
max_tokens: params.maxTokens ?? 1000,
temperature: params.temperature ?? 0.3,
system: params.systemPrompt,
messages: [{ role: "user", content: wrapForJson(params.userPrompt) }],
messages:
params.responseFormat === "json"
? [
{ role: "user", content: wrapForJson(params.userPrompt) },
{ role: "assistant", content: "{" },
]
: [{ role: "user", content: wrapForJson(params.userPrompt) }],
}),
},
// 60 s ceiling — see openai-client.ts for the rationale.
@@ -89,12 +103,21 @@ export class AnthropicClient implements AIProvider {
};
const textBlock = json.content?.find((c) => c.type === "text");
const content = textBlock?.text;
const rawText = textBlock?.text;
if (!content) {
if (!rawText) {
throw new Error("Anthropic returned empty content");
}
// When we prefilled the assistant turn with `{`, the model continues
// from there and its returned text omits that leading brace — re-prepend
// it so the caller parses a complete object. Guard against a model that
// (rarely) echoes the prefill itself.
const content =
params.responseFormat === "json" && !rawText.trimStart().startsWith("{")
? `{${rawText}`
: rawText;
const inputTokens = json.usage?.input_tokens ?? 0;
const outputTokens = json.usage?.output_tokens ?? 0;
const tokensUsed = inputTokens + outputTokens || null;
+5
View File
@@ -69,6 +69,11 @@ export class LocalOpenAICompatibleClient implements AIProvider {
// most OpenAI-compatible local servers honour it; servers that
// ignore it simply disregard the field. Omitted when unset.
...(params.seed !== undefined ? { seed: params.seed } : {}),
// v1.18.7 — Ollama's native structured-output switch. The JSON
// surfaces opt in via `responseFormat: "json"`; it cuts the
// first-pass JSON-failure rate that the fence-stripping safety net
// otherwise has to catch. The Coach (prose) leaves it unset.
...(params.responseFormat === "json" ? { format: "json" } : {}),
}),
},
{ timeoutMs: 60_000, requirePublicHost: !allowPrivate },
+11
View File
@@ -126,6 +126,17 @@ export interface CompletionParams {
* Messages API has no seed knob, so the Anthropic client ignores it.
*/
seed?: number;
/**
* v1.18.7 — opt the structured (JSON) surfaces into the provider's
* strongest JSON-reliability mode. When `"json"`:
* - 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.
*/
responseFormat?: "json";
}
export interface CompletionResult {
@@ -700,6 +700,10 @@ export async function generateComprehensiveInsight(
userPrompt,
temperature: AI_BUDGETS.comprehensive.temperature,
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
// v1.18.7 — structured surface: opt the non-OpenAI chains into their
// strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a
// first-pass JSON miss is rarer; stripJsonFences stays the net.
responseFormat: "json",
},
});
result = fallback.result;
@@ -267,6 +267,9 @@ export async function generatePeriodNarrative(
// v1.18.7 — reference surface: pin a deterministic seed so a prompt
// change is diff-able against a stable baseline (MEDIUM-4).
seed: REFERENCE_AI_SEED,
// The narrative output is PLAIN TEXT (no markdown, no JSON), so suppress
// the JSON-mode opt-in the status cards default into.
responseFormat: "text",
});
// A provider error / timeout is non-fatal — the last good row stays as-is
+10
View File
@@ -57,6 +57,13 @@ interface RunStatusCompletionArgs {
* QA); the period narrative passes the same constant explicitly.
*/
seed?: number;
/**
* v1.18.7 — output contract of this generation. The per-metric status
* cards return a JSON `{ "summary": ... }` (the default), so they opt the
* non-OpenAI chains into their strongest JSON mode. The period narrative
* returns PLAIN TEXT and passes `"text"` to suppress that.
*/
responseFormat?: "json" | "text";
/**
* v1.12.1 — which AI surface this generation serves, for the consent gate.
* `insights` for the per-metric status cards + period narrative; `coach`
@@ -173,6 +180,9 @@ export async function runStatusCompletion(
// v1.18.7 — status/reference output is reproducible: pin the
// deterministic seed unless a caller overrides it.
seed: args.seed ?? REFERENCE_AI_SEED,
// Status cards are JSON by default; the narrative opts out via
// `"text"`.
responseFormat: args.responseFormat === "text" ? undefined : "json",
},
}),
STATUS_PROVIDER_TIMEOUT_MS,