Files
gbrain/test/ai/gateway-chat.test.ts
T
6ec3dd410e fix(gateway): land Anthropic cache_control breakpoints on the system block, not just the call-level auto marker (#2490) (#2981)
gateway.chat() requested cacheSystem:true but never got a system-prompt
cache hit on single-turn callers (page-summary, skillopt, enrich): the
call-level providerOptions.anthropic.cacheControl is real (it becomes
Anthropic's documented top-level "auto-cache the last cacheable block"
shorthand via @ai-sdk/anthropic 3.0.47+), but for a stable system prompt
paired with a different user message every call, "the last cacheable
block" is that ever-varying tail -- every call writes a fresh cache
entry there and never reads a prior one.

Fix: pass system as a SystemModelMessage object (ai's documented shape
for attaching provider options to the system block) carrying its own
providerOptions.anthropic.cacheControl when cacheSystem is requested,
and mirror the same marker onto the last tool def (Anthropic caches
everything up to and including the last cache_control block it sees).
The call-level marker is kept, not removed -- it still gives toolLoop()'s
growing multi-turn conversation a rolling cache breakpoint on each
turn's tail. All three markers now derive from one canonical
cacheControlValue computed after provider_chat_options config merging,
so a configured TTL override (e.g. ttl: '1h') applies consistently
instead of only reaching the call-level marker.

Verified red-before-fix by stashing the gateway.ts diff and confirming
the new assertions fail on unfixed code, then restoring.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 14:16:00 -07:00

324 lines
13 KiB
TypeScript

/**
* Commit 1 — chat touchpoint coverage.
*
* Asserts:
* - chat() resolves provider:model strings + aliases
* - assertTouchpoint surfaces chat-only providers correctly
* - getChatModel() default + override
* - chat_fallback_chain plumbing (config plumbing only — chatWithFallback ships in commit 3)
* - new openai-compat recipes (deepseek, groq, together) parse + resolve
* - new ChatTouchpoint shape: supports_subagent_loop, supports_prompt_cache
* - mapStopReason via the chat() boundary (mocked client) — refusal / content_filter / tool_calls / end / length
*
* The actual `generateText` call is exercised via a fake AI SDK model object
* (the `model` returned from `createOpenAICompatible(...).languageModel()`)
* passed by patching the module cache. We bypass the heavy SDK by mocking the
* `generateText` import via Bun's module-replace pattern.
*/
import { describe, test, expect, beforeEach, mock } from 'bun:test';
import {
configureGateway,
resetGateway,
isAvailable,
getChatModel,
getChatFallbackChain,
chat,
__setGenerateTextTransportForTests,
} from '../../src/core/ai/gateway.ts';
import { parseModelId, resolveRecipe, assertTouchpoint } from '../../src/core/ai/model-resolver.ts';
import { AIConfigError } from '../../src/core/ai/errors.ts';
import { listRecipes, getRecipe } from '../../src/core/ai/recipes/index.ts';
describe('chat touchpoint — recipe registry', () => {
test('all six chat-capable providers ship a chat touchpoint with supports_subagent_loop', () => {
const expected = ['anthropic', 'openai', 'google', 'deepseek', 'groq', 'together'];
for (const id of expected) {
const r = getRecipe(id);
expect(r, `recipe missing: ${id}`).toBeDefined();
expect(r!.touchpoints.chat, `${id} missing chat touchpoint`).toBeDefined();
expect(r!.touchpoints.chat!.models.length, `${id} chat models empty`).toBeGreaterThan(0);
expect(r!.touchpoints.chat!.supports_subagent_loop, `${id} should support subagent loop`).toBe(true);
}
});
test('only Anthropic claims supports_prompt_cache=true', () => {
for (const r of listRecipes()) {
if (!r.touchpoints.chat) continue;
if (r.id === 'anthropic') {
expect(r.touchpoints.chat.supports_prompt_cache).toBe(true);
} else {
expect(r.touchpoints.chat.supports_prompt_cache ?? false).toBe(false);
}
}
});
test('embedding-only providers (voyage, ollama) do NOT declare chat', () => {
expect(getRecipe('voyage')!.touchpoints.chat).toBeUndefined();
expect(getRecipe('ollama')!.touchpoints.chat).toBeUndefined();
});
test('openai-compat chat recipes have base_url_default', () => {
expect(getRecipe('deepseek')!.base_url_default).toBe('https://api.deepseek.com/v1');
expect(getRecipe('groq')!.base_url_default).toBe('https://api.groq.com/openai/v1');
expect(getRecipe('together')!.base_url_default).toBe('https://api.together.xyz/v1');
});
});
describe('chat touchpoint — model resolver + aliases (Codex F-OV-5)', () => {
test('parseModelId handles dated and undated forms identically at parse time', () => {
expect(parseModelId('anthropic:claude-sonnet-4-6')).toEqual({
providerId: 'anthropic',
modelId: 'claude-sonnet-4-6',
});
expect(parseModelId('anthropic:claude-haiku-4-5-20251001')).toEqual({
providerId: 'anthropic',
modelId: 'claude-haiku-4-5-20251001',
});
});
test('resolveRecipe expands pre-4.6 dateless alias to dated canonical', () => {
// Pre-4.6 models keep date-based aliases (Haiku 4.5 predates the
// dateless convention).
const { parsed } = resolveRecipe('anthropic:claude-haiku-4-5');
expect(parsed.modelId).toBe('claude-haiku-4-5-20251001');
});
test('resolveRecipe leaves dateless 4.6+ models unchanged (they ARE canonical)', () => {
const { parsed } = resolveRecipe('anthropic:claude-opus-4-7');
expect(parsed.modelId).toBe('claude-opus-4-7');
const { parsed: parsed2 } = resolveRecipe('anthropic:claude-sonnet-4-6');
expect(parsed2.modelId).toBe('claude-sonnet-4-6');
});
test('reverse alias rescues v0.31.6-shipped broken Sonnet 4.6 ID (regression)', () => {
// gbrain v0.31.6 shipped 'claude-sonnet-4-6-20250929' as a hardcoded
// default, which 404s on the Anthropic API (Sonnet 4.6 is dateless).
// The reverse alias rewrites broken → canonical so any user with a
// stale `models.dream.synthesize` / `facts.extraction_model` config
// keeps working. Regression guard against a future "cleanup" that
// drops this alias entry.
const { parsed } = resolveRecipe('anthropic:claude-sonnet-4-6-20250929');
expect(parsed.modelId).toBe('claude-sonnet-4-6');
});
test('assertTouchpoint accepts chat for chat-capable native + openai-compat providers', () => {
expect(() => assertTouchpoint(getRecipe('anthropic')!, 'chat', 'claude-opus-4-7')).not.toThrow();
expect(() => assertTouchpoint(getRecipe('openai')!, 'chat', 'gpt-5.2')).not.toThrow();
expect(() => assertTouchpoint(getRecipe('google')!, 'chat', 'gemini-2.0-flash')).not.toThrow();
expect(() => assertTouchpoint(getRecipe('deepseek')!, 'chat', 'deepseek-chat')).not.toThrow();
});
test('assertTouchpoint rejects chat on embedding-only providers with a fix hint', () => {
expect(() => assertTouchpoint(getRecipe('voyage')!, 'chat', 'voyage-3'))
.toThrow(AIConfigError);
expect(() => assertTouchpoint(getRecipe('ollama')!, 'chat', 'nomic-embed-text'))
.toThrow(AIConfigError);
});
test('assertTouchpoint rejects unknown native model with the model list in the fix hint', () => {
try {
assertTouchpoint(getRecipe('anthropic')!, 'chat', 'claude-opus-9-99');
throw new Error('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(AIConfigError);
expect((e as AIConfigError).message).toContain('claude-opus-9-99');
}
});
test('assertTouchpoint accepts arbitrary model on openai-compat tier', () => {
// openai-compat lets users pass models not declared in the recipe (provider may host more)
expect(() => assertTouchpoint(getRecipe('groq')!, 'chat', 'some-future-model')).not.toThrow();
});
});
describe('chat touchpoint — gateway config plumbing', () => {
beforeEach(() => resetGateway());
test('default chat_model is anthropic:claude-sonnet-4-6', () => {
configureGateway({ env: {} });
expect(getChatModel()).toBe('anthropic:claude-sonnet-4-6');
});
test('explicit chat_model overrides the default', () => {
configureGateway({
chat_model: 'openai:gpt-5.2',
env: { OPENAI_API_KEY: 'fake' },
});
expect(getChatModel()).toBe('openai:gpt-5.2');
});
test('chat_fallback_chain plumbed and retrievable', () => {
configureGateway({
chat_fallback_chain: [
'anthropic:claude-opus-4-7',
'deepseek:deepseek-chat',
],
env: {},
});
expect(getChatFallbackChain()).toEqual([
'anthropic:claude-opus-4-7',
'deepseek:deepseek-chat',
]);
});
test('chat_fallback_chain defaults to empty array', () => {
configureGateway({ env: {} });
expect(getChatFallbackChain()).toEqual([]);
});
test('isAvailable("chat") returns true when default Anthropic + key present', () => {
configureGateway({ env: { ANTHROPIC_API_KEY: 'fake' } });
expect(isAvailable('chat')).toBe(true);
});
test('isAvailable("chat") returns false when configured provider has no key', () => {
configureGateway({ chat_model: 'openai:gpt-5.2', env: {} });
expect(isAvailable('chat')).toBe(false);
});
test('isAvailable("chat") returns false on embedding-only chat target', () => {
// Voyage doesn't expose a chat touchpoint; isAvailable should refuse.
configureGateway({ chat_model: 'voyage:voyage-3', env: { VOYAGE_API_KEY: 'fake' } });
expect(isAvailable('chat')).toBe(false);
});
});
describe('chat touchpoint — config alias resolution', () => {
beforeEach(() => resetGateway());
test('isAvailable("chat") accepts undated alias and resolves correctly', () => {
configureGateway({
chat_model: 'anthropic:claude-sonnet-4-6', // undated
env: { ANTHROPIC_API_KEY: 'fake' },
});
expect(isAvailable('chat')).toBe(true);
});
});
describe('chat touchpoint — chat() smoke + stop-reason mapping (Codex D8)', () => {
// We exercise chat() against a mocked AI-SDK 'generateText' to assert the
// gateway's structural-signal mapping (mapStopReason) covers refusal,
// content_filter, tool_calls, end, length without the regex layer (commit 3).
// A full integration test against real provider HTTP lives in
// test/e2e/agent-multi-provider.test.ts (commit 2).
//
// We can't easily monkey-patch ESM imports inside Bun's runtime; instead we
// write an end-to-end assertion against the resolver logic + verify the
// chat() function exists with the documented signature.
test('chat() function is exported with the expected signature', async () => {
const mod = await import('../../src/core/ai/gateway.ts');
expect(typeof mod.chat).toBe('function');
// Signature check: must accept ChatOpts. We don't call it without a real
// provider key — that's the e2e job.
});
test('ChatBlock + ChatMessage + ChatResult types are exported', async () => {
// Type-only assertion: if these imports compile, we're good. The test
// body is just a runtime touch.
const mod = await import('../../src/core/ai/gateway.ts');
expect(mod).toBeDefined();
});
});
describe('chat touchpoint — provider_chat_options passthrough', () => {
beforeEach(() => {
resetGateway();
__setGenerateTextTransportForTests(null);
});
async function captureProviderOptions(
config: Parameters<typeof configureGateway>[0],
opts: Partial<Parameters<typeof chat>[0]> = {},
): Promise<Record<string, any> | undefined> {
let captured: Record<string, any> | undefined;
__setGenerateTextTransportForTests(async (args: any) => {
captured = args.providerOptions;
return {
content: [{ type: 'text', text: 'ok' }],
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1 },
} as any;
});
configureGateway(config);
await chat({
model: config.chat_model ?? 'anthropic:claude-sonnet-4-6',
messages: [{ role: 'user', content: 'hello' }],
...opts,
});
return captured;
}
test('provider-scoped option reaches generateText providerOptions[recipe.id]', async () => {
const providerOptions = await captureProviderOptions({
chat_model: 'anthropic:claude-sonnet-4-6',
provider_chat_options: {
anthropic: { thinking: { type: 'disabled' } },
},
env: { ANTHROPIC_API_KEY: 'fake' },
});
expect(providerOptions).toEqual({
anthropic: { thinking: { type: 'disabled' } },
});
});
test('model-scoped option overrides provider-scoped option', async () => {
const providerOptions = await captureProviderOptions({
chat_model: 'anthropic:claude-sonnet-4-6',
provider_chat_options: {
anthropic: {
thinking: { type: 'enabled', budget_tokens: 1024 },
temperature: 0.2,
},
'anthropic:claude-sonnet-4-6': {
thinking: { type: 'disabled' },
},
},
env: { ANTHROPIC_API_KEY: 'fake' },
});
expect(providerOptions).toEqual({
anthropic: {
thinking: { type: 'disabled', budget_tokens: 1024 },
temperature: 0.2,
},
});
});
test('no provider_chat_options keeps providerOptions undefined when cache is off', async () => {
const providerOptions = await captureProviderOptions({
chat_model: 'anthropic:claude-sonnet-4-6',
env: { ANTHROPIC_API_KEY: 'fake' },
});
expect(providerOptions).toBeUndefined();
});
test('anthropic cacheControl survives provider_chat_options merging', async () => {
// gbrain#2490: this call-level cacheControl is real (not a no-op) —
// @ai-sdk/anthropic serializes it as the Anthropic API's documented
// top-level "auto-cache the last cacheable block" shorthand. It's kept
// alongside the fix (an explicit breakpoint on the system message's own
// providerOptions — see test/ai/gateway-cache-breakpoint.test.ts) because
// it's what gives toolLoop()'s growing multi-turn conversation a rolling
// cache breakpoint on each turn's tail. See gateway.ts's `useCache` block
// for the full explanation of why both markers are needed.
const providerOptions = await captureProviderOptions({
chat_model: 'anthropic:claude-sonnet-4-6',
provider_chat_options: {
anthropic: { thinking: { type: 'disabled' } },
},
env: { ANTHROPIC_API_KEY: 'fake' },
}, { cacheSystem: true });
expect(providerOptions).toEqual({
anthropic: {
cacheControl: { type: 'ephemeral' },
thinking: { type: 'disabled' },
},
});
});
});