mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: canonical Anthropic model IDs + reverse alias + Opus 4.7 pricing
Replace claude-sonnet-4-6-20250929 with claude-sonnet-4-6 everywhere it
appears as a model ID. Starting with Claude 4.6, Anthropic API IDs are
dateless and pinned — the date suffix was carried forward from Sonnet 4.5
by mistake, producing a phantom ID that 404'd on every call.
Production impact in v0.31.6: isAvailable("chat") returned false in every
code path that loaded the recipe's model list, and extractFactsFromTurn
silently returned []. The headline real-time facts extraction feature
was a no-op on the happy path.
- gateway.ts:46 DEFAULT_CHAT_MODEL -> anthropic:claude-sonnet-4-6
- recipes/anthropic.ts: chat + expansion model lists drop date suffix;
remove wrong-direction alias (claude-sonnet-4-6 -> -20250929);
add reverse alias (-20250929 -> claude-sonnet-4-6) so stale user
configs in models.dream.synthesize etc. keep working
- facts/extract.ts: routes through resolveModel; both fallbacks corrected
- anthropic-pricing.ts: Opus 4.7 corrected $15/$75 -> $5/$25 per
Anthropic docs (the $15/$75 was Opus 4.0 pricing)
- cross-modal-eval/runner.ts: PRICING now reads from ANTHROPIC_PRICING
for Anthropic models instead of duplicating the map (single source of
truth — fixes the drift trap that motivated this whole patch)
Tests: cherry-pick PR #830's test/anthropic-model-ids.test.ts verbatim
(6 recipe-shape guardrails). Update gateway-chat tests to assert reverse
alias resolves correctly. Update budget-meter test for new Opus pricing.
Co-Authored-By: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: model tier system + recipe-models merge + async reconfigure hook
Add 4-tier model routing (utility/reasoning/deep/subagent) so users can
swap defaults with one config key. Each tier maps to a class of work;
override globally via models.default or per-tier via models.tier.<tier>.
Codex flagged three real architecture issues in the v0.31.12 plan review;
this commit addresses each.
F3 — sync/async timing of configureGateway:
- buildGatewayConfig stays synchronous (pre-engine-connect callers
keep working)
- New reconfigureGatewayWithEngine(engine) async function re-resolves
expansion + chat defaults through resolveModel after engine.connect()
- cli.ts wires the re-stamp into the post-connect path
F4/F5 — softening assertTouchpoint was too broad:
- Earlier plan was to flip native-recipe validation from throw to warn,
affecting gateway.chat AND gateway.expand AND gateway.embed
- Instead: per-gateway-instance recipe-models merge. assertTouchpoint
gets an optional extendedModels Set; when the user opted into a model
via config, it bypasses the throw. Source-code typos still fail fast.
- Existing contract test (test/ai/gateway-chat.test.ts:106) preserved
Tier defaults are TIER_DEFAULTS in model-config.ts. Resolution chain
inserts at step 5 (between models.default and env var). Each existing
resolveModel call site gains a tier: arg — think (deep), cycle/synthesize
(reasoning + utility for verdict), patterns/drift (reasoning), auto-think
(deep), facts/extract (reasoning).
Plus 10 new tests pinning tier precedence, subagent-tier fallback when
models.default is non-Anthropic, and the F6 alias-chain conflict case.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: subagent runtime enforcement for non-Anthropic models (3 layers)
The subagent loop uses Anthropic's Messages API with prompt caching on
system + tools. OpenAI/Google have different shapes. Setting
models.default = openai:gpt-5.5 and routing the subagent there silently
breaks the loop.
Codex F1+F2+F13 in the v0.31.12 plan review pointed out that "warn at
doctor" wasn't enough — handlers/subagent.ts:148 still did
`const model = data.model ?? DEFAULT_MODEL` and called Anthropic directly,
so a job submitted with data.model = openai:gpt-5.5 bypassed any tier
logic and failed at runtime with a confusing provider error.
Three layers of enforcement, defense in depth:
Layer 1 (queue.ts:add) — submit-time guard. When name === 'subagent'
and data.model is set, validate the provider. Non-Anthropic rejects
before the job enters the queue.
Layer 2 (handlers/subagent.ts) — tier-resolution fallback. The handler
routes through resolveModel({ tier: 'subagent' }). If the chain resolves
to a non-Anthropic provider (via models.default or models.tier.subagent),
the resolver warns + falls back to TIER_DEFAULTS.subagent
(claude-sonnet-4-6).
Layer 3 (doctor.ts:checkSubagentProvider) — surfacing layer. Warns when
models.tier.subagent or models.default is explicitly set to a
non-Anthropic provider, with a paste-ready fix command. Lets users see
config drift before submitting a job.
Tests: 3 new cases in test/agent-cli.test.ts asserting the queue-level
guard rejects non-Anthropic data.model. Existing test/subagent-handler
suite still passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: gbrain models CLI + doctor probe + silent-no-op regression test
New gbrain models CLI gives the agent and user visibility into routing.
Read mode prints the tier table, current overrides, per-task config,
and aliases with source-of-truth attribution per row. Doctor subcommand
fires a 1-token probe to each configured chat/expansion model and
classifies failures (model_not_found / auth / rate_limit / network /
unknown) so config-time invalid IDs surface without waiting for a
production call that silently degrades.
Per Codex F11 — no specific dollar cost claim in either the help text
or the CHANGELOG (providers have minimum-output billing and prompt-cache
rounding that vary). Probe is opt-in (gbrain doctor --probe-models),
never auto-runs. --skip=<provider> narrows the matrix for cost-sensitive
operators.
Per Codex F7+F8+F15 (the structural regression gap): new
test/facts-extract-silent-no-op.test.ts is THE regression test for the
bug class that motivated v0.31.12. Five cases including the smoking-gun:
when chat IS available, extractFactsFromTurn MUST actually call the chat
transport, not silently return []. Uses the gateway's
__setChatTransportForTests seam so it runs in every shard with no API key.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.31.12)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: document v0.31.12 model tier system + gbrain models CLI
Add CLAUDE.md Key Files annotations for the v0.31.12 work:
src/core/model-config.ts (tier system + isAnthropicProvider + TIER_DEFAULTS),
src/core/ai/model-resolver.ts (assertTouchpoint extendedModels arg),
src/core/ai/gateway.ts (reconfigureGatewayWithEngine + extended-models registry),
src/core/minions/queue.ts (subagent submit-time guard, layer 1 of 3),
src/commands/models.ts (new gbrain models CLI + doctor probe),
src/commands/doctor.ts (subagent_provider check, layer 3 of 3),
src/core/ai/recipes/anthropic.ts (canonical model IDs + reverse alias),
src/core/anthropic-pricing.ts (Opus 4.7 corrected to \$5/\$25).
Add CLAUDE.md commands section for gbrain models + gbrain models doctor
+ power-user config recipes. Add README.md command-table rows for the
same. Regenerate llms-full.txt so the bundled docs stay in sync.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: scrub --probe-models reference (flag not actually wired)
The v0.31.12 CHANGELOG and skills/conventions/model-routing.md both
referenced `gbrain doctor --probe-models` as an integrated probe entry
point. The flag was never implemented — only `gbrain models doctor`
landed as the probe surface. Caught by /document-release subagent.
Drop the references rather than wire an untested flag at the last minute.
The probe is reachable via `gbrain models doctor`; users who want it
in doctor's output run that command separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
8.0 KiB
TypeScript
229 lines
8.0 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('tier.subagent falls back to TIER_DEFAULTS.subagent when models.default is non-Anthropic', async () => {
|
|
stub.set('models.default', 'openai:gpt-5.5');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'subagent',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(TIER_DEFAULTS.subagent);
|
|
expect(stderrCapture).toContain('tier.subagent');
|
|
expect(stderrCapture).toContain('non-Anthropic');
|
|
expect(stderrCapture).toContain('models.default');
|
|
});
|
|
|
|
test('tier.subagent falls back when explicitly set to non-Anthropic', async () => {
|
|
stub.set('models.tier.subagent', 'openai:gpt-5.5');
|
|
const m = await resolveModel(stub as never, {
|
|
tier: 'subagent',
|
|
fallback: 'sonnet',
|
|
});
|
|
expect(m).toBe(TIER_DEFAULTS.subagent);
|
|
expect(stderrCapture).toContain('models.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('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);
|
|
});
|
|
});
|