Files
gbrain/test/ai/gateway-probe-chat-model.test.ts
T
5911072aec v0.42.4.0 fix: think --model fails loud — slash-form ids + never persist empty synthesis (#1698) (#1736)
* fix: think --model fails loud on unresolvable model; never persist empty synthesis (#1698)

Slash-form model ids (anthropic/claude-sonnet-4-6) silently degraded to the
no-LLM stub and wrote empty synthesis pages with exit 0 (reporter saw 200).
Three compounding defects, fixed:

- normalizeModelId (src/core/model-id.ts): one shared provider:model normalizer
  replacing 4 colon-only inlines; slash→colon, bare→default, malformed
  leading-separator (:foo) returned unchanged so resolveRecipe throws loud.
- validateModelId + probeChatModel (gateway.ts): shared id-validity + key probe;
  runThink hard-errors on an explicit --model it can't run (no silent degrade).
- synthesisOk + persist-skip (think/index.ts): empty/malformed/empty-JSON
  synthesis is never persisted; --save with no synthesis exits 1.
- auto-think (cycle/auto-think.ts): empty synthesis no longer counts complete or
  advances the cooldown (the autonomous third caller of persistSynthesis).
- hasAnthropicKey consolidated into src/core/ai/anthropic-key.ts (3 copies → 1).

MCP think op sets modelExplicit; saved_slug '' maps to null.

* chore: bump version and changelog (v0.42.4.0)

#1698 think --model fail-loud wave. Also files the P3 follow-up TODO for the
provider-symmetric early gate (D1 accept-as-is).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:54:13 -07:00

106 lines
4.1 KiB
TypeScript

/**
* #1698 — validateModelId (C1 id-validity core) + probeChatModel (= validity + key).
*
* validateModelId reads the recipe REGISTRY (not gateway _config), so it works without
* configureGateway — that's the property makeJudgeClient + tryBuildGatewayClient rely on
* (C1 #6). probeChatModel adds the key layer via hasAnthropicKey (env OR gbrain config
* file — also gateway-config-independent). Non-Anthropic providers pass the probe (lazy
* key check deferred to gateway.chat).
*
* Hermetic: key-sensitive cases isolate env + GBRAIN_HOME via withEnv (R1) so the dev
* machine's real ~/.gbrain/config.json never leaks in.
*/
import { describe, test, expect, afterEach } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateModelId, probeChatModel } from '../../src/core/ai/gateway.ts';
import { normalizeModelId } from '../../src/core/model-id.ts';
import { withEnv } from '../helpers/with-env.ts';
const REAL = 'anthropic:claude-sonnet-4-6';
const tmpDirs: string[] = [];
function emptyHome(): string {
const d = mkdtempSync(join(tmpdir(), 'gbrain-probe-'));
tmpDirs.push(d);
return d;
}
afterEach(() => {
while (tmpDirs.length) {
try { rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* best effort */ }
}
});
// No-key env: ANTHROPIC_API_KEY unset + GBRAIN_HOME pointed at an empty dir so the
// config-file branch of hasAnthropicKey finds nothing.
const noKeyEnv = () => ({ ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: emptyHome() });
const withKeyEnv = () => ({ ANTHROPIC_API_KEY: 'sk-test', GBRAIN_HOME: emptyHome() });
describe('validateModelId (#1698 C1 core)', () => {
test('ok for a real model id, returns parsed + recipe', () => {
const v = validateModelId(REAL);
expect(v.ok).toBe(true);
if (v.ok) {
expect(v.parsed.providerId).toBe('anthropic');
expect(v.recipe).toBeDefined();
}
});
test('works WITHOUT configureGateway (reads registry, not _config) — C1 #6 guard', () => {
// No gateway configured in this process; validateModelId must still resolve.
const v = validateModelId(REAL);
expect(v.ok).toBe(true);
});
test('unknown_provider when resolveRecipe throws', () => {
const v = validateModelId('bogusprovider:whatever');
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe('unknown_provider');
});
test('unknown_model for a typo native model', () => {
const v = validateModelId('anthropic:claude-bogus-9');
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe('unknown_model');
});
});
describe('probeChatModel (#1698 = validity + key, config-independent)', () => {
test('unavailable: anthropic + no key', async () => {
await withEnv(noKeyEnv(), async () => {
const p = probeChatModel(REAL);
expect(p.ok).toBe(false);
if (!p.ok) expect(p.reason).toBe('unavailable');
});
});
test('ok: anthropic + key set (no configureGateway needed)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel(REAL).ok).toBe(true);
});
});
test('unknown_provider / unknown_model classify regardless of key (validity runs first)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel('bogusprovider:x')).toMatchObject({ ok: false, reason: 'unknown_provider' });
expect(probeChatModel('anthropic:claude-bogus-9')).toMatchObject({ ok: false, reason: 'unknown_model' });
});
});
test('non-anthropic provider passes the probe even with no key (lazy key check)', async () => {
// deepseek is a registered recipe; its key check is deferred to gateway.chat()
// (the per-transcript-degrade contract — A9). probe should be ok here.
await withEnv(noKeyEnv(), async () => {
expect(probeChatModel('deepseek:deepseek-chat').ok).toBe(true);
});
});
test('reported repro: slash form normalizes then probes ok (with key)', async () => {
await withEnv(withKeyEnv(), async () => {
expect(probeChatModel(normalizeModelId('anthropic/claude-sonnet-4-6')).ok).toBe(true);
});
});
});