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>
97 lines
4.0 KiB
TypeScript
97 lines
4.0 KiB
TypeScript
/**
|
|
* v0.41.21.0 — gateway-resolver parseModelId accepts slash form.
|
|
*
|
|
* Codex adversarial review caught a load-bearing gap: the v0.41.21.0
|
|
* pricing-side fix (`src/core/model-id.ts:splitProviderModelId`) let
|
|
* BudgetTracker pass for slash-form ids, but `gateway.chat()` then routed
|
|
* through `src/core/ai/model-resolver.ts:parseModelId` which still hard-
|
|
* rejected no-colon ids → AIConfigError mid-judge → judge_failed for the
|
|
* end-to-end user. The fix here extends model-resolver.ts:parseModelId to
|
|
* also accept slash form, completing the end-to-end bug class closure.
|
|
*
|
|
* Bare names without ANY separator STILL throw — gateway routing always
|
|
* needs an explicit provider. This file pins both:
|
|
* - slash form parses successfully
|
|
* - bare names still throw (back-compat)
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { parseModelId, resolveRecipe } from '../../src/core/ai/model-resolver.ts';
|
|
import { AIConfigError } from '../../src/core/ai/errors.ts';
|
|
|
|
describe('model-resolver parseModelId (gateway-side)', () => {
|
|
describe('happy paths', () => {
|
|
test('colon form parses (back-compat)', () => {
|
|
expect(parseModelId('anthropic:claude-sonnet-4-6')).toEqual({
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet-4-6',
|
|
});
|
|
});
|
|
|
|
test('slash form parses (THE END-TO-END FIX)', () => {
|
|
// Pre-v0.41.21.0: threw AIConfigError "missing a provider prefix"
|
|
// → brainstorm/lsd judge_failed despite pricing fix.
|
|
expect(parseModelId('anthropic/claude-sonnet-4-6')).toEqual({
|
|
providerId: 'anthropic',
|
|
modelId: 'claude-sonnet-4-6',
|
|
});
|
|
});
|
|
|
|
test('colon wins over slash (OpenRouter nested-id semantic)', () => {
|
|
// openrouter:anthropic/claude-... → transport=openrouter, model
|
|
// includes the nested anthropic/ prefix verbatim.
|
|
expect(parseModelId('openrouter:anthropic/claude-sonnet-4.6')).toEqual({
|
|
providerId: 'openrouter',
|
|
modelId: 'anthropic/claude-sonnet-4.6',
|
|
});
|
|
});
|
|
|
|
test('provider name is lowercased', () => {
|
|
expect(parseModelId('Anthropic/claude-sonnet-4-6').providerId).toBe('anthropic');
|
|
expect(parseModelId('OPENAI:gpt-5').providerId).toBe('openai');
|
|
});
|
|
});
|
|
|
|
describe('reject paths preserved', () => {
|
|
test('bare name with NO separator throws', () => {
|
|
expect(() => parseModelId('claude-sonnet-4-6')).toThrow(AIConfigError);
|
|
expect(() => parseModelId('claude-sonnet-4-6')).toThrow(/missing a provider prefix/);
|
|
});
|
|
|
|
test('empty string throws', () => {
|
|
expect(() => parseModelId('')).toThrow(AIConfigError);
|
|
});
|
|
|
|
test('null/undefined throws', () => {
|
|
expect(() => parseModelId(null as unknown as string)).toThrow(AIConfigError);
|
|
expect(() => parseModelId(undefined as unknown as string)).toThrow(AIConfigError);
|
|
});
|
|
|
|
test('trailing-only separator throws (empty model)', () => {
|
|
expect(() => parseModelId('anthropic:')).toThrow(AIConfigError);
|
|
expect(() => parseModelId('anthropic/')).toThrow(AIConfigError);
|
|
});
|
|
|
|
test('leading-only separator throws (empty provider)', () => {
|
|
expect(() => parseModelId(':claude-sonnet-4-6')).toThrow(AIConfigError);
|
|
expect(() => parseModelId('/claude-sonnet-4-6')).toThrow(AIConfigError);
|
|
});
|
|
});
|
|
|
|
describe('resolveRecipe end-to-end with slash form', () => {
|
|
test('slash form resolves to the same recipe as colon form', () => {
|
|
const colonResult = resolveRecipe('anthropic:claude-sonnet-4-6');
|
|
const slashResult = resolveRecipe('anthropic/claude-sonnet-4-6');
|
|
expect(slashResult.recipe.id).toBe(colonResult.recipe.id);
|
|
expect(slashResult.parsed.providerId).toBe(colonResult.parsed.providerId);
|
|
expect(slashResult.parsed.modelId).toBe(colonResult.parsed.modelId);
|
|
});
|
|
|
|
test('slash form gives the expected recipe for opus', () => {
|
|
const result = resolveRecipe('anthropic/claude-opus-4-7');
|
|
expect(result.parsed.providerId).toBe('anthropic');
|
|
expect(result.parsed.modelId).toBe('claude-opus-4-7');
|
|
});
|
|
});
|
|
});
|