Skip to content

Repair model JSON locally and enforce schemas before paying for a retry - #176

Merged
Programcode merged 7 commits into
mainfrom
cost/strict-json-output
Oct 22, 2026
Merged

Programcode merged 7 commits into
mainfrom
cost/strict-json-output

Conversation

@Programcode

@Programcode Programcode commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Cost fix 7 of 7. A reply that isn't valid JSON used to cost a full second generation. This PR makes that second call rarer and skips it when it can't help.

What changes

Local repair before any paid retry (provider.ts). Before the server gives up on a reply, it now fixes the usual slips itself:

  • a markdown fence around the whole reply, with or without a language tag (```json, ```JSON, plain ```);
  • prose before or after the object, even when that prose has braces of its own. The old greedy {...} match failed in that case. If two objects in the reply both parse, the answer is ambiguous, so the parser rejects the reply and the normal retry runs;
  • trailing commas before } or ];
  • raw line breaks and tabs inside strings.

The parser still rejects a reply that was cut off mid-object. Closing it would ship half a case as if it were complete.

A reply cut off at its cap is retried once at double the cap. If the provider reports finish_reason: "length", the server doesn't send the reply to the JSON repair retry, because a second call under the same cap would be cut off again. Since #182, which gave each feature its own reply cap, the server instead retries once at double that cap. The strict schema stays on the retry, and both attempts are billed. This works for streamed and non-streamed replies.

As first opened, this PR failed straight away on a cut-off reply and billed one attempt. #182 landed before the merge and its retry was kept, because with a smaller per-feature cap a doubled cap can actually fit the reply.

Strict schemas where the server already checks the shape. These calls now send response_format with type: "json_schema" and strict: true, so the provider has to return the right shape:

  • Crossfire next turn: type is fixed to the expected reply kind;
  • Crossfire debrief: turnIds can only be IDs from this transcript. Before, an invented ID failed the validator and the student had to run the debrief again, which cost another paid call;
  • Ask's "should I search" decision;
  • deep research follow-up queries.

Every other feature (Generate, Attack, Brainstorm, Factoids, Ask answers, Sparring) stays on plain JSON mode. Their contract lives in prompt prose, and the client reshapes them before validating. A strict schema makes every field required, so a schema that drifted from the prompt would turn rare failures into constant ones. They can move over one at a time once each has a code-side validator to copy.

Routing note

We send require_parameters: true. With a schema attached, OpenRouter then routes only to endpoints that support structured outputs. For the four schema calls, OpenRouter drops these endpoints from the pool. Amazon Bedrock (and, for Haiku and Sonnet, Claude Platform on AWS) for the Claude models, DeepSeek's own API for V4 Pro, Z.AI's own API for GLM, and several smaller hosts for the DeepSeek and GLM models. Every catalog model still has several endpoints that support it. Calls without a schema route exactly as before.

Verification

  • npm run verify at 451eae7: passed (docs check, lint, typecheck, 305 unit tests, 30 collaboration tests, 358 Edge Function tests, build, service worker check, 30 desktop Chromium smoke tests)
  • At b0cfe00, the fix for Codex round 1: lint, typecheck and npm run test:edge (358 passed). That commit only changes the parser and its test.
  • New tests in provider-errors_test.ts cover each repair case, the refusals (a nested object is never taken for the answer, a cut-off object is not completed even around a fenced example, two parsable objects are ambiguous), and a 20 KB fence-like string that must parse in under a second. After the merge with Cap each AI feature's reply length instead of 32,768 tokens #182, the cap test checks for one retry at a larger cap with the strict schema kept.
  • Live probe against real OpenRouter: I sent the strict debrief schema (the most complex one, with anyOf and a turn-ID enum) to all ten catalog models with require_parameters: true. All ten routed and returned valid JSON in the right shape. DeepSeek V4.1 Flash failed once, but only because my probe capped output at 4,000 tokens and reasoning used them up. It passed four out of four reruns. The production cap was then 32,768; since Cap each AI feature's reply length instead of 32,768 tokens #182 each feature has its own cap.
  • Crossfire streams, so I also sent both Crossfire schemas with stream: true to all ten models, using the then-production 32,768 output cap. All 20 streamed replies parsed, each ended with finish_reason: stop and [DONE], and every next turn came back with the expected type.
  • I did not run the verify-debatelab stack because a parallel cost-fix thread held the shared Supabase lock the whole time. The direct probes above are the live proof for the schema path. The fake provider ignores response_format, so a fake stack run wouldn't prove it anyway.

GitHub Actions is broken across the repo, so these are local results, not CI.

🤖 Generated with Claude Code

An invalid reply used to cost a full second generation. Now:
- the parser fixes fences, prose around the object, trailing commas and
  raw line breaks in strings before giving up;
- a reply cut off at the output token cap is not retried, since the same
  cap would cut the retry too;
- Crossfire turns and debriefs, the Ask search decision and research
  follow-up queries send a strict json_schema, so the provider holds the
  reply to the shape the server already validates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 11:17
@vercel

vercel Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
debate-navigator Ready Ready Preview Oct 22, 2026 12:39pm UTC
debatelab-staging Ready Ready Preview Oct 22, 2026 12:39pm UTC

@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between da7473e and c56cf8f.

📒 Files selected for processing: 8
  • docs/architecture.md
  • supabase/functions/_shared/crossfire-response.ts
  • supabase/functions/_shared/research.ts
  • supabase/functions/ai/features/crossfire-input.ts
  • supabase/functions/ai/features/crossfire.ts
  • supabase/functions/ai/index.ts
  • supabase/functions/ai/provider-errors_test.ts
  • supabase/functions/ai/provider.ts
+---------------------------------------------------+
| What happens in code review stays in code review. |
+---------------------------------------------------+
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Programcode

Copy link
Copy Markdown
Owner Author

All 11 failing Actions jobs on 451eae7 failed in about 2 seconds without starting. The job annotation says: "The job was not started because recent account payments have failed or your spending limit needs to be increased." That's the repo-wide Actions billing block, not this change. The same checks ran locally at this commit with npm run verify and passed: docs check, lint, typecheck, 305 unit tests, 30 collaboration tests, 358 Edge Function tests, build, service worker check, and 30 desktop Chromium smoke tests. The database gate didn't run locally because another worktree held the shared Supabase lock. This PR doesn't touch migrations or SQL. This is a local pass, not a CI pass.

Review of 451eae7 found three ways the parser took the wrong text:
- the fence regex backtracked for seconds on long whitespace, and ran
  before the plain parse;
- a fence inside a string of an unfinished object was taken as the answer;
- the first parsable span won, so "{} means omitted" beat the real object.

Plain JSON now parses first, a fence counts only when it wraps the whole
reply (found with indexOf, no regex), and a span is accepted only when it
is the one span in the reply that parses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Codex review round 1 on 451eae7 had three findings, all valid, all fixed in b0cfe00:

  1. Slow fence regex. The lazy fence regex backtracked on long whitespace (1.3 s for 1.5 KB of spaces in my repro, about 12.7 s in Codex's), and it ran before the plain JSON.parse. Fixed: plain JSON parses first, and the fence check uses indexOf with no regex. The same repro now takes 11 ms. Test: a 20 KB fence-like string inside valid JSON must parse in under a second.
  2. Fence inside a string. A fence inside a string of an unfinished object was returned as the answer. Fixed: a fence counts only when it wraps the whole reply. Test: the exact input from the finding is now rejected.
  3. First span wins. With two objects that both parsed, the first one won (Note: {} means omitted. Answer: {...} returned {}). Fixed: a span is accepted only when it's the one span in the reply that parses. Otherwise the reply goes to the paid repair retry, as before. Test: that input is now rejected.

All three cases are in provider-errors_test.ts, and the old parser accepted every one of them. I ran lint, typecheck, and npm run test:edge (358 passed) at this commit.

Review of b0cfe00 found two ways the repair still accepted an example:
- a reply that opened with a fence was taken even with text after the
  closing fence;
- the scan stopped after eight spans, so a second valid object later in
  the reply went unseen.

A fence now has to close the reply. The span scan reads every span and
rejects the reply on a second parsable span, on an unclosed brace, or past
16 spans (which bounds parse work on a reply full of braces: a 1 MB reply
of tiny spans went from 4.7 s to 118 ms). Rejection means the existing
paid retry, as before this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Programcode

Copy link
Copy Markdown
Owner Author

Codex review round 2 on b0cfe00 had two findings, both valid, both fixed in 3608445:

  1. Fence followed by more text. A reply that opened with a fence was accepted even with more text after the closing fence. Fixed: the fence has to end the reply, otherwise the ambiguity check runs. Test: the exact input from the finding is now rejected.
  2. Second object past the span cap. The scan stopped after eight spans, so a second valid object further on went unseen. Fixed: the scan reads every span and fails closed when a second span parses, when an unclosed { leaves text unread, or past 16 spans. Without some limit, a 1 MB reply of tiny {x} spans took 4.7 s; with the fail-closed limit it takes 118 ms. Rejecting means the existing paid retry, the same as on main. Tests: the finding's input with 9 spans, 20 spans followed by one valid object, and an unclosed brace before a later object are all rejected.

I ran typecheck, lint:repo, ESLint on the changed files, and npm run test:edge (358 passed) at this commit. A full eslint . run was killed with SIGTERM from outside the task twice on this VPS, so I linted the two changed files directly.

A slip in the real answer (an unescaped quote) made its span fail, and
any other span that parsed, such as an example object or a nested one,
was returned as the answer.
@vercel

vercel Bot commented Oct 22, 2026

Copy link
Copy Markdown

Deployment failed for project debate-navigator with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/adam-gaborjanis-projects?upgradeToPro=build-rate-limit

@vercel

vercel Bot commented Oct 22, 2026

Copy link
Copy Markdown

Deployment failed for project debatelab-staging with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/adam-gaborjanis-projects?upgradeToPro=build-rate-limit

Keep #182 retry of a reply cut off at its per-feature cap (now with the
strict schema) and drop this branch no-retry-on-truncation path, which
the per-feature caps superseded.
@Programcode
Programcode merged commit b8471e4 into main Oct 22, 2026
0 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants