Skip to content

Commit dda9bc0

Browse files
Programcodeclaude
andcommitted
Fail an Ask stream cut off before any text, widen Sparring caps, pin the cap math
An Ask stream that spent its whole cap on reasoning now fails so the client falls back to the structured endpoint, which retries with a larger cap. The Sparring speech and scorecard caps fit a full-length Hungarian reply. The cap formula uses integer percentages, and a table test pins it from outside. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f9159f7 commit dda9bc0

4 files changed

Lines changed: 59 additions & 11 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// max_tokens covers reasoning and the reply together, so a wrong formula
2+
// leaves a model no room to answer. These pin the formula from outside it.
3+
4+
import { assertEquals } from "jsr:@std/assert@1.0.19";
5+
import { outputTokenLimit, REPLY_TOKEN_CAPS, TRUNCATION_RETRY_FACTOR } from "./output-caps.ts";
6+
import type { ReasoningEffort } from "./model-catalog.ts";
7+
8+
// OpenRouter's Claude budget: max(max_tokens * share, 1024).
9+
// https://openrouter.ai/docs/use-cases/reasoning-tokens
10+
const CLAUDE_SHARE: Record<ReasoningEffort, number> = { low: 0.2, medium: 0.5, high: 0.8 };
11+
const EFFORTS: ReasoningEffort[] = ["low", "medium", "high"];
12+
13+
Deno.test("max_tokens for a known reply and effort", () => {
14+
assertEquals(outputTokenLimit(1_500, "low", 128_000), 3_548);
15+
assertEquals(outputTokenLimit(1_500, "medium", 128_000), 5_596);
16+
assertEquals(outputTokenLimit(1_500, "high", 128_000), 9_692);
17+
assertEquals(outputTokenLimit(6_000, "high", 128_000), 30_000);
18+
assertEquals(outputTokenLimit(6_000, "high", 20_000), 20_000);
19+
});
20+
21+
Deno.test("every cap leaves its full reply after Claude's reasoning budget and after the floor for other models", () => {
22+
for (const [cap, reply] of Object.entries(REPLY_TOKEN_CAPS)) {
23+
for (const effort of EFFORTS) {
24+
for (const replyTokens of [reply, reply * TRUNCATION_RETRY_FACTOR]) {
25+
const limit = outputTokenLimit(replyTokens, effort, 128_000);
26+
const claudeBudget = Math.max(Math.floor(limit * CLAUDE_SHARE[effort]), 1_024);
27+
if (limit - claudeBudget < replyTokens) throw new Error(`${cap} at ${effort}: Claude keeps ${limit - claudeBudget} of ${replyTokens}`);
28+
if (limit - replyTokens < 2_048) throw new Error(`${cap} at ${effort}: only ${limit - replyTokens} tokens for reasoning`);
29+
}
30+
}
31+
}
32+
});

