mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +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>
247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
/**
|
|
* v0.28: tests for the unified model resolver. Pure-function-style tests using
|
|
* a tiny stub engine — no DB, no PGLite, no Postgres needed.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
resolveModel,
|
|
resolveAlias,
|
|
DEFAULT_ALIASES,
|
|
TIER_DEFAULTS,
|
|
isAnthropicProvider,
|
|
_resetDeprecationWarningsForTest,
|
|
} from '../src/core/model-config.ts';
|
|
|
|
class StubEngine {
|
|
readonly kind = 'pglite' as const;
|
|
private cfg = new Map<string, string>();
|
|
set(key: string, value: string) { this.cfg.set(key, value); }
|
|
async getConfig(key: string) { return this.cfg.get(key) ?? null; }
|
|
// unused stubs to satisfy the BrainEngine duck-type at the resolveModel boundary
|
|
async setConfig() {}
|
|
}
|
|
|
|
let stub: StubEngine;
|
|
let stderrCapture: string;
|
|
const origWrite = process.stderr.write.bind(process.stderr);
|
|
|
|
beforeEach(() => {
|
|
stub = new StubEngine();
|
|
stderrCapture = '';
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
stderrCapture += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString();
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
delete process.env.GBRAIN_MODEL;
|
|
_resetDeprecationWarningsForTest();
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.stderr.write = origWrite;
|
|
});
|
|
|
|
describe('resolveAlias', () => {
|
|
test('built-in aliases resolve to full ids', async () => {
|
|
expect(await resolveAlias(null, 'opus')).toBe(DEFAULT_ALIASES.opus);
|
|
expect(await resolveAlias(null, 'sonnet')).toBe(DEFAULT_ALIASES.sonnet);
|
|
expect(await resolveAlias(null, 'haiku')).toBe(DEFAULT_ALIASES.haiku);
|
|
});
|
|
|
|
test('unknown alias passes through (treats as full id)', async () => {
|
|
expect(await resolveAlias(null, 'claude-experimental-9000')).toBe('claude-experimental-9000');
|
|
});
|
|
|
|
test('user-defined alias overrides built-in', async () => {
|
|
stub.set('models.aliases.opus', 'claude-opus-4-7-1m');
|
|
expect(await resolveAlias(stub as never, 'opus')).toBe('claude-opus-4-7-1m');
|
|
});
|
|
|
|
test('cycle in aliases breaks at depth 2', async () => {
|
|
stub.set('models.aliases.a', 'b');
|
|
stub.set('models.aliases.b', 'a');
|
|
const result = await resolveAlias(stub as never, 'a');
|
|
expect(typeof result).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('resolveModel — 6-tier precedence', () => {
|
|
test('CLI flag wins over everything', async () => {
|
|
stub.set('models.dream.synthesize', 'sonnet');
|
|
stub.set('models.default', 'opus');
|
|
process.env.GBRAIN_MODEL = 'haiku';
|
|
const m = await resolveModel(stub as never, {
|
|
cliFlag: 'gemini',
|
|
configKey: 'models.dream.synthesize',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.gemini);
|
|
});
|
|
|
|
test('new-key config wins over deprecated key, deprecated key wins over default', async () => {
|
|
stub.set('models.dream.synthesize', 'opus');
|
|
stub.set('dream.synthesize.model', 'sonnet');
|
|
stub.set('models.default', 'haiku');
|
|
const m = await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
deprecatedConfigKey: 'dream.synthesize.model',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.opus);
|
|
expect(stderrCapture).toContain('deprecated config "dream.synthesize.model" ignored');
|
|
});
|
|
|
|
test('deprecated key honored when new key absent (with warning)', async () => {
|
|
stub.set('dream.synthesize.model', 'opus');
|
|
const m = await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
deprecatedConfigKey: 'dream.synthesize.model',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.opus);
|
|
expect(stderrCapture).toContain('deprecated config "dream.synthesize.model" honored');
|
|
});
|
|
|
|
test('global default used when per-key keys absent', async () => {
|
|
stub.set('models.default', 'opus');
|
|
const m = await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.opus);
|
|
});
|
|
|
|
test('env var used when no config set', async () => {
|
|
process.env.GBRAIN_MODEL = 'haiku';
|
|
const m = await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.haiku);
|
|
});
|
|
|
|
test('hardcoded fallback last', async () => {
|
|
const m = await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.sonnet);
|
|
});
|
|
|
|
test('deprecation warning fires once per process per key', async () => {
|
|
stub.set('dream.synthesize.model', 'opus');
|
|
await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
deprecatedConfigKey: 'dream.synthesize.model',
|
|
fallback: 'sonnet',
|
|
});
|
|
const firstWarn = stderrCapture;
|
|
stderrCapture = '';
|
|
await resolveModel(stub as never, {
|
|
configKey: 'models.dream.synthesize',
|
|
deprecatedConfigKey: 'dream.synthesize.model',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(firstWarn).toContain('deprecated config');
|
|
expect(stderrCapture).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('resolveModel — v0.31.12 tier system', () => {
|
|
test('models.default beats tier override', async () => {
|
|
stub.set('models.default', 'opus');
|
|
stub.set('models.tier.reasoning', 'haiku');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'reasoning',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.opus);
|
|
});
|
|
|
|
test('models.tier.<tier> beats env + fallback', async () => {
|
|
stub.set('models.tier.reasoning', 'opus');
|
|
process.env.GBRAIN_MODEL = 'haiku';
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'reasoning',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(DEFAULT_ALIASES.opus);
|
|
});
|
|
|
|
test('TIER_DEFAULTS wins over caller fallback when no override', async () => {
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'reasoning',
|
|
fallback: 'haiku',
|
|
});
|
|
expect(m).toBe(TIER_DEFAULTS.reasoning);
|
|
});
|
|
|
|
test('v0.38 D7: tier.subagent accepts non-Anthropic models that support tools (with cost warn)', async () => {
|
|
// Pre-v0.38 the resolver hard-fell-back to TIER_DEFAULTS.subagent for any
|
|
// non-Anthropic model. v0.38 (D6/D7) replaces that with a capability check:
|
|
// OpenAI/Gemini/etc. support tools → resolved unchanged + warn about
|
|
// missing prompt caching (cost regression on long loops, not a refusal).
|
|
stub.set('models.default', 'openai:gpt-5.2');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'subagent',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe('openai:gpt-5.2');
|
|
expect(stderrCapture).toContain('caching');
|
|
});
|
|
|
|
test('v0.38 D7: tier.subagent rejects unknown providers (falls back to default)', async () => {
|
|
// Unknown providers fail the capability check (verdict='unknown'); the
|
|
// resolver falls back to TIER_DEFAULTS.subagent rather than burn money on
|
|
// an unverified model.
|
|
stub.set('models.tier.subagent', 'madeup-provider:weird-model');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'subagent',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(TIER_DEFAULTS.subagent);
|
|
expect(stderrCapture).toContain('tier.subagent');
|
|
});
|
|
|
|
test('tier.subagent accepts explicit Anthropic override', async () => {
|
|
stub.set('models.tier.subagent', 'anthropic:claude-opus-4-7');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'subagent',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe('anthropic:claude-opus-4-7');
|
|
expect(stderrCapture).toBe('');
|
|
});
|
|
|
|
test('isAnthropicProvider matches provider-prefixed and bare claude-* ids', () => {
|
|
expect(isAnthropicProvider('anthropic:claude-sonnet-4-6')).toBe(true);
|
|
expect(isAnthropicProvider('claude-opus-4-7')).toBe(true);
|
|
expect(isAnthropicProvider('openai:gpt-5.5')).toBe(false);
|
|
expect(isAnthropicProvider('gemini-3-pro')).toBe(false);
|
|
expect(isAnthropicProvider('')).toBe(false);
|
|
});
|
|
|
|
test('v0.41.20.0: isAnthropicProvider classifies slash-form (subagent-guard fix)', () => {
|
|
// Pre-fix: 'anthropic/claude-sonnet-4-6' had no colon and didn't start
|
|
// with 'claude-' (started with 'anthropic') → returned false → silent
|
|
// subagent-guard bypass → fall back to TIER_DEFAULTS.subagent without
|
|
// honoring the user's explicit slash-form config.
|
|
expect(isAnthropicProvider('anthropic/claude-sonnet-4-6')).toBe(true);
|
|
expect(isAnthropicProvider('anthropic/claude-opus-4-7')).toBe(true);
|
|
// Non-Anthropic slash forms STILL return false (don't accidentally
|
|
// widen the guard).
|
|
expect(isAnthropicProvider('openai/gpt-5')).toBe(false);
|
|
expect(isAnthropicProvider('google/gemini-3-pro')).toBe(false);
|
|
});
|
|
|
|
test('alias-chain conflict: forward + reverse for same id (Codex F6)', async () => {
|
|
// Codex F6: if both forward and reverse aliases exist, depth cap (2)
|
|
// prevents infinite loop. Canonicalization is deterministic — terminates
|
|
// and returns a valid string, no NaN/undefined fall-through.
|
|
stub.set('models.aliases.claude-sonnet-4-6', 'claude-sonnet-5');
|
|
stub.set('models.aliases.claude-sonnet-5', 'claude-sonnet-4-6');
|
|
const result = await resolveAlias(stub as never, 'claude-sonnet-4-6');
|
|
expect(typeof result).toBe('string');
|
|
expect(result.length).toBeGreaterThan(0);
|
|
});
|
|
});
|