fix(core): preserve Responses cache boundaries before auto-memory context (#15006)

This commit is contained in:
顾盼 authored and GitHub committed 2026-10-21 16:22:42 +08:00
1 parent 1d5806d560
commit 461be87304
8 files changed
+714 -11

No files matched your search

@@ -0,0 +1,55 @@
# Responses stable prefix caching
[English](responses-stable-prefix-cache.md) | [简体中文](responses-stable-prefix-cache.zh-CN.md)
## Problem and scope
Auto-memory catalogs and reattached images are request-only trailing content.
They carry `REATTACH_BOUNDARY_METADATA` and are absent from stored history.
Responses currently ignores that boundary. For GPT-5.6 and later, an implicit
write through the temporary tail does not write a separately reusable endpoint
at the unchanged conversation prefix. A stable session cache key alone cannot
fix this. Preserve the catalog, instructions, tool results, reasoning, images,
and their existing order.
## Implementation
The converter reports the last stable input item and, when necessary, the last
stable content block within that item. This metadata stays local. After merging
caller request options, the pipeline adds explicit breakpoints to at most three
recent stable user-text or group-ending function-output boundaries. The implicit
tail remains enabled, keeping the total within four write slots. Apply this
before orphan cleanup so conversion indices still identify the intended items.
Normalize all historical user strings and function outputs to text-block arrays
when applying this policy, including outputs whose markers rotate out. Marker
rotation must not change the representation of earlier text. Custom tool outputs
retain their existing representation; a function output followed by another
function or custom output is not a group endpoint. No text is removed or changed.
## Compatibility and cost
Reuse the existing GPT-5.6+ capability check. The official Responses endpoint,
including its default URL, enables the policy automatically. Compatible proxies
opt in through existing `enableCacheControl: true` or
`extra_body.prompt_cache_options` with implicit mode. `enableCacheControl: false`
disables automatic additions. Explicit-only mode or existing caller breakpoints
retain caller ownership. Requests without a volatile boundary remain unchanged.
Preserve `prompt_cache_key` and caller TTL settings. Do not select a longer TTL;
omitting it retains the server's default 30-minute lifetime. New stable endpoints
can incur writes, with later reads dependent on prefix identity, routing, expiry,
and compaction. No cache-hit percentage is guaranteed. See the
[official prompt-caching guide](https://developers.openai.com/api/docs/guides/prompt-caching).
## Validation and acceptance
The latest-main pipeline regression must fail before the fix and pass afterward.
Cover user text before a catalog, grouped tool outputs, six-turn marker rotation,
image reattachment, tool-returned media, caller settings, older models, and proxy
opt-in. Verify unchanged source history, payload text, order, cache key and TTL,
at most three explicit markers, and matching previous endpoints after rotation.
Run relevant tests, build, typecheck, lint, and a built-runtime loopback transport
check. These local checks establish request correctness; prior controlled live
experiments support the mechanism but are not production acceptance of this
revision or a measurement of long-history cache-hit rates.
@@ -0,0 +1,46 @@
# Responses 稳定前缀缓存
[English](responses-stable-prefix-cache.md) | [简体中文](responses-stable-prefix-cache.zh-CN.md)
## 问题与范围
自动记忆目录和重新附加的图片属于请求临时尾部,带有
`REATTACH_BOUNDARY_METADATA`,不会进入持久历史。Responses 目前忽略了这个边界。
对于 GPT-5.6 及后续模型,覆盖临时尾部的隐式缓存写入,不会在不变的会话前缀末尾
单独写入可复用端点。仅使用稳定的 session cache key 无法修复。必须保留目录、
指令、工具结果、推理、图片及其原有顺序。
## 实现
转换器返回最后一个稳定输入项的位置;必要时也返回该项中最后一个稳定内容块的
位置。这些元数据仅在本地使用。合并调用方请求选项后,pipeline 在最近最多三个
稳定用户文本或工具结果组末尾的 function output 上添加显式断点。保留隐式尾部
断点,使总写入点数不超过四个。在清理孤立工具调用之前应用,确保转换位置仍指向
预期输入项。
应用此策略时,将全部历史用户字符串和 function output 统一为文本块数组,
包括断点轮换后不再带标记的结果。轮换断点不能改变早期文本的表示形式。
Custom tool output 保持原有表示;后面仍有 function 或 custom output 的
function output 不属于结果组末尾。不删除或修改任何文本。
## 兼容性与费用
复用现有 GPT-5.6+ 能力检测。官方 Responses 端点及其默认 URL 自动启用策略。
兼容代理通过现有 `enableCacheControl: true` 或隐式模式的
`extra_body.prompt_cache_options` 显式启用。`enableCacheControl: false`
关闭自动添加。显式模式和调用方已有断点继续由调用方控制。没有临时区域边界的
请求保持不变。
保留 `prompt_cache_key` 和调用方 TTL 设置。不主动选择更长 TTL;省略时保持
服务端默认的 30 分钟生命周期。新增稳定端点可能产生缓存写入,后续读取仍依赖
前缀一致性、路由、有效期和压缩。不保证命中率百分比。机制依据见
[官方 Prompt caching 文档](https://developers.openai.com/api/docs/guides/prompt-caching)。
## 验证与验收
最新 main 的 pipeline 回归必须在修复前失败,修复后通过。覆盖目录之前的用户
文本、工具结果组、六轮断点轮换、图片重附加、工具返回媒体、调用方设置、旧模型
和代理显式启用。核对原始历史、正文、顺序、cache key 和 TTL 不变,显式断点不
超过三个,轮换后仍包含上一轮端点。执行相关测试、build、typecheck、lint 和基于
构建产物的本地回环传输验证。这些本地检查证明请求构造正确;此前受控在线实验
支持根因机制,但不代表当前版本的生产验收,也不是完整长历史命中率测量。
@@ -0,0 +1,366 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import type { Content } from '@google/genai';
import type { ContentGeneratorConfig } from '../contentGenerator.js';
import type { ResponsesApiRequest, ResponsesApiInputItem } from './types.js';
import { applyResponsesPromptCaching } from './prompt-caching.js';
import { convertGeminiContentsToResponsesInput } from './responses-converter.js';
import { appendAutoMemoryContext } from '../../memory/request-context.js';
import { REATTACH_BOUNDARY_METADATA } from '../../services/image-payload-references.js';
import {
content,
fnCall,
fnResponse,
userText,
} from '../../test-utils/model-fixtures.js';
const marker = { mode: 'explicit' as const };
const config: ContentGeneratorConfig = { model: 'gpt-6.1-sol' };
function prepare(
contents: Content[],
overrides: Partial<ContentGeneratorConfig> = {},
options?: ResponsesApiRequest['prompt_cache_options'],
) {
const original = structuredClone(contents);
const converted = convertGeminiContentsToResponsesInput({
model: config.model,
contents: appendAutoMemoryContext(contents, 'memory catalog'),
});
const request: ResponsesApiRequest = {
model: overrides.model ?? config.model,
input: converted.input,
instructions: 'Preserve all safety and permission instructions.',
prompt_cache_key: 'unchanged-key',
...(options ? { prompt_cache_options: options } : {}),
};
const before = structuredClone(request);
const result = applyResponsesPromptCaching(
request,
{ ...config, ...overrides },
converted.cacheBoundary,
);
expect(contents).toEqual(original);
expect(request).toEqual(before);
expect(result.instructions).toBe(request.instructions);
expect(result.prompt_cache_key).toBe(request.prompt_cache_key);
const semanticInput = (input: ResponsesApiInputItem[]) =>
JSON.parse(
JSON.stringify(
input.map((item) => {
if (item.type === 'message' && typeof item.content === 'string') {
return {
...item,
content: [{ type: 'input_text', text: item.content }],
};
}
if (
item.type === 'function_call_output' &&
typeof item.output === 'string'
) {
return {
...item,
output: [{ type: 'input_text', text: item.output }],
};
}
return item;
}),
(key, value) => (key === 'prompt_cache_breakpoint' ? undefined : value),
),
);
expect(semanticInput(result.input)).toEqual(semanticInput(request.input));
return { request, result };
}
function toolTurn(id: string): Content[] {
return [
content('model', fnCall('read_file', { path: id }, id)),
content('user', fnResponse('read_file', { output: `result ${id}` }, id)),
];
}
function blocks(item: ResponsesApiInputItem) {
const value =
item.type === 'message'
? item.content
: item.type === 'function_call_output'
? item.output
: undefined;
return Array.isArray(value) ? value : [];
}
function breakpoints(request: ResponsesApiRequest) {
return request.input.flatMap((item, itemIndex) =>
blocks(item).flatMap((block, contentIndex) =>
block.type === 'input_text' && block.prompt_cache_breakpoint
? [{ itemIndex, contentIndex }]
: [],
),
);
}
describe('Responses stable prefix caching', () => {
it('marks only the end of a consecutive group of tool outputs', () => {
const { result } = prepare([
userText('read both files'),
content(
'model',
fnCall('read_file', {}, 'a'),
fnCall('read_file', {}, 'b'),
),
content(
'user',
fnResponse('read_file', { output: 'A' }, 'a'),
fnResponse('read_file', { error: 'B failed' }, 'b'),
),
]);
expect(result.input.slice(-3)).toEqual([
{
type: 'function_call_output',
call_id: 'a',
output: [{ type: 'input_text', text: 'A' }],
},
{
type: 'function_call_output',
call_id: 'b',
output: [
{
type: 'input_text',
text: 'B failed',
prompt_cache_breakpoint: marker,
},
],
},
{
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'memory catalog' }],
},
]);
expect(breakpoints(result)).toHaveLength(2);
});
it('retains matching old endpoints and stable text-block shapes as markers rotate', () => {
const history = [userText('Read files')];
let previous: ResponsesApiRequest | undefined;
for (let turn = 0; turn < 6; turn++) {
history.push(...toolTurn(`file-${turn}`));
const { result } = prepare(history);
const endpoints = breakpoints(result);
expect(endpoints).toHaveLength(Math.min(turn + 2, 3));
if (previous) {
const oldEndpoint = breakpoints(previous).at(-1)!;
expect(endpoints).toContainEqual(oldEndpoint);
// Ignoring cache metadata, the serialized stable prefix is unchanged.
const strip = (value: unknown) =>
JSON.parse(
JSON.stringify(value, (key, entry) =>
key === 'prompt_cache_breakpoint' ? undefined : entry,
),
);
expect(strip(result.input.slice(0, previous.input.length - 1))).toEqual(
strip(previous.input.slice(0, -1)),
);
}
previous = result;
}
expect(blocks(previous!.input[2]!)[0]).toEqual({
type: 'input_text',
text: 'result file-0',
});
});
it('anchors before the whole volatile image region and catalog', () => {
const converted = convertGeminiContentsToResponsesInput({
model: config.model,
contents: appendAutoMemoryContext(
[
content(
'user',
{ text: 'stable question' },
{
text: 'reattached images',
partMetadata: { [REATTACH_BOUNDARY_METADATA]: true },
},
{ inlineData: { mimeType: 'image/png', data: 'AA==' } },
),
],
'memory catalog',
),
});
const result = applyResponsesPromptCaching(
{ model: config.model, input: converted.input },
config,
converted.cacheBoundary,
);
expect(breakpoints(result)).toEqual([{ itemIndex: 0, contentIndex: 0 }]);
expect(blocks(result.input[0]!)).toHaveLength(4);
expect(JSON.stringify(result)).not.toContain(REATTACH_BOUNDARY_METADATA);
});
it('keeps tool-returned images and reasoning in their original order', () => {
const imageResult = fnResponse(
'read_file',
{ output: 'image result' },
'img',
);
imageResult.functionResponse!.parts = [
{ inlineData: { mimeType: 'image/png', data: 'AA==' } },
];
const { request, result } = prepare([
userText('Inspect image'),
content(
'model',
{ thought: true, text: 'reasoning summary' },
fnCall('read_file', {}, 'img'),
),
content('user', imageResult),
]);
expect(result.input.map((item) => item.type)).toEqual(
request.input.map((item) => item.type),
);
expect(result.input.at(-1)).toEqual(request.input.at(-1));
expect(result.input.at(-2)).toEqual({
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: 'memory catalog' }],
});
expect(result.input[1]).toEqual(request.input[1]);
expect(breakpoints(result).at(-1)?.itemIndex).toBe(3);
});
it.each([
{ model: 'gpt-5.5' },
{ model: 'qwen3-coder-plus', enableCacheControl: true },
{ enableCacheControl: false },
{ baseUrl: 'https://proxy.example/v1' },
{ baseUrl: 'https://api.openai.com.evil.test/v1' },
])(
'leaves unsupported or disabled configurations untouched: %j',
(overrides) => {
const { request, result } = prepare(
[userText('hello'), ...toolTurn('a')],
overrides,
);
expect(result).toBe(request);
},
);
it.each([
{ baseUrl: 'https://proxy.example/v1', enableCacheControl: true },
{ baseUrl: 'https://api.openai.com/v1' },
{ model: 'gpt-5.6-2026-08-01' },
])('supports declared cache capability: %j', (overrides) => {
const { result } = prepare(
[userText('hello'), ...toolTurn('a')],
overrides,
);
expect(breakpoints(result)).toHaveLength(2);
expect(result.prompt_cache_options).toEqual({ mode: 'implicit' });
});
it('honors proxy opt-in through existing request options without changing the TTL', () => {
const options = { mode: 'implicit' as const, ttl: '12h' as const };
const { result } = prepare(
[userText('hello')],
{ baseUrl: 'https://proxy.example/v1' },
options,
);
expect(breakpoints(result)).toHaveLength(1);
expect(result.prompt_cache_options).toEqual(options);
});
it('preserves caller-owned explicit-only mode', () => {
const { request, result } = prepare(
[userText('hello')],
{},
{ mode: 'explicit', ttl: '30m' },
);
expect(result).toBe(request);
});
it('preserves existing caller breakpoints', () => {
const request: ResponsesApiRequest = {
model: config.model,
input: [
{
type: 'message',
role: 'developer',
content: [
{
type: 'input_text',
text: 'caller policy',
prompt_cache_breakpoint: { mode: 'explicit', ttl: '12h' },
},
],
},
],
};
expect(applyResponsesPromptCaching(request, config, { itemIndex: 0 })).toBe(
request,
);
});
it('keeps existing multimodal output blocks intact', () => {
const request: ResponsesApiRequest = {
model: config.model,
input: [
{
type: 'function_call_output',
call_id: 'image-call',
output: [
{ type: 'input_text', text: 'image description' },
{ type: 'input_image', image_url: 'data:image/png;base64,AA==' },
],
},
],
};
const result = applyResponsesPromptCaching(request, config, {
itemIndex: 0,
});
expect(blocks(result.input[0]!)).toEqual([
{
type: 'input_text',
text: 'image description',
prompt_cache_breakpoint: marker,
},
{ type: 'input_image', image_url: 'data:image/png;base64,AA==' },
]);
expect(request.input[0]).toEqual({
type: 'function_call_output',
call_id: 'image-call',
output: [
{ type: 'input_text', text: 'image description' },
{ type: 'input_image', image_url: 'data:image/png;base64,AA==' },
],
});
});
it('leaves requests without a volatile tail or a stable boundary unchanged', () => {
const request: ResponsesApiRequest = { model: config.model, input: [] };
expect(applyResponsesPromptCaching(request, config, undefined)).toBe(
request,
);
const { result } = prepare([]);
expect(result.prompt_cache_options).toBeUndefined();
expect(breakpoints(result)).toEqual([]);
});
it('does not mark the middle of a mixed function/custom tool-output group', () => {
const request: ResponsesApiRequest = {
model: config.model,
input: [
{ type: 'function_call_output', call_id: 'a', output: 'A' },
{ type: 'custom_tool_call_output', call_id: 'b', output: 'B' },
],
};
expect(applyResponsesPromptCaching(request, config, { itemIndex: 1 })).toBe(
request,
);
});
});
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ContentGeneratorConfig } from '../contentGenerator.js';
import { supportsExplicitOpenAIPromptCaching } from '../openaiContentGenerator/prefix-caching.js';
import { normalizeOpenAiWireBaseUrl } from '../openaiContentGenerator/constants.js';
import type { ResponsesApiRequest } from './types.js';
/** End of the stable prefix, before request-only memory or image reattachment. */
export interface ResponsesCacheBoundary {
itemIndex: number;
contentIndex?: number;
}
export function applyResponsesPromptCaching(
request: ResponsesApiRequest,
config: ContentGeneratorConfig,
boundary: ResponsesCacheBoundary | undefined,
): ResponsesApiRequest {
const options = request.prompt_cache_options;
if (
!boundary ||
config.enableCacheControl === false ||
!supportsExplicitOpenAIPromptCaching(request.model) ||
(options !== undefined &&
(!options ||
typeof options !== 'object' ||
(options.mode !== undefined && options.mode !== 'implicit')))
) {
return request;
}
// A compatible proxy must opt in; its model name alone is not a capability.
const official =
new URL(normalizeOpenAiWireBaseUrl(config.baseUrl)).hostname ===
'api.openai.com';
if (!official && config.enableCacheControl !== true && !options) {
return request;
}
if (
request.input.some((item) => {
const blocks =
item.type === 'message'
? item.content
: item.type === 'function_call_output' ||
item.type === 'custom_tool_call_output'
? item.output
: undefined;
return (
Array.isArray(blocks) &&
blocks.some((block) => 'prompt_cache_breakpoint' in block)
);
})
) {
return request;
}
// Normalize every historical text output, including ones whose marker has
// aged out. Rotating markers must not reshape the earlier rendered prefix.
const input = request.input.map((item) => {
if (
item.type === 'function_call_output' &&
typeof item.output === 'string'
) {
return {
...item,
output: [{ type: 'input_text' as const, text: item.output }],
};
}
if (
item.type === 'message' &&
item.role === 'user' &&
typeof item.content === 'string'
) {
return {
...item,
content: [{ type: 'input_text' as const, text: item.content }],
};
}
return item;
});
const candidates: ResponsesCacheBoundary[] = [];
for (let index = 0; index <= boundary.itemIndex; index++) {
const item = input[index];
const next = input[index + 1];
const blocks =
item?.type === 'message' && item.role === 'user'
? item.content
: item?.type === 'function_call_output' &&
next?.type !== 'function_call_output' &&
next?.type !== 'custom_tool_call_output'
? item.output
: undefined;
if (!Array.isArray(blocks)) continue;
const end =
index === boundary.itemIndex && boundary.contentIndex !== undefined
? boundary.contentIndex
: blocks.length - 1;
for (let partIndex = end; partIndex >= 0; partIndex--) {
if (blocks[partIndex]?.type !== 'input_text') continue;
candidates.push({ itemIndex: index, contentIndex: partIndex });
break;
}
}
if (candidates.length === 0) return request;
// The implicit tail uses the fourth write slot. Keep older boundaries for
// lookup while the newest stable boundary grows on subsequent requests.
for (const candidate of candidates.slice(-3)) {
const item = input[candidate.itemIndex];
if (item?.type !== 'message' && item?.type !== 'function_call_output') {
continue;
}
const blocks = item.type === 'message' ? item.content : item.output;
if (!Array.isArray(blocks)) continue;
const marked = blocks.map((block, index) =>
index === candidate.contentIndex && block.type === 'input_text'
? { ...block, prompt_cache_breakpoint: { mode: 'explicit' as const } }
: block,
);
input[candidate.itemIndex] =
item.type === 'message'
? { ...item, content: marked }
: { ...item, output: marked };
}
return {
...request,
input,
prompt_cache_options: { mode: 'implicit', ...options },
};
}
@@ -30,6 +30,8 @@ import { safeJsonParse } from '../../utils/safeJsonParse.js';
import { setGenAiUsageProvenance } from '../../telemetry/gen-ai-usage.js';
import { createOpenAIReasoningThoughtPart } from '../../utils/thoughtUtils.js';
import { ToolNames } from '../../tools/tool-names.js';
import { REATTACH_BOUNDARY_METADATA } from '../../services/image-payload-references.js';
import type { ResponsesCacheBoundary } from './prompt-caching.js';
import {
getResponsesMessage,
type ResponsesMessageMetadata,
@@ -532,8 +534,13 @@ function mapFinishReason(reason: string): FinishReason {
export function convertGeminiContentsToResponsesInput(
request: GenerateContentParameters,
freeformExec = false,
): { instructions: string | undefined; input: ResponsesApiInputItem[] } {
): {
instructions: string | undefined;
input: ResponsesApiInputItem[];
cacheBoundary?: ResponsesCacheBoundary;
} {
let instructions: string | undefined;
let cacheBoundary: ResponsesCacheBoundary | undefined;
const items: ResponsesApiInputItem[] = [];
let callIdCounter = 0;
const customCallIds = new Set<string>();
@@ -616,6 +623,19 @@ export function convertGeminiContentsToResponsesInput(
continue;
}
if (
role === 'user' &&
part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true &&
!cacheBoundary
) {
cacheBoundary = pendingContentParts.length
? {
itemIndex: items.length,
contentIndex: pendingContentParts.length - 1,
}
: { itemIndex: items.length - 1 };
}
if ('thought' in part && part.thought) {
flushPendingMessage();
// Prior-turn reasoning can only be replayed as a real 'reasoning'
@@ -713,8 +733,7 @@ export function convertGeminiContentsToResponsesInput(
output = JSON.stringify(fr.response ?? {});
}
// A tool can return media (e.g. read_file on an image) in
// functionResponse.parts alongside its textual output. The
// function_call_output.output field is string-only, so surface the
// functionResponse.parts alongside its textual output. Surface the
// media the way the sibling Chat converter does
// (createToolMessage/splitToolMedia) instead of dropping it: append
// any text entries (e.g. compaction-slimmer placeholders) to the
@@ -814,7 +833,7 @@ export function convertGeminiContentsToResponsesInput(
}
}
return { instructions, input: items };
return { instructions, input: items, cacheBoundary };
}
/**
@@ -34,6 +34,7 @@ import type { ContentGeneratorConfig } from '../contentGenerator.js';
import type { ResponsesApiRequest } from './types.js';
import { preloadRuntimeFetchModule } from '../../utils/runtimeFetchOptions.js';
import { classifyRetryError } from '../../utils/retryErrorClassification.js';
import { appendAutoMemoryContext } from '../../memory/request-context.js';
import {
collect,
content,
@@ -443,6 +444,77 @@ describe('ResponsesPipeline', () => {
expect(keys[1]).toBe(keys[0]);
});
it('caches the stable user text before the request-only memory catalog', async () => {
const contents = [userText('Keep all permission checks.')];
const body = await sendBody(pipe({ model: 'gpt-6.1-sol' }), {
model: 'gpt-6.1-sol',
contents: appendAutoMemoryContext(contents, 'Current memory catalog'),
});
expect(body.input).toEqual([
{
type: 'message',
role: 'user',
content: [
{
type: 'input_text',
text: 'Keep all permission checks.',
prompt_cache_breakpoint: { mode: 'explicit' },
},
{ type: 'input_text', text: 'Current memory catalog' },
],
},
]);
expect(body.prompt_cache_options).toEqual({ mode: 'implicit' });
expect(contents).toEqual([userText('Keep all permission checks.')]);
});
it.each([
[{ baseUrl: 'https://proxy.example/v1' }, undefined],
[
{ baseUrl: 'https://proxy.example/v1', enableCacheControl: true },
{ mode: 'implicit' },
],
[
{
baseUrl: 'https://proxy.example/v1',
extra_body: { prompt_cache_options: { mode: 'implicit', ttl: '12h' } },
},
{ mode: 'implicit', ttl: '12h' },
],
[
{ extra_body: { prompt_cache_options: { mode: 'explicit' } } },
{ mode: 'explicit' },
],
[{ enableCacheControl: false }, undefined],
] satisfies Array<
[
Partial<ContentGeneratorConfig>,
ResponsesApiRequest['prompt_cache_options'],
]
>)(
'honors caller cache policy after extra_body merges: %j',
async (overrides, expected) => {
const body = await sendBody(
pipe({ model: 'gpt-6.1-sol', ...overrides }),
{
model: 'gpt-6.1-sol',
contents: appendAutoMemoryContext(
[userText('stable question')],
'catalog',
),
},
);
expect(body.prompt_cache_options).toEqual(expected);
const serialized = JSON.stringify(body);
expect(serialized.includes('prompt_cache_breakpoint')).toBe(
expected?.mode === 'implicit',
);
expect(serialized).not.toContain('cacheBoundary');
expect(serialized).toContain('catalog');
},
);
it('falls back to userPromptId as prompt_cache_key when the session id is empty', async () => {
const body = await sendBody(pipe(), undefined, 'short-id');
expect(body.prompt_cache_key).toBe('short-id');
@@ -54,6 +54,7 @@ import { buildSessionAwareFetch } from '../outbound-session-id.js';
import { createHash } from 'node:crypto';
import { createDebugLogger } from '../../utils/debugLogger.js';
import { ResponsesHttpError } from '../../utils/responses-http-error.js';
import { applyResponsesPromptCaching } from './prompt-caching.js';
const debugLogger = createDebugLogger('RESPONSES_PIPELINE');
@@ -406,10 +407,8 @@ export class ResponsesPipeline {
userPromptId: string,
): ResponsesApiRequest {
const freeformExec = this.cliConfig.getFreeform();
const { instructions, input } = convertGeminiContentsToResponsesInput(
request,
freeformExec,
);
const { instructions, input, cacheBoundary } =
convertGeminiContentsToResponsesInput(request, freeformExec);
const tools = convertGeminiToolsToResponsesTools(request, freeformExec);
// History is always re-derived in full from this app's own Content[]
@@ -421,7 +420,7 @@ export class ResponsesPipeline {
const apiRequest: ResponsesApiRequest = {
model: this.config.model,
input: cleanOrphanedFunctionCalls(input),
input,
instructions,
tools,
stream: true,
@@ -528,7 +527,15 @@ export class ResponsesPipeline {
}
}
return apiRequest;
const cachedRequest = applyResponsesPromptCaching(
apiRequest,
this.config,
cacheBoundary,
);
return {
...cachedRequest,
input: cleanOrphanedFunctionCalls(cachedRequest.input),
};
}
private buildReasoning(
@@ -67,6 +67,10 @@ export interface ResponsesApiRequest {
truncation?: ResponsesApiTruncation;
previous_response_id?: string;
prompt_cache_key?: string;
prompt_cache_options?: {
mode?: 'implicit' | 'explicit';
ttl?: '30m' | '12h';
};
reasoning?: ResponsesApiReasoning;
text?: ResponsesApiTextControls;
service_tier?: ResponsesApiServiceTier;
@@ -111,6 +115,7 @@ export type ResponsesApiContentPart =
export interface ResponsesApiTextPart {
type: 'input_text';
text: string;
prompt_cache_breakpoint?: { mode: 'explicit'; ttl?: '30m' | '12h' };
}
export interface ResponsesApiImagePart {
@@ -129,7 +134,7 @@ export interface ResponsesApiFunctionCallItem {
export interface ResponsesApiFunctionCallOutputItem {
type: 'function_call_output';
call_id: string;
output: string;
output: string | ResponsesApiContentPart[];
}
export interface ResponsesApiCustomToolCallItem {