From 630d2ba0601e85902cc9b90b40996a0a2609cad8 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 2 Nov 2026 18:26:39 +0000 Subject: [PATCH] Stop the /ai-debate demo reply being cut off by Claude reasoning --- supabase/README.md | 2 +- supabase/functions/demo-rebuttal/index.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/supabase/README.md b/supabase/README.md index 265abad1..33d033a3 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -53,7 +53,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh ### Authentication and administration -- `demo-rebuttal`: the no-login mini round on `/ai-debate`. Off unless `DEMO_ENABLED=true` and `TURNSTILE_SECRET_KEY` is set. It accepts only allowed origins, checks a Cloudflare Turnstile token before a session starts, and reserves every model call through the service-role-only `claim_demo_round_turn` RPC: 3 sessions per IP per UTC day, 2 turns per session, and `DEMO_DAILY_LIMIT` model calls per UTC day across all visitors (500 when unset). Rows keep a salted, per-day hash of the IP and counts only; nothing the visitor typed is stored or logged. One Claude Haiku call per turn with `max_tokens` 450 and no reasoning. +- `demo-rebuttal`: the no-login mini round on `/ai-debate`. Off unless `DEMO_ENABLED=true` and `TURNSTILE_SECRET_KEY` is set. It accepts only allowed origins, checks a Cloudflare Turnstile token before a session starts, and reserves every model call through the service-role-only `claim_demo_round_turn` RPC: 3 sessions per IP per UTC day, 2 turns per session, and `DEMO_DAILY_LIMIT` model calls per UTC day across all visitors (500 when unset). Rows keep a salted, per-day hash of the IP and counts only; nothing the visitor typed is stored or logged. One Claude Haiku call per turn at low reasoning effort, with `max_tokens` sized so the reasoning cannot crowd out the JSON reply. - `demo-code-login`: bounded pre-session access-code flow with normalized hashing, fail-closed attempt limits, atomic use consumption, server-owned demo-user bindings in Auth `app_metadata`, and a prohibition on granting administrator status. - `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions. - `announcements`: authenticated announcement listing and dismissal. diff --git a/supabase/functions/demo-rebuttal/index.ts b/supabase/functions/demo-rebuttal/index.ts index 91818216..dc01cb53 100644 --- a/supabase/functions/demo-rebuttal/index.ts +++ b/supabase/functions/demo-rebuttal/index.ts @@ -5,7 +5,7 @@ // - a Cloudflare Turnstile check before a session starts; // - DEMO_SESSIONS_PER_IP sessions per IP per UTC day, DEMO_TURNS_PER_SESSION turns each; // - DEMO_DAILY_LIMIT model calls per UTC day across everyone; -// - the default Claude Haiku model with a small max_tokens and no reasoning. +// - the default Claude Haiku model at low reasoning effort with a bounded reply. // Logs carry counts and statuses only, never the IP or what the visitor typed. import { createClient } from "https://esm.sh/@supabase/supabase-js@2.114.0"; import { getCorsHeaders, isAllowedOrigin } from "../_shared/cors.ts"; @@ -13,6 +13,7 @@ import { DEFAULT_MODEL } from "../_shared/model-catalog.ts"; import { openRouterProviderOptions, warnIfClaudeNotByok } from "../_shared/provider-routing.ts"; import { fetchJsonWithTimeout, OPENROUTER_API_URL } from "../_shared/research.ts"; import { BodyTooLargeError, readBoundedBody } from "../_shared/bounded-body.ts"; +import { outputTokenLimit } from "../_shared/output-caps.ts"; const SESSIONS_PER_IP = 3; const TURNS_PER_SESSION = 2; @@ -21,8 +22,16 @@ const MOTION_MAX = 200; const ARGUMENT_MAX = 600; const REBUTTAL_MAX = 1_200; const REBUTTAL_WORDS = 150; -// About 150 words of rebuttal, a judge note and the JSON around them. -const MAX_TOKENS = 450; +// About 150 words of rebuttal, a judge note and the JSON around them, with +// room for a Hungarian or Spanish reply, which takes more tokens. +const REPLY_TOKENS = 600; +// OpenRouter counts Claude's reasoning against max_tokens. With a bare 450 +// the hidden reasoning used most of it and the JSON reply was cut off +// mid-string ("Unterminated string in JSON"), so reasoning is pinned to low +// and max_tokens leaves room for it on top of the reply, as the app's own +// Claude calls do. Unused room costs nothing. +const REASONING_EFFORT = "low" as const; +const MAX_TOKENS = outputTokenLimit(REPLY_TOKENS, REASONING_EFFORT, 128_000); const MODEL_TIMEOUT_MS = 30_000; const MAX_BODY_BYTES = 8_192; const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; @@ -174,12 +183,15 @@ async function callModel(request: DemoRequest): Promise { model: DEFAULT_MODEL, messages: buildDemoMessages(request), max_tokens: MAX_TOKENS, + reasoning: { effort: REASONING_EFFORT, exclude: true }, response_format: { type: "json_schema", json_schema: OUTPUT_SCHEMA }, provider: openRouterProviderOptions(DEFAULT_MODEL), }), }, MODEL_TIMEOUT_MS); if (!response.ok) throw new Error(`OpenRouter ${response.status}`); warnIfClaudeNotByok(DEFAULT_MODEL, data?.usage, "demo-rebuttal"); + const finishReason = data?.choices?.[0]?.finish_reason; + if (finishReason === "length") throw new Error(`Demo reply cut off at max_tokens (completion_tokens=${String(data?.usage?.completion_tokens)})`); const content = data?.choices?.[0]?.message?.content; const parsed = typeof content === "string" ? JSON.parse(content) as Partial : {}; if (typeof parsed.allowed !== "boolean" || typeof parsed.rebuttal !== "string" || typeof parsed.judgeNote !== "string") {