‎supabase/functions/_shared/output-caps.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ export const REPLY_TOKEN_CAPS = {
1515
crossfireTurn: 1_000,
1616
/** The turn-cited Crossfire debrief: six arrays of findings. */
1717
crossfireDebrief: 2_000,
18-
/** One Sparring opponent speech. */
19-
sparringSpeech: 1_500,
18+
/** One Sparring opponent speech: up to 7 minutes, 6,000 characters. */
19+
sparringSpeech: 2_500,
2020
/** The Sparring scorecard: four categories, moments, areas, verdict. */
21-
sparringScore: 2_000,
21+
sparringScore: 3_000,
2222
/** A full case, any length setting. */
2323
generate: 6_000,
2424
attack: 4_000,
@@ -37,12 +37,12 @@ export type ReplyCap = keyof typeof REPLY_TOKEN_CAPS;
3737
export const TRUNCATION_RETRY_FACTOR = 2;
3838

3939
/**
40-
* The share of max_tokens a budget-based model (Claude) may spend on
40+
* The percentage of max_tokens a budget-based model (Claude) may spend on
4141
* reasoning at each effort. OpenRouter sets its budget to
4242
* max(max_tokens * share, 1024).
4343
* https://openrouter.ai/docs/use-cases/reasoning-tokens
4444
*/
45-
const REASONING_SHARE: Record<ReasoningEffort, number> = { low: 0.2, medium: 0.5, high: 0.8 };
45+
const REASONING_SHARE_PERCENT: Record<ReasoningEffort, number> = { low: 20, medium: 50, high: 80 };
4646

4747
/**
4848
* Reasoning room kept on top of the reply at each effort. Effort-based models
@@ -62,6 +62,6 @@ export function outputTokenLimit(
6262
effort: ReasoningEffort,
6363
modelMaxTokens: number,
6464
): number {
65-
const afterBudget = Math.ceil(replyTokens / (1 - REASONING_SHARE[effort]));
65+
const afterBudget = Math.ceil((replyTokens * 100) / (100 - REASONING_SHARE_PERCENT[effort]));
6666
return Math.min(modelMaxTokens, Math.max(afterBudget, replyTokens + REASONING_HEADROOM[effort]));
6767
}

‎supabase/functions/ai-stream/index.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,11 +344,14 @@ export async function handleRequest(req: Request): Promise<Response> {
344344
throw new Error("OpenRouter stream ended before completion");
345345
}
346346

347-
// The text already reached the reader, so a cut-off answer cannot be
348-
// retried. The flag tells the user it stopped short.
347+
// A cut-off answer that already reached the reader cannot be retried;
348+
// the flag tells the user it stopped short. One that spent the whole
349+
// cap on reasoning sent nothing, so it fails and the client asks the
350+
// structured endpoint, which retries with a larger cap.
349351
const replyTruncated = streamFinishReason === "length";
350352
if (replyTruncated) {
351353
console.warn(`[AI_STREAM] Reply hit its ask cap (${REPLY_TOKEN_CAPS.ask} tokens, ${model}, effort ${reasoningEffort})`);
354+
if (!accumulated.trim()) throw new Error("OpenRouter reply was cut off before any text");
352355
}
353356

354357
const output = {

‎supabase/functions/ai-stream/index_test.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ for (const [key, value] of Object.entries({
1414

1515
const { handleRequest } = await import("./index.ts");
1616

17-
type Scenario = "complete" | "audit-error" | "truncated" | "provider-error" | "provider-error-after-finish" | "aborted" | "aborted-after-finish";
17+
type Scenario = "complete" | "audit-error" | "truncated" | "cut-off" | "cut-off-empty" | "provider-error" | "provider-error-after-finish" | "aborted" | "aborted-after-finish";
1818

1919
const usage = {
2020
prompt_tokens: 12,
@@ -51,6 +51,15 @@ function framesFor(scenario: Exclude<Scenario, "aborted">): string {
5151
usageFrame,
5252
].join("");
5353
}
54+
// Stopped at max_tokens: after some text, or after reasoning only.
55+
if (scenario === "cut-off" || scenario === "cut-off-empty") {
56+
return [
57+
...(scenario === "cut-off" ? [`data: ${JSON.stringify({ choices: [{ delta: { content: "Partial" } }] })}\n\n`] : []),
58+
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "length" }] })}\n\n`,
59+
usageFrame,
60+
"data: [DONE]\n\n",
61+
].join("");
62+
}
5463
if (scenario === "provider-error-after-finish") {
5564
return [
5665
usageFrame,
@@ -145,7 +154,7 @@ async function runScenario(scenario: Scenario) {
145154
}
146155

147156
Deno.test("OpenRouter stream accounting bills only completed work", async () => {
148-
for (const scenario of ["complete", "audit-error", "truncated", "provider-error", "provider-error-after-finish", "aborted", "aborted-after-finish"] as const) {
157+
for (const scenario of ["complete", "audit-error", "cut-off", "truncated", "cut-off-empty", "provider-error", "provider-error-after-finish", "aborted", "aborted-after-finish"] as const) {
149158
const result = await runScenario(scenario);
150159
if (result.providerCalls !== 1) throw new Error(`${scenario}: expected one provider call, got ${result.providerCalls}`);
151160
if (result.rows.length !== 1) throw new Error(`${scenario}: expected one audit insert, got ${result.rows.length}`);
@@ -160,10 +169,14 @@ Deno.test("OpenRouter stream accounting bills only completed work", async () =>
160169
throw new Error(`${scenario}: raw provider usage was lost: ${JSON.stringify(row)}`);
161170
}
162171

163-
if (scenario === "complete" || scenario === "audit-error") {
172+
if (scenario === "complete" || scenario === "audit-error" || scenario === "cut-off") {
164173
if (row.status !== "success" || row.cost_usd !== usage.cost || !result.body.includes("event: final")) {
165174
throw new Error(`complete stream was not billed: ${JSON.stringify(row)}\n${result.body}`);
166175
}
176+
// A partial answer is kept and flagged; a full one is not.
177+
if (result.body.includes('"replyTruncated":true') !== (scenario === "cut-off")) {
178+
throw new Error(`${scenario}: wrong cut-off flag\n${result.body}`);
179+
}
167180
continue;
168181
}
169182

0 commit comments

Comments
 (0)