mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(core): preserve Responses cache boundaries before auto-memory context (#15006)
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user