fix(ai): restore cached OpenAI continuation (#122483)

This commit is contained in:
Peter Steinberger
2026-08-11 23:24:57 -07:00
committed by GitHub
parent 90beb639e7
commit 0c8c8d95c7
4 changed files with 76 additions and 23 deletions
+2 -2
View File
@@ -108,8 +108,8 @@ Official provider plugins publish their own model catalog rows. These providers
- Example models: `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, `openai/gpt-5.6-luna`, `openai/gpt-5.5`; the bare direct-API `openai/gpt-5.6` alias remains supported.
- Verify account/model availability with `openclaw models list --provider openai` if a specific install or API key behaves differently.
- CLI: `openclaw onboard --auth-choice openai-api-key`
- Default transport is `auto`; OpenClaw passes the transport choice to the shared model runtime.
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- Direct OpenAI API-key Responses requests default to `"sse"`.
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, `"websocket-cached"`, or `"auto"`). Cached WebSockets reuse the session connection and send only new input with `previous_response_id` when history still matches.
- Set an explicit OpenAI API service tier with `params.serviceTier` or `params.service_tier`; Fast mode (formerly Priority processing) uses `service_tier=priority`.
- On native public OpenAI and ChatGPT/Codex Responses requests, precedence is payload/transport `service_tier`, then a valid explicit model param, then the fast-mode default.
- `/fast` and valid `params.fastMode` / `params.fast_mode` values are shared agent-runtime controls; on direct embedded `openai/*` Responses requests they supply `service_tier=priority` only when no higher-precedence tier exists.
@@ -136,11 +136,27 @@ function completedEvent(responseId: string, content?: string | Array<Record<stri
typeof content === "string"
? [
{
type: "message",
id: `msg_${responseId}`,
role: "assistant",
type: "message",
status: "completed",
content: [{ type: "output_text", text: content, annotations: [] }],
content: [
{
annotations: [
{
type: "url_citation",
url: "https://example.test/source",
title: "source",
start_index: 0,
end_index: content.length,
},
],
logprobs: [{ token: content, logprob: -0.1, bytes: [], top_logprobs: [] }],
text: content,
type: "output_text",
},
],
role: "assistant",
phase: "final_answer",
},
]
: (content ?? []);
@@ -281,7 +297,7 @@ describe("native OpenAI Responses WebSocket client integration", () => {
configureAiTransportHost(initialHost);
});
it("reuses one production-path session socket and continues with only new input", async () => {
it("continues past provider-only output metadata with one socket and only new input", async () => {
transportState.responseBatches.push(
[message(completedEvent("resp_1", "first answer"))],
[message(completedEvent("resp_2", "second answer"))],
@@ -83,6 +83,7 @@ const assistantOutput = {
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "one", annotations: [] }],
phase: "final_answer",
};
function completion(responseId: string, output: Array<Record<string, unknown>> = []) {
@@ -206,22 +207,30 @@ describe("native OpenAI Responses WebSocket transport", () => {
});
});
it("uses the response id when persisted encrypted reasoning has a different replay shape", async () => {
it("continues across equivalent request ordering, omissions, and persisted reasoning replay", async () => {
const reasoning = { type: "reasoning", id: "rs_1", encrypted_content: "ciphertext" };
websocketState.responseBatches.push(
[completion("resp_1", [reasoning, assistantOutput])],
[completion("resp_2")],
);
await consumeResponse(createStream({ model: "gpt-5.6-luna", input: [firstUser] }));
await consumeResponse(
createStream({
model: "gpt-5.6-luna",
metadata: { beta: "2", alpha: "1" },
max_output_tokens: undefined,
input: [firstUser],
}),
);
const second = createStream({
model: "gpt-5.6-luna",
input: [
firstUser,
{ type: "reasoning", summary: [] },
assistantOutput,
{ role: "user", content: "second" },
],
metadata: { alpha: "1", beta: "2" },
model: "gpt-5.6-luna",
});
expect(second.continuationStatus).toBe("continued");
@@ -304,6 +313,17 @@ describe("native OpenAI Responses WebSocket transport", () => {
input: [{ role: "user", content: "rewritten" }],
}),
},
{
name: "assistant phase change",
mutate: (request: Record<string, unknown>) => ({
...request,
input: [
firstUser,
{ ...assistantOutput, phase: "commentary" },
{ role: "user", content: "second" },
],
}),
},
])("resets continuation on $name", async ({ mutate }) => {
websocketState.responseBatches.push(
[completion("resp_1", [assistantOutput])],
@@ -1,3 +1,5 @@
import { stableStringify } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type OpenAI from "openai";
import type {
ResponseInput,
@@ -299,22 +301,35 @@ function sanitizeWebSocketRequest(request: Record<string, unknown>): ResponsesWe
return websocketRequest as ResponsesWebSocketRequest;
}
function jsonValuesEqual(left: object, right: object): boolean {
// Round-trip first so stable key ordering retains JSON's omitted/undefined wire semantics.
const leftJson = JSON.parse(JSON.stringify(left) as string);
const rightJson = JSON.parse(JSON.stringify(right) as string);
return stableStringify(leftJson) === stableStringify(rightJson);
}
function normalizeAssistantReplayInput(input: readonly unknown[]): unknown[] {
return input.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
if (!isRecord(item)) {
return item;
}
const typedItem = item as unknown as Record<string, unknown>;
if (typedItem.type === "reasoning") {
if (item.type === "reasoning") {
return { type: "reasoning" };
}
if (
typedItem.type !== "function_call" &&
!(typedItem.type === "message" && typedItem.role === "assistant")
) {
if (item.type !== "function_call" && !(item.type === "message" && item.role === "assistant")) {
return item;
}
const { id: _id, status: _status, ...stableItem } = typedItem;
const { id: _id, status: _status, ...stableItem } = item;
if (item.type === "message" && Array.isArray(stableItem.content)) {
// Strip only provider delivery metadata that reconstructed assistant replay cannot contain.
stableItem.content = stableItem.content.map((part) => {
if (!isRecord(part) || part.type !== "output_text") {
return part;
}
const { annotations: _annotations, logprobs: _logprobs, ...stablePart } = part;
return stablePart;
});
}
return stableItem;
});
}
@@ -340,8 +355,7 @@ function buildCachedWebSocketRequest(
return rejectContinuation("explicit_previous_response_id");
}
if (
JSON.stringify(requestWithoutInput(request)) !==
JSON.stringify(requestWithoutInput(continuation.lastRequest))
!jsonValuesEqual(requestWithoutInput(request), requestWithoutInput(continuation.lastRequest))
) {
return rejectContinuation("request_changed");
}
@@ -353,11 +367,14 @@ function buildCachedWebSocketRequest(
return rejectContinuation("history_shorter");
}
if (
JSON.stringify(normalizeAssistantReplayInput(currentInput.slice(0, previousInput.length))) !==
JSON.stringify(normalizeAssistantReplayInput(previousInput)) ||
JSON.stringify(
!jsonValuesEqual(
normalizeAssistantReplayInput(currentInput.slice(0, previousInput.length)),
normalizeAssistantReplayInput(previousInput),
) ||
!jsonValuesEqual(
normalizeAssistantReplayInput(currentInput.slice(previousInput.length, baselineLength)),
) !== JSON.stringify(normalizeAssistantReplayInput(continuation.lastResponseItems))
normalizeAssistantReplayInput(continuation.lastResponseItems),
)
) {
return rejectContinuation("history_changed");
}