Skip to content

Commit 345444a

Browse files
fix(selfhost): force JSON mode on OpenAI-compatible review calls and stop retrying deterministic-identical outputs (#8790) (#8794)
Confirmed live 2026-07-26 (the back-to-back inconclusive-review incident): the ollama fallback answered the review prompt with the same 2,814-char markdown prose on all 3 attempts — hasJsonObject:false every time — so any primary-model bail became a guaranteed "no usable verdict" manual hold, and the retry budget was pure waste (reviews run at temperature 0; identical input yields identical output). - AiRunOptions gains responseFormat: "json_object"; createOpenAiCompatibleAi forwards it as OpenAI's response_format (Ollama/vLLM honor it), with a single 400-fallback retry stripping the parameter for older servers that reject it (degrade to ask-nicely, never fail the call). Non-400 failures and 400s without the declared contract throw exactly as before. - runWorkersOpinion declares the JSON contract on every review call (other providers ignore the field; the subscription CLIs already comply via the prompt) and stops a model's retries when an attempt returns byte-identical output to the previous one — the same stop-retrying-this-model reasoning as the deliberate-bail/timeout/429 breaks. The next model keeps its full budget. New diagnostic status: identical_retry_skipped. Co-authored-by: loopover-orb[bot] <296761690+loopover-orb[bot]@users.noreply.github.com>
1 parent a8a0912 commit 345444a

5 files changed

Lines changed: 145 additions & 9 deletions

File tree

src/selfhost/ai.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ interface AiRunOptions {
3333
// (embeddings, the subscription CLIs, Anthropic) ignores it, so it is safe to set unconditionally on a
3434
// call that ONLY ever targets an Ollama-backed binding (e.g. AI_VISION).
3535
providerOptions?: Record<string, unknown>;
36+
// #8790: the caller expects a JSON object back (the PR-review prompt's contract). Only
37+
// createOpenAiCompatibleAi's chat path reads this — forwarded as OpenAI's `response_format` so a local
38+
// model (confirmed live: an Ollama fallback answering the review prompt in markdown prose on every
39+
// attempt) is FORCED into JSON mode instead of merely asked. Every other provider ignores it (the
40+
// subscription CLIs already comply via the prompt), so it is safe to set unconditionally on review calls.
41+
responseFormat?: "json_object";
3642
// Correlation context for a provider-failure log (#codex-timeout-fields): purely observational, never read by a
3743
// provider's own request logic. The caller (runWorkersOpinion) passes whatever of these it already has in scope
3844
// for THIS review — job id and attempt are per-attempt, repoFullName/pullNumber identify the PR being reviewed —
@@ -366,18 +372,33 @@ export function createOpenAiCompatibleAi(opts: {
366372
}
367373
const repoOverride = opts.providerName ? resolveOpenAiCompatibleRepoOverride(opts.providerName, options) : undefined;
368374
const resolvedModel = resolveModel(firstConfigured(repoOverride, opts.model), model, opts.defaultModel ?? DEFAULT_OPENAI_COMPATIBLE_CHAT_MODEL);
369-
const res = await fetch(`${base}/chat/completions`, {
370-
method: "POST",
371-
headers: headers(),
372-
body: JSON.stringify({
375+
const chatBody = (withResponseFormat: boolean): string =>
376+
JSON.stringify({
373377
model: resolvedModel,
374378
messages: toMessages(options).map((message) => ({ role: message.role, content: toOpenAiMessageContent(message.content) })),
375379
max_tokens: options.max_tokens,
376380
temperature: options.temperature,
377381
...(options.providerOptions ? { options: options.providerOptions } : {}),
378-
}),
382+
// #8790: force JSON mode when the caller declared a JSON contract — Ollama's and vLLM's
383+
// OpenAI-compatible layers both honor it; servers that don't get the 400-fallback below.
384+
...(withResponseFormat && options.responseFormat === "json_object" ? { response_format: { type: "json_object" } } : {}),
385+
});
386+
let res = await fetch(`${base}/chat/completions`, {
387+
method: "POST",
388+
headers: headers(),
389+
body: chatBody(true),
379390
signal: AbortSignal.timeout(120_000),
380391
});
392+
// #8790: an older OpenAI-compatible server may reject the response_format parameter outright with a
393+
// 400. Retry once without it — degrading to the pre-#8790 ask-nicely behavior beats failing the call.
394+
if (!res.ok && res.status === 400 && options.responseFormat === "json_object") {
395+
res = await fetch(`${base}/chat/completions`, {
396+
method: "POST",
397+
headers: headers(),
398+
body: chatBody(false),
399+
signal: AbortSignal.timeout(120_000),
400+
});
401+
}
381402
if (!res.ok) throw new Error(`ai_http_${res.status}`);
382403
const data = (await res.json()) as { choices?: Array<{ message?: { content?: string } }> };
383404
const usage = extractCliUsage(JSON.stringify(data));

src/services/ai-review.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,7 @@ export type ModelReview = {
476476
export type AiReviewDiagnostic = {
477477
model: string;
478478
attempt: number;
479-
status: "parsed" | "empty_output" | "unparseable_output" | "provider_error" | "missing_assessment";
479+
status: "parsed" | "empty_output" | "unparseable_output" | "provider_error" | "missing_assessment" | "identical_retry_skipped";
480480
responseChars?: number | undefined;
481481
hasJsonObject?: boolean | undefined;
482482
error?: string | undefined;
@@ -1189,6 +1189,12 @@ async function runWorkersOpinion(
11891189
if (modelIndex > 0) {
11901190
incr("loopover_ai_review_model_fallback_total", { primary, fallback: model });
11911191
}
1192+
// #8790: the previous attempt's raw output for THIS model. Reviews run at temperature 0, so a
1193+
// byte-identical repeat is deterministic — the remaining retries are provably useless (confirmed live
1194+
// 2026-07-26: a fallback returned the same 2,814-char markdown response on all 3 attempts). Same
1195+
// stop-retrying-this-model reasoning as the deliberate-bail/timeout/429 breaks below; the next model
1196+
// still gets its own full budget.
1197+
let lastRawText: string | undefined;
11921198
for (let attempt = 0; attempt < 3; attempt += 1) {
11931199
try {
11941200
const cliSystemAppend = selfHostCliSystemAppend(model, systemAppend);
@@ -1197,6 +1203,10 @@ async function runWorkersOpinion(
11971203
{
11981204
max_tokens: maxTokens,
11991205
temperature: 0,
1206+
// #8790: the review prompt's contract is a JSON object — declare it so an OpenAI-compatible
1207+
// provider is forced into JSON mode (response_format) instead of merely asked. Ignored by every
1208+
// other provider (the subscription CLIs already comply via the prompt).
1209+
responseFormat: "json_object",
12001210
messages: [
12011211
{ role: "system", content: system },
12021212
{ role: "user", content: toContentBlocks(user, images) },
@@ -1232,6 +1242,22 @@ async function runWorkersOpinion(
12321242
const text = coerceAiText(result);
12331243
const usage = coerceAiUsage(result);
12341244
const usageFields = usage ? { usage } : {};
1245+
// #8790: byte-identical to the previous attempt's (necessarily failed — a success returns) output →
1246+
// deterministic repeat; stop this model's retries instead of burning the rest of the budget on it.
1247+
if (attempt > 0 && text.trim() !== "" && text === lastRawText) {
1248+
diagnostics.push({ model, attempt, status: "identical_retry_skipped", responseChars: text.length, hasJsonObject: Boolean(extractLastJsonObject(text)), ...usageFields });
1249+
console.warn(
1250+
JSON.stringify({
1251+
level: "warn",
1252+
event: "ai_review_provider_identical_retry_skipped",
1253+
model,
1254+
attempt,
1255+
responseChars: text.length,
1256+
}),
1257+
);
1258+
break;
1259+
}
1260+
lastRawText = text;
12351261
const parsed = parseModelReview(text);
12361262
if (parsed && parsed.assessment.trim() !== "") {
12371263
diagnostics.push({ model, attempt, status: "parsed", responseChars: text.length, hasJsonObject: Boolean(extractLastJsonObject(text)), ...usageFields });

test/unit/ai-review-advisory.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,49 @@ describe("runAiReviewForAdvisory", () => {
760760
captureSpy.mockRestore();
761761
});
762762

763+
it("#8790: stops retrying a model after a byte-identical repeat of a failed attempt (deterministic at temperature 0)", async () => {
764+
const adv = advisory();
765+
let aiCalls = 0;
766+
// The same unparseable markdown every time — the confirmed 2026-07-26 production shape. Attempt 0 fails,
767+
// attempt 1 comes back byte-identical → the remaining retry budget is provably useless and is skipped.
768+
const env = aiEnv(async () => {
769+
aiCalls += 1;
770+
return { response: "### Review of Code Changes\n\nProse, not JSON." };
771+
});
772+
const result = await runAiReviewForAdvisory(env, {
773+
mode: "live",
774+
settings: { aiReviewMode: "block" } as RepositorySettings,
775+
advisory: adv,
776+
repoFullName: "acme/widgets",
777+
pr,
778+
author: "alice",
779+
confirmedContributor: true,
780+
});
781+
expect(result?.findings).toEqual([expect.objectContaining({ code: "ai_review_inconclusive" })]);
782+
// This harness's plan resolves 4 model slots (two reviewers x primary+fallback); the invariant under
783+
// test is per-slot: exactly 2 attempts each (attempt 0 + the identical attempt 1), never a third.
784+
expect(aiCalls).toBe(8);
785+
});
786+
787+
it("#8790: a model whose failed outputs DIFFER between attempts keeps its full retry budget (only determinism short-circuits)", async () => {
788+
const adv = advisory();
789+
let aiCalls = 0;
790+
const env = aiEnv(async () => {
791+
aiCalls += 1;
792+
return { response: `### Prose attempt ${aiCalls}` };
793+
});
794+
await runAiReviewForAdvisory(env, {
795+
mode: "live",
796+
settings: { aiReviewMode: "block" } as RepositorySettings,
797+
advisory: adv,
798+
repoFullName: "acme/widgets",
799+
pr,
800+
author: "alice",
801+
confirmedContributor: true,
802+
});
803+
expect(aiCalls).toBe(12); // 3 attempts x the same 4 model slots — pre-#8790 behavior pinned for varying output
804+
});
805+
763806
it("uses the non-cacheable block-mode inconclusive note when no reviewer returns public text", async () => {
764807
const adv = advisory();
765808
const result = await runAiReviewForAdvisory(aiEnv(async () => ({ response: "" })), {

test/unit/ai-review.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,8 +1449,9 @@ describe("Workers AI fallback + degraded output", () => {
14491449
const result = await runLoopOverAiReview(env, baseInput);
14501450
expect(result.status === "ok" && result.advisoryNotes).toBeNull();
14511451
expect(result.status === "ok" && result.inconclusive).toBe(true);
1452-
// primary 3× + fallback 3× retries, all unparseable.
1453-
expect(run).toHaveBeenCalledTimes(6);
1452+
// #8790: identical unparseable output on every call → each model stops after its byte-identical
1453+
// attempt 1 (2 calls per model) instead of burning the full 3-attempt budget on a deterministic repeat.
1454+
expect(run).toHaveBeenCalledTimes(4);
14541455
});
14551456
});
14561457

@@ -3259,7 +3260,10 @@ describe("pure helpers", () => {
32593260
// attempt ever produced the required assessment field.
32603261
expect(parsed.review?.assessment).toBe("");
32613262
expect(parsed.review?.nits).toEqual(["Edge case on empty input is untested.", "Naming could be clearer."]);
3262-
expect(run).toHaveBeenCalledTimes(6); // 3 attempts x 2 models -- the full budget, since nothing here is a deliberate bail.
3263+
// #8790: the mock returns byte-identical output every call, so each model stops after its identical
3264+
// attempt 1 (2 calls per model). The incomplete-review fallback + exhausted log below are unaffected —
3265+
// attempt 0 already captured bestIncompleteReview.
3266+
expect(run).toHaveBeenCalledTimes(4);
32633267
const exhausted = logSpy.mock.calls
32643268
.map((c) => c[0])
32653269
.find((l) => typeof l === "string" && l.includes("ai_review_missing_assessment_exhausted"));

test/unit/selfhost-ai.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,48 @@ describe("createOpenAiCompatibleAi (#979)", () => {
178178
expect(first?.body.model).toBe("llama3.1");
179179
});
180180

181+
it("#8790: forwards response_format json_object when the caller declares a JSON contract, and omits it otherwise", async () => {
182+
const bodies: Array<Record<string, unknown>> = [];
183+
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: { body: string }) => {
184+
bodies.push(JSON.parse(init.body));
185+
return new Response(JSON.stringify({ choices: [{ message: { content: "{}" } }] }), { status: 200 });
186+
}));
187+
const ai = createOpenAiCompatibleAi({ baseUrl: "http://o/v1" });
188+
await ai.run("m", { prompt: "x", responseFormat: "json_object" });
189+
expect(bodies[0]).toMatchObject({ response_format: { type: "json_object" } });
190+
await ai.run("m", { prompt: "x" });
191+
expect("response_format" in bodies[1]!).toBe(false);
192+
});
193+
194+
it("#8790: retries ONCE without response_format when the server 400s on it — degrade to ask-nicely, never fail the call", async () => {
195+
const bodies: Array<Record<string, unknown>> = [];
196+
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: { body: string }) => {
197+
const body = JSON.parse(init.body) as Record<string, unknown>;
198+
bodies.push(body);
199+
if ("response_format" in body) return new Response("unknown parameter", { status: 400 });
200+
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 });
201+
}));
202+
const ai = createOpenAiCompatibleAi({ baseUrl: "http://o/v1" });
203+
const out = await ai.run("m", { prompt: "x", responseFormat: "json_object" });
204+
expect(out.response).toBe("ok");
205+
expect(bodies).toHaveLength(2);
206+
expect("response_format" in bodies[1]!).toBe(false);
207+
});
208+
209+
it("#8790: a 400 WITHOUT a declared JSON contract still throws ai_http_400 — the retry is format-rejection-specific", async () => {
210+
vi.stubGlobal("fetch", vi.fn(async () => new Response("bad request", { status: 400 })));
211+
const ai = createOpenAiCompatibleAi({ baseUrl: "http://o/v1" });
212+
await expect(ai.run("m", { prompt: "x" })).rejects.toThrow("ai_http_400");
213+
});
214+
215+
it("#8790: a NON-400 failure with the JSON contract set throws without a format-stripping retry (only 400 means parameter rejection)", async () => {
216+
const fetchMock = vi.fn(async () => new Response("upstream broke", { status: 500 }));
217+
vi.stubGlobal("fetch", fetchMock);
218+
const ai = createOpenAiCompatibleAi({ baseUrl: "http://o/v1" });
219+
await expect(ai.run("m", { prompt: "x", responseFormat: "json_object" })).rejects.toThrow("ai_http_500");
220+
expect(fetchMock).toHaveBeenCalledTimes(1);
221+
});
222+
181223
it("attributes usage.provider from its own configured providerName (#ai-usage-provider-attribution) since an HTTP chat-completions response never reports one itself", async () => {
182224
vi.stubGlobal("fetch", vi.fn(async () =>
183225
new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { status: 200 }),

0 commit comments

Comments
 (0)