fix(providers): strip cache-boundary marker from non-Anthropic prompts (#89716)

This commit is contained in:
Masato Hoshino
2026-06-22 19:14:31 +00:00
committed by GitHub
parent 23f94bfa78
commit 965d1fff3f
8 changed files with 122 additions and 4 deletions
+11
View File
@@ -1,6 +1,7 @@
// Google shared provider tests cover response conversion and finish reasons.
import { FinishReason, type GenerateContentResponse } from "@google/genai";
import { describe, expect, it } from "vitest";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js";
import type { AssistantMessage, Model } from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import {
@@ -146,4 +147,14 @@ describe("buildGoogleGenerateContentParams", () => {
expect(params.config?.stopSequences).toEqual(["STOP"]);
});
it("strips the internal cache boundary marker from systemInstruction", () => {
const params = buildGoogleGenerateContentParams(model, {
systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`,
messages: [{ role: "user", content: "hello", timestamp: 0 }],
});
expect(params.config?.systemInstruction).toBe("Stable\nDynamic");
expect(JSON.stringify(params)).not.toContain("OPENCLAW_CACHE_BOUNDARY");
});
});
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type Part,
type ThinkingConfig,
} from "@google/genai";
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
import { calculateCost, clampThinkingLevel } from "../model-utils.js";
import type {
Api,
@@ -500,7 +501,9 @@ export function buildGoogleGenerateContentParams<T extends GoogleApiType>(
const config: GenerateContentConfig = {
...(Object.keys(generationConfig).length > 0 && generationConfig),
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
...(context.systemPrompt && {
systemInstruction: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)),
}),
...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
};
+21
View File
@@ -1,5 +1,6 @@
// Mistral provider tests cover request mapping and stream conversion.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js";
import type { Context, Model } from "../types.js";
const mistralMockState = vi.hoisted(() => ({
@@ -214,4 +215,24 @@ describe("Mistral provider", () => {
function: { name: "healthy_tool" },
});
});
it("strips the internal cache boundary marker from the system message", async () => {
const stream = streamSimpleMistral(
makeMistralModel(),
{
systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`,
messages: [{ role: "user", content: "hello", timestamp: 0 }],
},
{ apiKey: "sk-mistral-provider" },
);
await stream.result();
const payload = mistralMockState.payloads[0] as {
messages: Array<{ role: string; content: string }>;
};
const systemMessage = payload.messages.find((message) => message.role === "system");
expect(systemMessage?.content).toBe("Stable\nDynamic");
expect(JSON.stringify(payload)).not.toContain("OPENCLAW_CACHE_BOUNDARY");
});
});
+2 -1
View File
@@ -7,6 +7,7 @@ import type {
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components";
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { calculateCost, clampThinkingLevel } from "../model-utils.js";
import type {
@@ -309,7 +310,7 @@ function buildChatPayload(
if (context.systemPrompt) {
payload.messages.unshift({
role: "system",
content: sanitizeSurrogates(context.systemPrompt),
content: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)),
});
}
@@ -1,6 +1,7 @@
// ChatGPT Responses provider tests cover stream handling and timeout behavior.
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js";
import type { Context, Model } from "../types.js";
import {
extractOpenAICodexAccountId,
@@ -403,6 +404,60 @@ describe("streamOpenAICodexResponses transport", () => {
expect(result.errorMessage).toContain("Request timed out after 5ms");
});
it("strips the internal cache boundary marker from request instructions", async () => {
let capturedPayload: { instructions?: string } | undefined;
const stream = streamOpenAICodexResponses(
model,
{
systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`,
messages: [{ role: "user", content: "hi", timestamp: 1 }],
},
{
apiKey: createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "acct-1",
},
}),
transport: "sse",
onPayload: (payload) => {
capturedPayload = payload as typeof capturedPayload;
throw new Error("stop after payload");
},
},
);
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(capturedPayload?.instructions).toBe("Stable\nDynamic");
expect(JSON.stringify(capturedPayload)).not.toContain("OPENCLAW_CACHE_BOUNDARY");
});
it("falls back to the default instructions when no system prompt is set", async () => {
let capturedPayload: { instructions?: string } | undefined;
const stream = streamOpenAICodexResponses(
model,
{ messages: [{ role: "user", content: "hi", timestamp: 1 }] },
{
apiKey: createJwt({
"https://api.openai.com/auth": {
chatgpt_account_id: "acct-1",
},
}),
transport: "sse",
onPayload: (payload) => {
capturedPayload = payload as typeof capturedPayload;
throw new Error("stop after payload");
},
},
);
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(capturedPayload?.instructions).toBe("You are a helpful assistant.");
});
it("prefers promptCacheKey over sessionId for request cache affinity", async () => {
let payload: unknown;
vi.stubGlobal(
@@ -25,6 +25,7 @@ import {
resolveTimerTimeoutMs,
clampTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { clampThinkingLevel } from "../model-utils.js";
import { registerSessionResourceCleanup } from "../session-resources.js";
@@ -488,7 +489,8 @@ function buildRequestBody(
model: model.id,
store: false,
stream: true,
instructions: context.systemPrompt || "You are a helpful assistant.",
instructions:
stripSystemPromptCacheBoundary(context.systemPrompt ?? "") || "You are a helpful assistant.",
input: messages,
text: { verbosity: options?.textVerbosity || "low" },
include: ["reasoning.encrypted_content"],
@@ -1,6 +1,7 @@
// OpenAI Responses shared tests cover tool conversion and response item mapping.
import type { Tool as OpenAIResponsesTool } from "openai/resources/responses/responses.js";
import { describe, expect, it } from "vitest";
import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js";
import type { AssistantMessage, AssistantMessageEvent, Context, Model, Tool } from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import {
@@ -262,6 +263,24 @@ describe("convertResponsesMessages", () => {
});
});
it("strips the internal cache boundary marker from the system prompt message", () => {
const input = convertResponsesMessages(
nativeOpenAIModel,
{
systemPrompt: `Stable${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic`,
messages: [],
} satisfies Context,
allowedToolCallProviders,
);
expect(input[0]).toMatchObject({
type: "message",
role: "developer",
content: [{ type: "input_text", text: "Stable\nDynamic" }],
});
expect(JSON.stringify(input)).not.toContain("OPENCLAW_CACHE_BOUNDARY");
});
it("omits phase-tagged assistant replay ids without reasoning", () => {
const input = convertResponsesMessages(
nativeOpenAIModel,
+7 -1
View File
@@ -13,6 +13,7 @@ import type {
ResponseReasoningItem,
ResponseStreamEvent,
} from "openai/resources/responses/responses.js";
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
import {
AZURE_RESPONSES_TEXT_CONTENT_PART_TYPE,
OPENAI_RESPONSES_OUTPUT_TEXT_CONTENT_PART_TYPE,
@@ -254,7 +255,12 @@ export function convertResponsesMessages<TApi extends Api>(
messages.push({
type: "message",
role,
content: [{ type: "input_text", text: sanitizeSurrogates(context.systemPrompt) }],
content: [
{
type: "input_text",
text: sanitizeSurrogates(stripSystemPromptCacheBoundary(context.systemPrompt)),
},
],
});
}