mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ai): restore cached OpenAI continuation (#122483)
This commit is contained in:
committed by
GitHub
parent
90beb639e7
commit
0c8c8d95c7
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user