mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user