mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:50:49 +00:00
fix(openai-codex): avoid stale Responses replay state
This commit is contained in:
committed by
Peter Steinberger
parent
8285786c22
commit
27e467ad23
@@ -1059,7 +1059,7 @@ describe("openai transport stream", () => {
|
||||
expect(params.input?.some((item) => item.role === "system" || item.role === "developer")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(params.prompt_cache_key).toBe("session-123");
|
||||
expect(params).not.toHaveProperty("prompt_cache_key");
|
||||
expect(params.store).toBe(false);
|
||||
expect(params).not.toHaveProperty("metadata");
|
||||
expect(params).not.toHaveProperty("max_output_tokens");
|
||||
@@ -1097,7 +1097,7 @@ describe("openai transport stream", () => {
|
||||
payload,
|
||||
);
|
||||
|
||||
expect(sanitized.prompt_cache_key).toBe("session-123");
|
||||
expect(sanitized).not.toHaveProperty("prompt_cache_key");
|
||||
expect(sanitized).not.toHaveProperty("metadata");
|
||||
expect(sanitized).not.toHaveProperty("max_output_tokens");
|
||||
expect(sanitized).not.toHaveProperty("prompt_cache_retention");
|
||||
@@ -1178,6 +1178,197 @@ describe("openai transport stream", () => {
|
||||
expect(sanitized).toEqual(payload);
|
||||
});
|
||||
|
||||
it("omits prior Responses replay item ids for native Codex responses", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-codex-responses">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Need a tool.",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_prior",
|
||||
encrypted_content: "ciphertext",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking the price.",
|
||||
textSignature: JSON.stringify({
|
||||
v: 1,
|
||||
id: "msg_prior",
|
||||
phase: "commentary",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_abc|fc_prior",
|
||||
name: "price_lookup",
|
||||
arguments: { symbol: "SOL" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_abc|fc_prior",
|
||||
toolName: "price_lookup",
|
||||
content: [{ type: "text", text: "$83.95" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
{ role: "user", content: "what is the capital of the philippines", timestamp: 3 },
|
||||
],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ sessionId: "session-123" },
|
||||
) as {
|
||||
input?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(params.input?.some((item) => item.type === "reasoning")).toBe(false);
|
||||
const assistantMessage = params.input?.find(
|
||||
(item) => item.type === "message" && item.role === "assistant",
|
||||
);
|
||||
expect(assistantMessage).toMatchObject({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
phase: "commentary",
|
||||
});
|
||||
expect(assistantMessage?.id).toBeUndefined();
|
||||
const functionCall = params.input?.find((item) => item.type === "function_call");
|
||||
expect(functionCall).toMatchObject({
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
});
|
||||
expect(functionCall?.id).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves prior Responses replay item ids for custom Codex-compatible responses", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-codex-responses">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Need a tool.",
|
||||
thinkingSignature: JSON.stringify({
|
||||
type: "reasoning",
|
||||
id: "rs_prior",
|
||||
encrypted_content: "ciphertext",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking the price.",
|
||||
textSignature: JSON.stringify({
|
||||
v: 1,
|
||||
id: "msg_prior",
|
||||
phase: "commentary",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_abc|fc_prior",
|
||||
name: "price_lookup",
|
||||
arguments: { symbol: "SOL" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ sessionId: "session-123" },
|
||||
) as {
|
||||
input?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(params.input?.some((item) => item.type === "reasoning")).toBe(true);
|
||||
const assistantMessage = params.input?.find(
|
||||
(item) => item.type === "message" && item.role === "assistant",
|
||||
);
|
||||
expect(assistantMessage).toMatchObject({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg_prior",
|
||||
phase: "commentary",
|
||||
});
|
||||
const functionCall = params.input?.find((item) => item.type === "function_call");
|
||||
expect(functionCall).toMatchObject({
|
||||
type: "function_call",
|
||||
id: "fc_prior",
|
||||
call_id: "call_abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("adds minimal user input for Codex responses when only the system prompt is present", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
@@ -1492,7 +1683,7 @@ describe("openai transport stream", () => {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
},
|
||||
},
|
||||
])("replays assistant phase metadata for $label responses payloads", ({ model }) => {
|
||||
])("replays assistant phase metadata for $label responses payloads", ({ label, model }) => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
...model,
|
||||
@@ -1548,9 +1739,13 @@ describe("openai transport stream", () => {
|
||||
const assistantItem = params.input?.find((item) => item.role === "assistant");
|
||||
expect(assistantItem).toMatchObject({
|
||||
role: "assistant",
|
||||
id: "msg_commentary",
|
||||
phase: "commentary",
|
||||
});
|
||||
if (label === "openai-codex") {
|
||||
expect(assistantItem?.id).toBeUndefined();
|
||||
} else {
|
||||
expect(assistantItem?.id).toBe("msg_commentary");
|
||||
}
|
||||
});
|
||||
|
||||
it("strips the internal cache boundary from OpenAI system prompts", () => {
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseFunctionCallOutputItemList,
|
||||
ResponseInput,
|
||||
ResponseInputItem,
|
||||
ResponseInputMessageContentList,
|
||||
ResponseOutputMessage,
|
||||
} from "openai/resources/responses/responses.js";
|
||||
import type { ModelCompatConfig } from "../config/types.models.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
@@ -57,6 +59,8 @@ const DEFAULT_AZURE_OPENAI_API_VERSION = "2024-12-01-preview";
|
||||
const OPENAI_CODEX_RESPONSES_EMPTY_INPUT_TEXT = " ";
|
||||
const log = createSubsystemLogger("openai-transport");
|
||||
|
||||
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
|
||||
|
||||
type BaseStreamOptions = {
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
@@ -211,9 +215,16 @@ function convertResponsesMessages(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
allowedToolCallProviders: Set<string>,
|
||||
options?: { includeSystemPrompt?: boolean; supportsDeveloperRole?: boolean },
|
||||
options?: {
|
||||
includeSystemPrompt?: boolean;
|
||||
supportsDeveloperRole?: boolean;
|
||||
replayReasoningItems?: boolean;
|
||||
replayResponsesItemIds?: boolean;
|
||||
},
|
||||
): ResponseInput {
|
||||
const messages: ResponseInput = [];
|
||||
const shouldReplayReasoningItems = options?.replayReasoningItems ?? true;
|
||||
const shouldReplayResponsesItemIds = options?.replayResponsesItemIds ?? true;
|
||||
const normalizeIdPart = (part: string) => {
|
||||
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
|
||||
@@ -287,15 +298,18 @@ function convertResponsesMessages(
|
||||
msg.model !== model.id && msg.provider === model.provider && msg.api === model.api;
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking") {
|
||||
if (block.thinkingSignature) {
|
||||
if (shouldReplayReasoningItems && block.thinkingSignature) {
|
||||
output.push(JSON.parse(block.thinkingSignature));
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
let msgId = parseTextSignature(block.textSignature)?.id ?? `msg_${msgIndex}`;
|
||||
if (msgId.length > 64) {
|
||||
const textSignature = parseTextSignature(block.textSignature);
|
||||
let msgId = shouldReplayResponsesItemIds
|
||||
? (textSignature?.id ?? `msg_${msgIndex}`)
|
||||
: undefined;
|
||||
if (msgId && msgId.length > 64) {
|
||||
msgId = `msg_${shortHash(msgId)}`;
|
||||
}
|
||||
output.push({
|
||||
const messageItem: ReplayableResponseOutputMessage = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -306,12 +320,16 @@ function convertResponsesMessages(
|
||||
},
|
||||
],
|
||||
status: "completed",
|
||||
id: msgId,
|
||||
phase: parseTextSignature(block.textSignature)?.phase,
|
||||
});
|
||||
...(msgId ? { id: msgId } : {}),
|
||||
phase: textSignature?.phase,
|
||||
};
|
||||
output.push(messageItem as ResponseInputItem);
|
||||
} else if (block.type === "toolCall") {
|
||||
const [callId, itemIdRaw] = block.id.split("|");
|
||||
const itemId = isDifferentModel && itemIdRaw?.startsWith("fc_") ? undefined : itemIdRaw;
|
||||
const itemId =
|
||||
shouldReplayResponsesItemIds && !(isDifferentModel && itemIdRaw?.startsWith("fc_"))
|
||||
? itemIdRaw
|
||||
: undefined;
|
||||
output.push({
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
@@ -909,6 +927,7 @@ function usesNativeOpenAICodexResponsesBackend(model: Model<Api>): boolean {
|
||||
const OPENAI_CODEX_RESPONSES_UNSUPPORTED_PARAMS = [
|
||||
"max_output_tokens",
|
||||
"metadata",
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_retention",
|
||||
"service_tier",
|
||||
"temperature",
|
||||
@@ -957,6 +976,7 @@ export function buildOpenAIResponsesParams(
|
||||
metadata?: Record<string, string>,
|
||||
) {
|
||||
const isCodexResponses = isOpenAICodexResponsesModel(model);
|
||||
const isNativeCodexResponses = usesNativeOpenAICodexResponsesBackend(model);
|
||||
const compat = getCompat(model as OpenAIModeModel);
|
||||
const supportsDeveloperRole =
|
||||
typeof compat.supportsDeveloperRole === "boolean" ? compat.supportsDeveloperRole : undefined;
|
||||
@@ -964,7 +984,12 @@ export function buildOpenAIResponsesParams(
|
||||
model,
|
||||
context,
|
||||
new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]),
|
||||
{ includeSystemPrompt: !isCodexResponses, supportsDeveloperRole },
|
||||
{
|
||||
includeSystemPrompt: !isCodexResponses,
|
||||
supportsDeveloperRole,
|
||||
replayReasoningItems: !isNativeCodexResponses,
|
||||
replayResponsesItemIds: !isNativeCodexResponses,
|
||||
},
|
||||
);
|
||||
if (isCodexResponses) {
|
||||
ensureOpenAICodexResponsesInput(messages, context);
|
||||
|
||||
Reference in New Issue
Block a user