From 628c71103eb7c6cac630bfeeaf1dcf4a5a1422fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 01:19:00 +0100 Subject: [PATCH] fix: align native openai transport defaults --- CHANGELOG.md | 1 + docs/providers/openai.md | 14 +++ extensions/openai/openai-codex-provider.ts | 2 +- extensions/openai/openai-provider.test.ts | 39 +++++++- extensions/openai/openai-provider.ts | 4 +- .../openai-responses-payload-policy.test.ts | 28 +++++- src/agents/openai-responses-payload-policy.ts | 3 +- src/agents/openai-transport-stream.test.ts | 93 +++++++++++++++++++ src/agents/openai-transport-stream.ts | 62 ++++++++++++- .../pi-embedded-runner-extraparams.test.ts | 18 ++-- .../openai-stream-wrappers.ts | 2 +- .../list.list-command.forward-compat.test.ts | 2 +- 12 files changed, 245 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4a1dbba7d..1d5beaabba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/OpenAI: preserve native `reasoning.effort: "none"` and strict tool schemas on direct OpenAI-family endpoints, keep OpenAI-compatible proxies on the older compat shim path, and enable OpenAI WebSocket warm-up by default for native Responses routes. - Skills/uv install: block workspace `.env` from overriding `UV_PYTHON` and strip related interpreter override keys from uv skill-install subprocesses so repository-controlled env files cannot steer the selected Python runtime. (#59178) Thanks @pgondhi987. - Telegram/reactions: preserve `reactionNotifications: "own"` across gateway restarts by persisting sent-message ownership state instead of treating cold cache as a permissive fallback. (#59207) Thanks @samzong. - Gateway/startup: detect PID recycling in gateway lock files on Windows and macOS, and add startup progress so stale lock conflicts no longer block healthy restarts. (#59843) Thanks @TonyDerek-dot. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index f5a39b919dc..2130abe9794 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -239,6 +239,20 @@ Example: Session overrides win over config. Clearing the session override in the Sessions UI returns the session to the configured default. +### Native OpenAI versus OpenAI-compatible routes + +OpenClaw treats direct OpenAI, Codex, and Azure OpenAI endpoints differently +from generic OpenAI-compatible `/v1` proxies: + +- native `openai/*`, `openai-codex/*`, and Azure OpenAI routes keep + `reasoning: { effort: "none" }` intact when you explicitly disable reasoning +- native OpenAI-family routes default tool schemas to strict mode +- proxy-style OpenAI-compatible routes keep the looser compat behavior and do + not force strict tool schemas or native-only request shaping + +This preserves current native OpenAI Responses behavior without forcing older +OpenAI-compatible shims onto third-party `/v1` backends. + ### OpenAI Responses server-side compaction For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 863d4ba2fde..c315e307cb1 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -33,7 +33,7 @@ import { wrapOpenAICodexProviderStream } from "./stream-hooks.js"; const PROVIDER_ID = "openai-codex"; const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4"; -const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 272_000; +const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 400_000; const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000; const OPENAI_CODEX_GPT_54_COST = { input: 2.5, diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index c5e05a12e88..bf0bd6274c4 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -163,6 +163,39 @@ describe("buildOpenAIProvider", () => { ); }); + it("keeps GPT-5.4 family metadata aligned with native OpenAI docs", () => { + const provider = buildOpenAIProvider(); + const codexProvider = buildOpenAICodexProviderPlugin(); + + const openaiModel = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-5.4", + modelRegistry: { find: () => null }, + } as never); + const codexModel = codexProvider.resolveDynamicModel?.({ + provider: "openai-codex", + modelId: "gpt-5.4", + modelRegistry: { find: () => null }, + } as never); + + expect(openaiModel).toMatchObject({ + provider: "openai", + id: "gpt-5.4", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + contextWindow: 1_050_000, + maxTokens: 128_000, + }); + expect(codexModel).toMatchObject({ + provider: "openai-codex", + id: "gpt-5.4", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + contextWindow: 400_000, + maxTokens: 128_000, + }); + }); + it("keeps modern live selection on OpenAI 5.2+ and Codex 5.2+", () => { const provider = buildOpenAIProvider(); const codexProvider = buildOpenAICodexProviderPlugin(); @@ -289,11 +322,11 @@ describe("buildOpenAIProvider", () => { expect(extraParams).toMatchObject({ transport: "auto", - openaiWsWarmup: false, + openaiWsWarmup: true, }); expect(result.payload.service_tier).toBe("priority"); expect(result.payload.text).toEqual({ verbosity: "low" }); - expect(result.payload).not.toHaveProperty("reasoning"); + expect(result.payload.reasoning).toEqual({ effort: "none" }); }); it("owns Azure OpenAI reasoning compatibility without forcing OpenAI transport defaults", () => { @@ -315,7 +348,7 @@ describe("buildOpenAIProvider", () => { expect(result.options?.transport).toBeUndefined(); expect(result.options?.openaiWsWarmup).toBeUndefined(); - expect(result.payload).not.toHaveProperty("reasoning"); + expect(result.payload.reasoning).toEqual({ effort: "none" }); }); it("owns Codex wrapper composition for responses payloads", () => { diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index f8398107e3e..a85a994f928 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -24,7 +24,7 @@ const OPENAI_GPT_54_MODEL_ID = "gpt-5.4"; const OPENAI_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro"; const OPENAI_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini"; const OPENAI_GPT_54_NANO_MODEL_ID = "gpt-5.4-nano"; -const OPENAI_GPT_54_CONTEXT_TOKENS = 272_000; +const OPENAI_GPT_54_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_PRO_CONTEXT_TOKENS = 1_050_000; const OPENAI_GPT_54_MINI_CONTEXT_TOKENS = 400_000; const OPENAI_GPT_54_NANO_CONTEXT_TOKENS = 400_000; @@ -248,7 +248,7 @@ export function buildOpenAIProvider(): ProviderPlugin { return { ...ctx.extraParams, ...(hasSupportedTransport ? {} : { transport: "auto" }), - ...(hasExplicitWarmup ? {} : { openaiWsWarmup: false }), + ...(hasExplicitWarmup ? {} : { openaiWsWarmup: true }), }; }, wrapStreamFn: (ctx) => diff --git a/src/agents/openai-responses-payload-policy.test.ts b/src/agents/openai-responses-payload-policy.test.ts index eec00fd0528..88bfdb63165 100644 --- a/src/agents/openai-responses-payload-policy.test.ts +++ b/src/agents/openai-responses-payload-policy.test.ts @@ -58,7 +58,7 @@ describe("openai responses payload policy", () => { expect(payload).not.toHaveProperty("prompt_cache_retention"); }); - it("strips disabled reasoning payloads through the shared helper", () => { + it("keeps disabled reasoning payloads on native OpenAI responses routes", () => { const payload = { reasoning: { effort: "none", @@ -77,6 +77,32 @@ describe("openai responses payload policy", () => { ), ); + expect(payload).toEqual({ + reasoning: { + effort: "none", + }, + }); + }); + + it("strips disabled reasoning payloads for proxy-like OpenAI responses routes", () => { + const payload = { + reasoning: { + effort: "none", + }, + } satisfies Record; + + applyOpenAIResponsesPayloadPolicy( + payload, + resolveOpenAIResponsesPayloadPolicy( + { + api: "openai-responses", + provider: "openai", + baseUrl: "https://proxy.example.com/v1", + }, + { storeMode: "disable" }, + ), + ); + expect(payload).not.toHaveProperty("reasoning"); }); }); diff --git a/src/agents/openai-responses-payload-policy.ts b/src/agents/openai-responses-payload-policy.ts index 7e6b8a63da2..31b93601b55 100644 --- a/src/agents/openai-responses-payload-policy.ts +++ b/src/agents/openai-responses-payload-policy.ts @@ -119,7 +119,8 @@ export function resolveOpenAIResponsesPayloadPolicy( parsePositiveInteger(options.extraParams?.responsesCompactThreshold) ?? resolveOpenAIResponsesCompactThreshold(model), explicitStore, - shouldStripDisabledReasoningPayload: capabilities.supportsOpenAIReasoningCompatPayload, + shouldStripDisabledReasoningPayload: + capabilities.supportsOpenAIReasoningCompatPayload && !capabilities.usesKnownNativeOpenAIRoute, shouldStripPromptCache: options.enablePromptCacheStripping === true && capabilities.shouldStripResponsesPromptCache, shouldStripStore: diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 4d46a9beaea..489b4466487 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -439,6 +439,68 @@ describe("openai transport stream", () => { expect(params.input?.[0]).toMatchObject({ role: "developer" }); }); + it("defaults responses tool schemas to strict on native OpenAI routes", () => { + const params = buildOpenAIResponsesParams( + { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-responses">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean }> }; + + expect(params.tools?.[0]?.strict).toBe(true); + }); + + it("omits responses strict tool shaping for proxy-like OpenAI routes", () => { + const params = buildOpenAIResponsesParams( + { + id: "custom-model", + name: "Custom Model", + api: "openai-responses", + provider: "openai", + 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-responses">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + undefined, + ) as { tools?: Array<{ strict?: boolean }> }; + + expect(params.tools?.[0]).not.toHaveProperty("strict"); + }); + it("gates responses service_tier to native OpenAI endpoints", () => { const nativeParams = buildOpenAIResponsesParams( { @@ -695,6 +757,37 @@ describe("openai transport stream", () => { expect(params.tools?.[0]?.function).not.toHaveProperty("strict"); }); + it("defaults completions tool schemas to strict on native OpenAI routes", () => { + const params = buildOpenAICompletionsParams( + { + id: "gpt-5", + name: "GPT-5", + api: "openai-completions", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {} }, + }, + ], + } as never, + undefined, + ) as { tools?: Array<{ function?: { strict?: boolean } }> }; + + expect(params.tools?.[0]?.function?.strict).toBe(true); + }); + it("uses Mistral compat defaults for direct Mistral completions providers", () => { const params = buildOpenAICompletionsParams( { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 993327a6a4a..f590cc98699 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -328,7 +328,15 @@ function convertResponsesTools( tools: NonNullable, options?: { strict?: boolean | null }, ): FunctionTool[] { - const strict = options?.strict === undefined ? false : options.strict; + const strict = options?.strict; + if (strict === undefined) { + return tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })) as unknown as FunctionTool[]; + } return tools.map((tool) => ({ type: "function", name: tool.name, @@ -698,7 +706,9 @@ export function buildOpenAIResponsesParams( params.service_tier = options.serviceTier; } if (context.tools) { - params.tools = convertResponsesTools(context.tools); + params.tools = convertResponsesTools(context.tools, { + strict: resolveOpenAIStrictToolSetting(model as OpenAIModeModel), + }); } if (model.reasoning) { if (options?.reasoningEffort || options?.reasoningSummary) { @@ -1156,14 +1166,56 @@ function mapReasoningEffort(effort: string, reasoningEffortMap: Record, compat: ReturnType) { +function resolvesToNativeOpenAIStrictTools(model: OpenAIModeModel): boolean { + const capabilities = resolveProviderRequestCapabilities({ + provider: model.provider, + api: model.api, + baseUrl: model.baseUrl, + capability: "llm", + transport: "stream", + modelId: model.id, + compat: + model.compat && typeof model.compat === "object" + ? (model.compat as { supportsStore?: boolean }) + : undefined, + }); + if (!capabilities.usesKnownNativeOpenAIRoute) { + return false; + } + return ( + capabilities.provider === "openai" || + capabilities.provider === "openai-codex" || + capabilities.provider === "azure-openai" || + capabilities.provider === "azure-openai-responses" + ); +} + +function resolveOpenAIStrictToolSetting( + model: OpenAIModeModel, + compat?: ReturnType, +): boolean | undefined { + if (resolvesToNativeOpenAIStrictTools(model)) { + return true; + } + if (compat?.supportsStrictMode) { + return false; + } + return undefined; +} + +function convertTools( + tools: NonNullable, + compat: ReturnType, + model: OpenAIModeModel, +) { + const strict = resolveOpenAIStrictToolSetting(model, compat); return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters, - ...(compat.supportsStrictMode ? { strict: false } : {}), + ...(strict === undefined ? {} : { strict }), }, })); } @@ -1196,7 +1248,7 @@ export function buildOpenAICompletionsParams( params.temperature = options.temperature; } if (context.tools) { - params.tools = convertTools(context.tools, compat); + params.tools = convertTools(context.tools, compat, model); } else if (hasToolHistory(context.messages)) { params.tools = []; } diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 37a56147999..058d50d645b 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -707,7 +707,7 @@ describe("applyExtraParamsToAgent", () => { expect(payloads[0]).not.toHaveProperty("reasoning_effort"); }); - it("strips disabled reasoning payloads for native OpenAI responses routes", () => { + it("keeps disabled reasoning payloads for native OpenAI responses routes", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { @@ -731,7 +731,9 @@ describe("applyExtraParamsToAgent", () => { void agent.streamFn?.(model, context, {}); expect(payloads).toHaveLength(1); - expect(payloads[0]).not.toHaveProperty("reasoning"); + expect(payloads[0]).toEqual({ + reasoning: { effort: "none", summary: "auto" }, + }); }); it("keeps disabled reasoning payloads for proxied OpenAI responses routes", () => { @@ -1572,7 +1574,7 @@ describe("applyExtraParamsToAgent", () => { expect(calls[0]?.transport).toBe("auto"); }); - it("defaults OpenAI transport to auto without websocket warm-up", () => { + it("defaults OpenAI transport to auto with websocket warm-up", () => { const { calls, agent } = createOptionsCaptureAgent(); applyExtraParamsToAgent(agent, undefined, "openai", "gpt-5"); @@ -1587,7 +1589,7 @@ describe("applyExtraParamsToAgent", () => { expect(calls).toHaveLength(1); expect(calls[0]?.transport).toBe("auto"); - expect(calls[0]?.openaiWsWarmup).toBe(false); + expect(calls[0]?.openaiWsWarmup).toBe(true); }); it("injects native Codex web_search for direct openai-codex Responses models", () => { @@ -2165,7 +2167,7 @@ describe("applyExtraParamsToAgent", () => { expect(payload.store).toBe(true); }); - it("strips disabled OpenAI reasoning payloads instead of sending effort:none", () => { + it("keeps disabled OpenAI reasoning payloads on native Responses routes", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "openai", applyModelId: "gpt-5-mini", @@ -2180,10 +2182,10 @@ describe("applyExtraParamsToAgent", () => { reasoning: { effort: "none" }, }, }); - expect(payload).not.toHaveProperty("reasoning"); + expect(payload.reasoning).toEqual({ effort: "none" }); }); - it("strips disabled Azure OpenAI Responses reasoning payloads", () => { + it("keeps disabled Azure OpenAI Responses reasoning payloads", () => { const payload = runResponsesPayloadMutationCase({ applyProvider: "azure-openai-responses", applyModelId: "gpt-5-mini", @@ -2198,7 +2200,7 @@ describe("applyExtraParamsToAgent", () => { reasoning: { effort: "none" }, }, }); - expect(payload).not.toHaveProperty("reasoning"); + expect(payload.reasoning).toEqual({ effort: "none" }); }); it("injects configured OpenAI service_tier into Responses payloads", () => { diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 6e62f41b69a..ab14bbc9c4f 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -359,7 +359,7 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und const mergedOptions = { ...options, transport: options?.transport ?? "auto", - openaiWsWarmup: typedOptions?.openaiWsWarmup ?? false, + openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true, } as SimpleStreamOptions; return underlying(model, context, mergedOptions); }; diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 277abf43c70..a2f8d06819a 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -268,7 +268,7 @@ describe("modelsListCommand forward-compat", () => { id: "gpt-5.4", name: "GPT-5.3 Codex", input: ["text"], - contextWindow: 272000, + contextWindow: 400000, }, ]); mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>