fix(budgeting): add Sonnet 5, Fable 5, and Opus 4.8 to the synthesize context map and brainstorm output caps (#3727)

Wave-assembled from PR #3727 by @p3ob7o.

Co-Authored-By: Paolo Belcastro <p3ob7o@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-08-12 14:38:36 -07:00
committed by Sina Matian
co-authored by Paolo Belcastro
parent 9b6db85d39
commit cb07cfda8d
4 changed files with 99 additions and 3 deletions
+4
View File
@@ -61,6 +61,10 @@ export const MAX_OUTPUT_TOKENS_CEIL = 32_000;
* (with a readable error) instead of the provider's opaque HTTP 400.
*/
export const ANTHROPIC_OUTPUT_CAPS: Record<string, number> = {
'claude-fable-5': 64_000,
'claude-opus-5': 32_000,
'claude-sonnet-5': 64_000,
'claude-opus-4-8': 32_000,
'claude-opus-4-7': 32_000,
'claude-sonnet-4-6': 64_000,
'claude-haiku-4-5': 64_000,
+14 -3
View File
@@ -31,7 +31,7 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { chat as gatewayChat, validateModelId, type ChatResult } from '../ai/gateway.ts';
import { AIConfigError } from '../ai/errors.ts';
import { normalizeModelId } from '../model-id.ts';
import { normalizeModelId, splitProviderModelId } from '../model-id.ts';
import { hasAnthropicKey } from '../ai/anthropic-key.ts';
import { basename, join, dirname, isAbsolute, resolve } from 'node:path';
import type { BrainEngine } from '../engine.ts';
@@ -62,6 +62,10 @@ const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, '
* resolver returns for known Anthropic aliases.
*/
const MODEL_CONTEXT_TOKENS: Record<string, number> = {
'claude-fable-5': 1_000_000,
'claude-opus-5': 1_000_000,
'claude-sonnet-5': 1_000_000,
'claude-opus-4-8': 1_000_000,
'claude-opus-4-7': 1_000_000,
'claude-opus-4-6': 1_000_000,
'claude-sonnet-4-6': 200_000,
@@ -95,14 +99,21 @@ const DEFAULT_SUBAGENT_WAIT_TIMEOUT_MS = 35 * 60 * 1000;
* accumulation is out of scope for v0.30.2 (terminal-error classification
* catches turn-N blowups; per-turn budget guard is a v0.31+ follow-up).
*/
function computeChunkCharBudget(
export function computeChunkCharBudget(
model: string,
configMaxPromptTokens: number | null,
): number {
if (configMaxPromptTokens !== null) {
return Math.floor(configMaxPromptTokens * CHARS_PER_TOKEN);
}
const ctx = MODEL_CONTEXT_TOKENS[model];
// Lookup keyed on the bare model name (after prefix strip), mirroring
// ANTHROPIC_OUTPUT_CAPS in brainstorm/judges.ts: resolveModel returns
// provider-prefixed strings when TIER_DEFAULTS / config values carry a
// prefix (the current tier defaults all do), so a raw keyed lookup sent
// every tier-resolved brain to the unknown-model fallback — a 5x budget
// cut on 1M-context models.
const bare = splitProviderModelId(model).model || model;
const ctx = MODEL_CONTEXT_TOKENS[bare];
if (ctx === undefined) {
warnUnknownModelOnce(model);
return Math.floor(UNKNOWN_MODEL_BUDGET_TOKENS * CHARS_PER_TOKEN);
+27
View File
@@ -194,3 +194,30 @@ describe('runJudge wires computeJudgeMaxTokens into chat({maxTokens})', () => {
expect(captured[2].maxTokens).toBe(Math.max(LEGACY_MIN_MAX_TOKENS, 50 * 150 + 500)); // 8000
});
});
describe('current-generation model caps (Sonnet 5 / Fable 5 / Opus 4.8)', () => {
test('500 ideas on claude-sonnet-5: cap binds at its 64K entry, not the 32K unknown-model fallback', () => {
// 500 * 150 + 500 = 75_500 → capped. Without a map entry this would
// fall to MAX_OUTPUT_TOKENS_CEIL (32_000) and silently halve the
// budget on a current-generation model.
expect(computeJudgeMaxTokens(500, 'claude-sonnet-5')).toBe(ANTHROPIC_OUTPUT_CAPS['claude-sonnet-5']);
expect(ANTHROPIC_OUTPUT_CAPS['claude-sonnet-5']).toBe(64_000);
});
test('provider-prefixed id resolves to the same entry', () => {
expect(computeJudgeMaxTokens(500, 'anthropic:claude-sonnet-5')).toBe(64_000);
});
test('claude-fable-5 caps at 64K; claude-opus-4-8 at 32K', () => {
expect(computeJudgeMaxTokens(500, 'claude-fable-5')).toBe(64_000);
expect(computeJudgeMaxTokens(500, 'claude-opus-4-8')).toBe(32_000);
});
});
describe('claude-opus-5 cap', () => {
test('500 ideas on claude-opus-5: cap binds at its 32K entry (Opus-family convention), prefixed form included', () => {
expect(computeJudgeMaxTokens(500, 'claude-opus-5')).toBe(ANTHROPIC_OUTPUT_CAPS['claude-opus-5']);
expect(ANTHROPIC_OUTPUT_CAPS['claude-opus-5']).toBe(32_000);
expect(computeJudgeMaxTokens(500, 'anthropic:claude-opus-5')).toBe(32_000);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* computeChunkCharBudget — MODEL_CONTEXT_TOKENS resolution.
*
* Pins two things:
* 1. Current-generation models resolve their real context budget instead
* of the unknown-model fallback (the gap this map extension closes).
* 2. Provider-prefixed ids resolve to the same entry as bare ids.
* resolveModel returns prefixed strings when TIER_DEFAULTS / config
* values carry a prefix (the current tier defaults all do); before the
* prefix-strip fix, every tier-resolved brain silently fell to the
* 180K fallback — a 5x prompt-budget cut on 1M-context models.
*/
import { describe, test, expect } from 'bun:test';
import { computeChunkCharBudget } from '../src/core/cycle/synthesize.ts';
// Mirror the module's constants (deliberately duplicated: a silent change to
// either should fail this suite, not be absorbed by it).
const CHARS_PER_TOKEN = 3.5;
const HEADROOM_RATIO = 0.9;
const UNKNOWN_BUDGET = Math.floor(180_000 * CHARS_PER_TOKEN);
const budgetFor = (contextTokens: number) =>
Math.floor(contextTokens * HEADROOM_RATIO * CHARS_PER_TOKEN);
describe('computeChunkCharBudget — model resolution', () => {
test('current-generation 1M-context models get their real budget, not the fallback', () => {
for (const model of ['claude-sonnet-5', 'claude-fable-5', 'claude-opus-5', 'claude-opus-4-8']) {
expect(computeChunkCharBudget(model, null)).toBe(budgetFor(1_000_000));
}
});
test('provider-prefixed ids resolve to the same entry as bare ids', () => {
for (const bare of ['claude-sonnet-5', 'claude-opus-5', 'claude-sonnet-4-6']) {
expect(computeChunkCharBudget(`anthropic:${bare}`, null)).toBe(
computeChunkCharBudget(bare, null),
);
}
// The load-bearing case: a prefixed 1M-context model must NOT fall to
// the 180K unknown-model budget.
expect(computeChunkCharBudget('anthropic:claude-sonnet-5', null)).toBe(budgetFor(1_000_000));
expect(computeChunkCharBudget('anthropic:claude-sonnet-5', null)).not.toBe(UNKNOWN_BUDGET);
});
test('unknown models fall back to the conservative 180K budget', () => {
expect(computeChunkCharBudget('openai:gpt-5', null)).toBe(UNKNOWN_BUDGET);
expect(computeChunkCharBudget('some-custom-model', null)).toBe(UNKNOWN_BUDGET);
});
test('config override wins over the map', () => {
expect(computeChunkCharBudget('claude-sonnet-5', 200_000)).toBe(
Math.floor(200_000 * CHARS_PER_TOKEN),
);
});
});