Commit 5f2e13e9 authored by Gregory Havenga's avatar Gregory Havenga 2️⃣
Browse files

perf(anthropic): place cache breakpoints on final two messages

The prompt-cache breakpoint was placed on a single message (the
penultimate one). Because an agentic turn appends several message
blocks (assistant tool_call -> tool_result -> ...), that single
breakpoint can drift more than Anthropic's 20-block lookback window
behind the current position. When it does, the next request fails to
prefix-match and pays for a fresh cache write instead of a cheap read.

Place breakpoints on the last two messages instead. The second trailing
breakpoint keeps a prior write inside the lookback window as the
conversation grows, so subsequent requests read the cached prefix.
Breakpoints are free (you only pay for tokens actually written/read),
so this raises the cache hit rate in multi-turn tool-using sessions at
no cost.

Refs prompt-caching lookback semantics:
https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
parent 7b313f25
Loading
Loading
Loading
Loading
+19 −8
Original line number Diff line number Diff line
@@ -231,11 +231,17 @@ export class GitLabAnthropicLanguageModel implements LanguageModelV3 {
   *
   * Cache breakpoints (`cache_control: { type: "ephemeral" }`) are placed on:
   * 1. The system prompt content block — static across all turns.
   * 2. The last content block of the second-to-last message — the boundary
   *    between conversation history and the current turn.
   * 2. The last content block of each of the final two messages.
   *
   * This lets Anthropic cache the system prompt and the accumulated
   * conversation prefix, so each new turn only pays for the new content.
   * Two trailing breakpoints (rather than a single one on the penultimate
   * message) keep a cache write within Anthropic's 20-block lookback window
   * as an agentic conversation grows several messages per turn (assistant
   * tool-call → tool-result → …). With a single breakpoint the most recent
   * write can drift more than 20 blocks behind the current position, so the
   * next request fails to prefix-match and pays for a fresh cache write
   * instead of a cheap read. The extra breakpoint costs nothing (breakpoints
   * themselves are free; you only pay for tokens actually written/read) and
   * materially raises the cache hit rate in multi-turn tool-using sessions.
   *
   * @see https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
   */
@@ -344,10 +350,15 @@ export class GitLabAnthropicLanguageModel implements LanguageModelV3 {
        ]
      : undefined;

    if (messages.length >= 2) {
      const penultimate = messages[messages.length - 2];
      if (Array.isArray(penultimate.content)) {
        const lastBlock = penultimate.content[penultimate.content.length - 1];
    // Mark the last content block of each of the final two messages. Two
    // trailing breakpoints keep a prior cache write inside Anthropic's
    // 20-block lookback window as the conversation grows several blocks per
    // turn, so the next request prefix-matches and reads instead of rewriting.
    const breakpointCount = Math.min(2, messages.length);
    for (let i = messages.length - breakpointCount; i < messages.length; i++) {
      const message = messages[i];
      if (Array.isArray(message.content) && message.content.length > 0) {
        const lastBlock = message.content[message.content.length - 1];
        (lastBlock as unknown as Record<string, unknown>).cache_control = {
          type: 'ephemeral' as const,
        };
+45 −6
Original line number Diff line number Diff line
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitLabAnthropicLanguageModel } from '../src/gitlab-anthropic-language-model';
import { GitLabError } from '../src/gitlab-error';
import type { LanguageModelV3StreamPart } from '@ai-sdk/provider';
import type { LanguageModelV3Message, LanguageModelV3StreamPart } from '@ai-sdk/provider';

const mockCreate = vi.fn();
const mockStream = vi.fn();
@@ -345,7 +345,7 @@ describe('GitLabAnthropicLanguageModel', () => {
      expect(result.usage.inputTokens.noCache).toBe(6000);
    });

    it('should set cache_control on the penultimate message in multi-turn conversations', async () => {
    it('should set cache_control on the final two messages in multi-turn conversations', async () => {
      const model = new GitLabAnthropicLanguageModel('test-model', defaultConfig);

      await model.doGenerate({
@@ -360,13 +360,19 @@ describe('GitLabAnthropicLanguageModel', () => {
      });

      const callArgs = mockCreate.mock.calls[0][0];
      // Two trailing breakpoints keep a cache write inside the 20-block
      // lookback window as the conversation grows several blocks per turn.
      expect(callArgs.messages[1].content[0]).toEqual(
        expect.objectContaining({ cache_control: { type: 'ephemeral' } })
      );
      expect(callArgs.messages[2].content[0]).not.toHaveProperty('cache_control');
      expect(callArgs.messages[2].content[0]).toEqual(
        expect.objectContaining({ cache_control: { type: 'ephemeral' } })
      );
      // Earlier messages beyond the final two stay uncached.
      expect(callArgs.messages[0].content[0]).not.toHaveProperty('cache_control');
    });

    it('should not set conversation cache_control when only one message exists', async () => {
    it('should set cache_control on the sole message when only one message exists', async () => {
      const model = new GitLabAnthropicLanguageModel('test-model', defaultConfig);

      await model.doGenerate({
@@ -379,10 +385,12 @@ describe('GitLabAnthropicLanguageModel', () => {
      });

      const callArgs = mockCreate.mock.calls[0][0];
      expect(callArgs.messages[0].content[0]).not.toHaveProperty('cache_control');
      expect(callArgs.messages[0].content[0]).toEqual(
        expect.objectContaining({ cache_control: { type: 'ephemeral' } })
      );
    });

    it('should set cache_control on last content block of penultimate message with tool results', async () => {
    it('should place breakpoints on the last block of each of the final two messages with tool results', async () => {
      const model = new GitLabAnthropicLanguageModel('test-model', defaultConfig);

      await model.doGenerate({
@@ -421,6 +429,37 @@ describe('GitLabAnthropicLanguageModel', () => {
      const penultimate = callArgs.messages[callArgs.messages.length - 2];
      const lastBlock = penultimate.content[penultimate.content.length - 1];
      expect(lastBlock).toEqual(expect.objectContaining({ cache_control: { type: 'ephemeral' } }));
      // The final message also carries a breakpoint (two trailing breakpoints).
      const last = callArgs.messages[callArgs.messages.length - 1];
      expect(last.content[last.content.length - 1]).toEqual(
        expect.objectContaining({ cache_control: { type: 'ephemeral' } })
      );
    });

    it('places exactly two message breakpoints regardless of conversation length', async () => {
      const model = new GitLabAnthropicLanguageModel('test-model', defaultConfig);

      const prompt: LanguageModelV3Message[] = [
        { role: 'system', content: 'You are a helpful assistant.' },
      ];
      for (let i = 0; i < 8; i++) {
        prompt.push({ role: 'user', content: [{ type: 'text', text: `q${i}` }] });
        prompt.push({ role: 'assistant', content: [{ type: 'text', text: `a${i}` }] });
      }
      prompt.push({ role: 'user', content: [{ type: 'text', text: 'final' }] });

      await model.doGenerate({ prompt, inputFormat: 'messages', mode: { type: 'regular' } });

      const callArgs = mockCreate.mock.calls[0][0];
      const marked = callArgs.messages.filter(
        (m: { content: Array<Record<string, unknown>> }) =>
          Array.isArray(m.content) &&
          m.content.some((b) => (b as Record<string, unknown>).cache_control)
      );
      expect(marked).toHaveLength(2);
      // The two marked messages are the final two.
      expect(marked[0]).toBe(callArgs.messages[callArgs.messages.length - 2]);
      expect(marked[1]).toBe(callArgs.messages[callArgs.messages.length - 1]);
    });

    it('should return V3 finishReason with unified and raw fields', async () => {