diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index c482366570bf..db155a215a16 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -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/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) +- Direct OpenAI API-key Responses requests default to `"sse"`. +- Override per model via `agents.defaults.models["openai/"].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. diff --git a/packages/ai/src/transports/openai-responses-websocket-client.test.ts b/packages/ai/src/transports/openai-responses-websocket-client.test.ts index 9f86694c822b..ca7f4161ea3a 100644 --- a/packages/ai/src/transports/openai-responses-websocket-client.test.ts +++ b/packages/ai/src/transports/openai-responses-websocket-client.test.ts @@ -136,11 +136,27 @@ function completedEvent(responseId: string, content?: string | Array { 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"))], diff --git a/packages/ai/src/transports/openai-responses-websocket.test.ts b/packages/ai/src/transports/openai-responses-websocket.test.ts index 57fbf2ab6ae0..d2b52bf0a50d 100644 --- a/packages/ai/src/transports/openai-responses-websocket.test.ts +++ b/packages/ai/src/transports/openai-responses-websocket.test.ts @@ -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> = []) { @@ -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) => ({ + ...request, + input: [ + firstUser, + { ...assistantOutput, phase: "commentary" }, + { role: "user", content: "second" }, + ], + }), + }, ])("resets continuation on $name", async ({ mutate }) => { websocketState.responseBatches.push( [completion("resp_1", [assistantOutput])], diff --git a/packages/ai/src/transports/openai-responses-websocket.ts b/packages/ai/src/transports/openai-responses-websocket.ts index 628e0f8f392d..b2b9a1db6e8f 100644 --- a/packages/ai/src/transports/openai-responses-websocket.ts +++ b/packages/ai/src/transports/openai-responses-websocket.ts @@ -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): 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; - 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"); }