Files
gbrain/test/think-gateway-adapter.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

202 lines
9.4 KiB
TypeScript

/**
* Gateway adapter tests for runThink (#952 fix).
*
* Pre-v0.36, runThink instantiated `new Anthropic()` directly. Closing #952
* routed it through gateway.chat() so MCP stdio launches pick up
* `anthropic_api_key` from gbrain config instead of process.env.
*
* The adapter shape was determined by plan-eng-review D10 (cross-model
* tension D10 with codex C7+C8+C9+C10):
* - drop new Anthropic() entirely
* - real availability check (NOT a false-positive `getChatModel()` truthy)
* - model-id normalization (bare → provider-prefixed)
* - response-shape conversion (ChatResult → Anthropic.Message)
*
* These tests pin the four spec points. Hermetic — no real LLM call.
*/
import { describe, test, expect } from 'bun:test';
import { __thinkAdapter } from '../src/core/think/index.ts';
import { resetGateway } from '../src/core/ai/gateway.ts';
import { withEnv } from './helpers/with-env.ts';
describe('think gateway adapter — response shape conversion', () => {
test('chatResultToMessage maps ChatResult.text to Anthropic.Message content[0].text', () => {
const out = __thinkAdapter.chatResultToMessage(
{
text: '{"answer":"hi","citations":[],"gaps":[]}',
blocks: [],
stopReason: 'end',
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
model: 'anthropic:claude-opus-4-7',
providerId: 'anthropic',
},
'anthropic:claude-opus-4-7',
);
expect(out.content[0].type).toBe('text');
expect(out.content[0].text).toBe('{"answer":"hi","citations":[],"gaps":[]}');
expect(out.usage.input_tokens).toBe(5);
expect(out.usage.output_tokens).toBe(2);
expect(out.stop_reason).toBe('end_turn');
expect(out.model).toBe('anthropic:claude-opus-4-7');
});
test('mapStopReason covers the full provider-neutral stop-reason set', () => {
expect(__thinkAdapter.mapStopReason('end')).toBe('end_turn');
expect(__thinkAdapter.mapStopReason('length')).toBe('max_tokens');
expect(__thinkAdapter.mapStopReason('tool_calls')).toBe('tool_use');
// 'refusal', 'content_filter', 'other' → end_turn (no Anthropic equivalent).
expect(__thinkAdapter.mapStopReason('refusal')).toBe('end_turn');
expect(__thinkAdapter.mapStopReason('content_filter')).toBe('end_turn');
expect(__thinkAdapter.mapStopReason('other')).toBe('end_turn');
});
});
describe('think gateway adapter — model-id normalization', () => {
test('tryBuildGatewayClient accepts bare anthropic model ids and prefixes anthropic:', async () => {
// Bare model: `claude-opus-4-7` → must resolve through `anthropic:` recipe.
// resolveRecipe will throw AIConfigError for unknown providers, so a
// successful build proves the prefix landed.
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('claude-opus-4-7');
expect(client).not.toBeNull();
});
});
test('tryBuildGatewayClient accepts already-prefixed provider:model strings', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('anthropic:claude-sonnet-4-6');
expect(client).not.toBeNull();
});
});
test('tryBuildGatewayClient returns null on unknown provider (AIConfigError → graceful fallback)', async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('nonexistent-provider:foo-1');
expect(client).toBeNull();
});
test('tryBuildGatewayClient returns null when ANTHROPIC_API_KEY is absent (preserves legacy NO_ANTHROPIC_API_KEY signal)', async () => {
await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('claude-opus-4-7');
expect(client).toBeNull();
});
});
test('hasAnthropicKey reads process.env', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-key' }, async () => {
expect(__thinkAdapter.hasAnthropicKey()).toBe(true);
});
await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
expect(__thinkAdapter.hasAnthropicKey()).toBe(false);
});
});
});
describe('think gateway adapter — #1698 slash form + explicit-model fork', () => {
test('tryBuildGatewayClient accepts SLASH form (anthropic/claude-...) — the reported bug', async () => {
// Pre-fix the colon-only inline produced `anthropic:anthropic/claude-sonnet-4-6`
// and the client silently degraded. normalizeModelId fixes it → builds cleanly.
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('anthropic/claude-sonnet-4-6');
expect(client).not.toBeNull();
});
});
test('explicit unresolvable model THROWS (does not degrade to null)', async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('bogusprovider:foo-1', { explicitModel: true }),
).rejects.toThrow(/not usable.*unknown_provider/);
});
test('explicit typo native model THROWS (unknown_model)', async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('anthropic:claude-bogus-9', { explicitModel: true }),
).rejects.toThrow(/not usable.*unknown_model/);
});
test('explicit anthropic model with no key THROWS (unavailable)', async () => {
await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
await expect(
__thinkAdapter.tryBuildGatewayClient('anthropic:claude-sonnet-4-6', { explicitModel: true }),
).rejects.toThrow(/not usable.*unavailable/);
});
});
test('NON-explicit unresolvable model returns null (graceful, unchanged)', async () => {
const client = await __thinkAdapter.tryBuildGatewayClient('bogusprovider:foo-1', { explicitModel: false });
expect(client).toBeNull();
});
test('create-callback fork: explicit rethrows AIConfigError; non-explicit returns the sentinel', async () => {
await withEnv({ ANTHROPIC_API_KEY: 'sk-test-fake' }, async () => {
// Build valid clients (key present → probe ok) but leave the gateway UNCONFIGURED
// so gateway.chat() throws AIConfigError (requireConfig) at create() time.
resetGateway();
const params: any = {
model: 'anthropic:claude-sonnet-4-6',
max_tokens: 16,
system: 'sys',
messages: [{ role: 'user', content: 'hi' }],
};
const explicitClient = await __thinkAdapter.tryBuildGatewayClient(
'anthropic:claude-sonnet-4-6', { explicitModel: true },
);
expect(explicitClient).not.toBeNull();
await expect(explicitClient!.create(params)).rejects.toThrow();
const gracefulClient = await __thinkAdapter.tryBuildGatewayClient(
'anthropic:claude-sonnet-4-6', { explicitModel: false },
);
expect(gracefulClient).not.toBeNull();
const msg = await gracefulClient!.create(params);
const text = msg.content.find((b: any) => b.type === 'text');
expect(text && 'text' in text ? text.text : '').toContain('no LLM available');
});
});
// D1 BACKSTOP (codex #1, accepted-as-is): probeChatModel only PRE-checks the Anthropic
// key, so an explicit NON-anthropic model (deepseek/openai/...) passes the early gate and
// BUILDS a client even with no provider key — its key is checked lazily at chat time. The
// create-callback rethrow is then the ONLY thing standing between "explicit unusable model"
// and a silent degrade. This test locks that backstop into a contract: the client builds
// (proving the deviation), and create() HARD-ERRORS (proving it never degrades to the
// 'no LLM available' stub). A future refactor that turns this into a graceful path fails here.
test('D1 backstop: explicit non-anthropic model, no key → BUILDS then create() THROWS (never a stub)', async () => {
await withEnv(
{ ANTHROPIC_API_KEY: undefined, DEEPSEEK_API_KEY: undefined, OPENAI_API_KEY: undefined },
async () => {
resetGateway(); // unconfigured → gateway.chat() throws AIConfigError at create()
// deepseek:deepseek-chat passes validateModelId (real recipe + chat touchpoint) — the
// A9 non-anthropic model. probeChatModel returns ok (no anthropic key check) → builds.
const client = await __thinkAdapter.tryBuildGatewayClient(
'deepseek:deepseek-chat', { explicitModel: true },
);
expect(client).not.toBeNull(); // proves the early gate did NOT pre-reject non-anthropic
const params: any = {
model: 'deepseek:deepseek-chat',
max_tokens: 16,
system: 'sys',
messages: [{ role: 'user', content: 'hi' }],
};
// The backstop fires: explicit → AIConfigError rethrown, NOT the graceful sentinel.
await expect(client!.create(params)).rejects.toThrow();
},
);
});
});
describe('think gateway adapter — graceful fallback shape', () => {
test('buildGracefulMessage produces a parseable Anthropic.Message-shaped object', () => {
const m = __thinkAdapter.buildGracefulMessage('anthropic:claude-opus-4-7');
expect(m.type).toBe('message');
expect(m.role).toBe('assistant');
expect(m.content[0].type).toBe('text');
expect(m.content[0].text).toContain('no LLM available');
expect(m.content[0].text).toContain('gbrain config');
expect(m.usage.input_tokens).toBe(0);
expect(m.usage.output_tokens).toBe(0);
expect(m.stop_reason).toBe('end_turn');
});
});