mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* feat(core): add splitProviderModelId centralizer for pricing-side parsing
New pure helper in src/core/model-id.ts that splits provider:model,
provider/model, and bare model strings into a {provider, model} pair.
Defensive contract: null/undefined/empty/whitespace returns
{provider: null, model: ''}.
Will be wired into the 5 pricing/budget sites in the next commit.
Named splitProviderModelId (not parseModelId) to avoid the in-project
collision with the gateway-side src/core/ai/model-resolver.ts:parseModelId
which has a different bare-name contract.
Pinned by 16 cases in test/model-id.test.ts covering all separator
forms plus defensive + edge inputs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(gateway): accept slash-form provider id in model-resolver
src/core/ai/model-resolver.ts:parseModelId now accepts both
provider:model (colon) and provider/model (slash) forms. Colon wins
when both separators present so OpenRouter nested ids like
openrouter:anthropic/claude-sonnet-4.6 route as
{providerId: 'openrouter', modelId: 'anthropic/claude-sonnet-4.6'}.
Pre-fix: every gateway entry point (chat / embed / rerank) threw
AIConfigError 'missing a provider prefix' on slash form ids. That
meant CLI users running
gbrain brainstorm --judge-model anthropic/claude-sonnet-4-6
would still fail mid-judge with AIConfigError even after pricing
was relaxed to accept slash form. Closes the end-to-end bug class.
Bare names without ANY separator still throw — gateway routing
always needs an explicit provider. Existing tests pinning that
throw (test/ai/capabilities.test.ts:43) stay green.
Pinned by 10 cases in test/ai/model-resolver-slash.test.ts
including a resolveRecipe round-trip that slash and colon forms
land on the same recipe.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor: route 5 pricing/config sites through splitProviderModelId
Five sites had inline ':'-only provider-prefix splits that silently
missed slash-form ids. Centralizing through splitProviderModelId
closes the bug class:
- src/core/anthropic-pricing.ts:estimateMaxCostUsd
- src/core/budget/budget-tracker.ts:lookupPricing (closes the
headline BudgetExhausted no_pricing failure on --max-cost +
slash-form --judge-model)
- src/core/eval-contradictions/cost-tracker.ts:pricingFor
(legacy silent-Haiku fallback preserved per plan D9)
- src/core/minions/batch-projection.ts (deleted bareModel inline
helper; inlined splitProviderModelId at 2 call sites)
- src/core/model-config.ts:isAnthropicProvider (silently fixed
v0.31.12 subagent-guard bypass for slash-form Anthropic ids)
Test gates land together so any bisect step is green:
- NEW test/anthropic-pricing.test.ts (7 cases including structural
regression guard: every ANTHROPIC_PRICING key reachable via all
three forms)
- NEW test/eval-contradictions/cost-tracker-slash.test.ts (6 cases
including legacy-Haiku-fallback pin)
- EXTENDED test/batch-projection.test.ts (slash + double-separator
cases)
- EXTENDED test/model-config.serial.test.ts (2 slash-form
isAnthropicProvider cases)
- EXTENDED test/core/budget/budget-tracker.test.ts (2 slash + colon
reserve() cases)
Behavior changes for slash-prefix ids only; bare and colon ids
unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(brainstorm): scale judge maxTokens with per-model output cap
Replace the hard-coded maxTokens: 4000 with computeJudgeMaxTokens
that scales with idea count and respects each model's actual output
cap.
Pre-fix: any judge call with 36+ ideas produced ~100 tokens/idea of
JSON that got truncated mid-output. parseJudgeJSON threw, orchestrator
surfaced judge_failed: true, all ideas saved unscored. Verified
failure mode on 72-idea fixture: 0/72 passing before, 39/72 after.
Formula: min(modelCap, max(LEGACY_MIN_MAX_TOKENS, ideaCount*150+500))
Named constants extracted at top of judges.ts:
- TOKEN_BUDGET_PER_IDEA = 150 (1.5x headroom over observed ~100/idea)
- TOKEN_BUDGET_ENVELOPE = 500 (JSON wrapper)
- LEGACY_MIN_MAX_TOKENS = 4000 (pre-fix floor preserved for 1-idea)
- MAX_OUTPUT_TOKENS_CEIL = 32_000 (fallback when model unknown)
- ANTHROPIC_OUTPUT_CAPS (per-model: Opus 4.7 = 32K, Sonnet 4.6 /
Haiku 4.5 = 64K, legacy 3.5 = 8K)
When the caller passes no modelOverride, the cap routes through the
gateway's actual configured chat model via getChatModel() so the
formula matches what chat() will use, not whatever the override
hints at. Pre-fix the undefined-override case fell back to 32K even
if the configured default was a legacy 8K model.
Pinned by 16 cases in test/brainstorm/judges-maxtokens.test.ts:
formula at 1/10/36/96/200/300 ideas, per-model cap binding (Haiku 3.5
8K, Opus 4.7 32K, Sonnet 4.6 64K), and integration via runJudge with
a stubbed chatFn that captures ChatOpts.maxTokens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: bump version and changelog (v0.41.21.0)
Brainstorm judge fix-wave: closes #1540 end-to-end. parseModelId
centralizer + gateway resolver slash-form acceptance + per-model
maxTokens cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: update project documentation for v0.41.21.0
CLAUDE.md: add v0.41.21.0 annotations to brainstorm/judges + model-config
entries; add new key-files entry for src/core/model-id.ts (the shared
splitProviderModelId centralizer) and src/core/ai/model-resolver.ts
slash-form extension.
README.md: add user-facing callout for the brainstorm judge_failed +
slash-form pricing fix, mirroring the v0.41.19.0 callout shape.
llms-full.txt: regenerated to absorb the CLAUDE.md + README changes
(passes test/build-llms.test.ts drift guard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
/**
|
|
* v0.41.20.0 — pin the judge maxTokens scaling formula + per-model cap.
|
|
*
|
|
* Bug 1 from #1540: `maxTokens: 4000` hard-coded in judges.ts → response
|
|
* truncated mid-JSON at any chunk ≥ ~40 ideas → parseJudgeJSON throw →
|
|
* judge_failed: true → ideas saved unscored. Verified failure mode:
|
|
* 0/72 ideas passing before fix; 39/72 after.
|
|
*
|
|
* The fix is in two pieces and this file pins both:
|
|
*
|
|
* 1. `computeJudgeMaxTokens` (pure formula) — scales with idea count,
|
|
* respects per-model output cap, floors at 4000 for tiny batches.
|
|
* 2. `runJudgeChunk` wires the formula into the `chat({maxTokens})` call
|
|
* — integration test via the existing `chatFn` DI seam captures the
|
|
* ChatOpts and asserts maxTokens matches the formula.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import {
|
|
computeJudgeMaxTokens,
|
|
TOKEN_BUDGET_PER_IDEA,
|
|
TOKEN_BUDGET_ENVELOPE,
|
|
LEGACY_MIN_MAX_TOKENS,
|
|
MAX_OUTPUT_TOKENS_CEIL,
|
|
ANTHROPIC_OUTPUT_CAPS,
|
|
runJudge,
|
|
BRAINSTORM_JUDGE_CONFIG,
|
|
type ChatFn,
|
|
} from '../../src/core/brainstorm/judges.ts';
|
|
import type { ChatOpts, ChatResult } from '../../src/core/ai/gateway.ts';
|
|
|
|
describe('computeJudgeMaxTokens (pure formula)', () => {
|
|
test('1 idea: formula yields 650 → floor binds at LEGACY_MIN_MAX_TOKENS (4000)', () => {
|
|
// 1 * 150 + 500 = 650; max(4000, 650) = 4000.
|
|
expect(computeJudgeMaxTokens(1, 'claude-sonnet-4-6')).toBe(LEGACY_MIN_MAX_TOKENS);
|
|
});
|
|
|
|
test('10 ideas: formula yields 2000 → floor binds', () => {
|
|
// 10 * 150 + 500 = 2000; max(4000, 2000) = 4000.
|
|
expect(computeJudgeMaxTokens(10, 'claude-sonnet-4-6')).toBe(LEGACY_MIN_MAX_TOKENS);
|
|
});
|
|
|
|
test('36 ideas: formula yields 5900 → above floor', () => {
|
|
// 36 * 150 + 500 = 5900; max(4000, 5900) = 5900; below 64K Sonnet cap.
|
|
expect(computeJudgeMaxTokens(36, 'claude-sonnet-4-6')).toBe(36 * TOKEN_BUDGET_PER_IDEA + TOKEN_BUDGET_ENVELOPE);
|
|
});
|
|
|
|
test('96 ideas: formula yields 14_900 → above floor, under modern cap', () => {
|
|
// 96 * 150 + 500 = 14_900; below 32K Opus cap.
|
|
expect(computeJudgeMaxTokens(96, 'claude-opus-4-7')).toBe(96 * TOKEN_BUDGET_PER_IDEA + TOKEN_BUDGET_ENVELOPE);
|
|
});
|
|
|
|
test('300 ideas on Opus 4.7: formula yields 45_500 → CAP binds at 32K', () => {
|
|
// 300 * 150 + 500 = 45_500; min(32_000, 45_500) = 32_000.
|
|
expect(computeJudgeMaxTokens(300, 'claude-opus-4-7')).toBe(32_000);
|
|
});
|
|
|
|
test('300 ideas on Sonnet 4.6: formula yields 45_500 → fits under 64K Sonnet cap', () => {
|
|
expect(computeJudgeMaxTokens(300, 'claude-sonnet-4-6')).toBe(300 * TOKEN_BUDGET_PER_IDEA + TOKEN_BUDGET_ENVELOPE);
|
|
});
|
|
|
|
test('96 ideas on legacy Haiku 3.5 (8K cap): CAP binds at 8192 (D11 codex fix)', () => {
|
|
// 96 * 150 + 500 = 14_900 > 8192; legacy 3.5 caps at 8K — without
|
|
// ANTHROPIC_OUTPUT_CAPS this would have been the next opaque HTTP 400.
|
|
expect(computeJudgeMaxTokens(96, 'claude-3-5-haiku-20241022')).toBe(8_192);
|
|
});
|
|
|
|
test('unknown model: falls back to MAX_OUTPUT_TOKENS_CEIL', () => {
|
|
expect(computeJudgeMaxTokens(300, 'mistral:medium')).toBe(MAX_OUTPUT_TOKENS_CEIL);
|
|
expect(computeJudgeMaxTokens(300, 'gpt-5')).toBe(MAX_OUTPUT_TOKENS_CEIL);
|
|
});
|
|
|
|
// v0.41.21.0: when modelId is undefined the cap routes through the gateway's
|
|
// configured chat model via getChatModel(). The actual returned cap therefore
|
|
// depends on whether the gateway has been initialized in this test process
|
|
// (cross-test side effect of any earlier import that called configureGateway).
|
|
// We test the explicit-modelId path comprehensively above; the undefined path
|
|
// is exercised end-to-end below via runJudge() without modelOverride.
|
|
|
|
test('colon-prefixed id resolves through splitProviderModelId', () => {
|
|
expect(computeJudgeMaxTokens(96, 'anthropic:claude-opus-4-7')).toBe(96 * 150 + 500);
|
|
});
|
|
|
|
test('slash-prefixed id resolves through splitProviderModelId (THE FIX combined with site routing)', () => {
|
|
expect(computeJudgeMaxTokens(96, 'anthropic/claude-opus-4-7')).toBe(96 * 150 + 500);
|
|
// Legacy 3.5 via slash form still hits the 8K cap.
|
|
expect(computeJudgeMaxTokens(96, 'anthropic/claude-3-5-haiku-20241022')).toBe(8_192);
|
|
});
|
|
|
|
test('every entry in ANTHROPIC_OUTPUT_CAPS is reachable by lookup', () => {
|
|
for (const [key, cap] of Object.entries(ANTHROPIC_OUTPUT_CAPS)) {
|
|
// Pick an idea count high enough that the cap binds.
|
|
const huge = Math.ceil(cap / TOKEN_BUDGET_PER_IDEA) + 10;
|
|
expect(computeJudgeMaxTokens(huge, key)).toBe(cap);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('runJudge wires computeJudgeMaxTokens into chat({maxTokens})', () => {
|
|
function makeCapturingChatFn(captured: ChatOpts[]): ChatFn {
|
|
return async (opts: ChatOpts): Promise<ChatResult> => {
|
|
captured.push(opts);
|
|
// Return a valid-shape judge response so parseJudgeJSON succeeds and
|
|
// we can pin maxTokens without dealing with parse failures.
|
|
const ideasJson = (opts.messages[0].content as string)
|
|
.match(/^- id=(\S+)/gm)
|
|
?.map((line) => line.replace(/^- id=/, '')) ?? [];
|
|
const ideas = ideasJson.map((id) => ({
|
|
id,
|
|
scores: {
|
|
originality: 3,
|
|
resistance: 3,
|
|
thesis_density: 3,
|
|
concrete_grounding: 3,
|
|
cognitive_load: 3,
|
|
},
|
|
note: 'stub',
|
|
}));
|
|
const text = JSON.stringify({ ideas });
|
|
return {
|
|
text,
|
|
blocks: [{ type: 'text', text }],
|
|
stopReason: 'end',
|
|
model: opts.model ?? 'noop',
|
|
providerId: 'anthropic',
|
|
usage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
};
|
|
};
|
|
}
|
|
|
|
function makeIdeas(n: number) {
|
|
return Array.from({ length: n }, (_, i) => ({
|
|
id: String(i + 1).padStart(2, '0'),
|
|
text: 'stub idea text',
|
|
close_slug: 'concepts/foo',
|
|
far_slug: 'wiki/bar',
|
|
}));
|
|
}
|
|
|
|
test('1 idea → maxTokens = LEGACY_MIN_MAX_TOKENS (4000)', async () => {
|
|
const captured: ChatOpts[] = [];
|
|
await runJudge(BRAINSTORM_JUDGE_CONFIG, makeIdeas(1), {
|
|
chatFn: makeCapturingChatFn(captured),
|
|
modelOverride: 'claude-sonnet-4-6',
|
|
});
|
|
expect(captured.length).toBe(1);
|
|
expect(captured[0].maxTokens).toBe(LEGACY_MIN_MAX_TOKENS);
|
|
});
|
|
|
|
test('96 ideas on Opus 4.7 → maxTokens = formula (14_900)', async () => {
|
|
const captured: ChatOpts[] = [];
|
|
await runJudge(BRAINSTORM_JUDGE_CONFIG, makeIdeas(96), {
|
|
chatFn: makeCapturingChatFn(captured),
|
|
modelOverride: 'anthropic:claude-opus-4-7',
|
|
});
|
|
expect(captured.length).toBe(1);
|
|
expect(captured[0].maxTokens).toBe(96 * 150 + 500);
|
|
});
|
|
|
|
test('slash-form modelOverride routes through parseModelId for the cap lookup', async () => {
|
|
// Pre-v0.41.20.0 the inline maxTokens was a constant; this test would
|
|
// not have caught anything. Post-fix: slash-form is honored for the
|
|
// per-model cap because computeJudgeMaxTokens routes through parseModelId.
|
|
const captured: ChatOpts[] = [];
|
|
await runJudge(BRAINSTORM_JUDGE_CONFIG, makeIdeas(96), {
|
|
chatFn: makeCapturingChatFn(captured),
|
|
modelOverride: 'anthropic/claude-3-5-haiku-20241022',
|
|
});
|
|
// 14_900 formula > 8K legacy cap → cap binds.
|
|
expect(captured[0].maxTokens).toBe(8_192);
|
|
});
|
|
|
|
test('200-idea chunk size set via maxIdeasPerCall → maxTokens scales (single chunk)', async () => {
|
|
const captured: ChatOpts[] = [];
|
|
await runJudge(BRAINSTORM_JUDGE_CONFIG, makeIdeas(200), {
|
|
chatFn: makeCapturingChatFn(captured),
|
|
modelOverride: 'claude-sonnet-4-6',
|
|
maxIdeasPerCall: 200,
|
|
});
|
|
expect(captured.length).toBe(1);
|
|
expect(captured[0].maxTokens).toBe(200 * 150 + 500); // 30_500, under 64K Sonnet cap
|
|
});
|
|
|
|
test('chunking is independent of cap — multi-chunk each gets its own scaled budget', async () => {
|
|
const captured: ChatOpts[] = [];
|
|
// 250 ideas at default chunk 100 → 3 chunks of [100, 100, 50].
|
|
await runJudge(BRAINSTORM_JUDGE_CONFIG, makeIdeas(250), {
|
|
chatFn: makeCapturingChatFn(captured),
|
|
modelOverride: 'claude-sonnet-4-6',
|
|
});
|
|
expect(captured.length).toBe(3);
|
|
expect(captured[0].maxTokens).toBe(100 * 150 + 500); // 15_500
|
|
expect(captured[1].maxTokens).toBe(100 * 150 + 500);
|
|
expect(captured[2].maxTokens).toBe(Math.max(LEGACY_MIN_MAX_TOKENS, 50 * 150 + 500)); // 8000
|
|
});
|
|
});
|