diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c75b38aad..0495091d633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Sessions/reset: clear auto-sourced model, provider, and auth-profile overrides on `/new` and `/reset` while preserving explicit user selections, so channel sessions stop staying pinned to runtime fallback choices. (#69419) Thanks @sk7n4k3d. - Sessions/costs: snapshot `estimatedCostUsd` like token counters so repeated persist paths no longer compound the same run cost by up to dozens of times. (#69403) Thanks @MrMiaigi. - OpenAI Codex: route ChatGPT/Codex OAuth Responses requests through the `/backend-api/codex` endpoint so `openai-codex/gpt-5.4` no longer hits the removed `/backend-api/responses` alias. (#69336) Thanks @mzogithub. +- OpenAI/Responses: omit disabled reasoning payloads when `/think off` is active, so GPT reasoning models no longer receive unsupported `reasoning.effort: "none"` requests. (#61982) Thanks @a-tokyo. - Gateway/pairing: treat loopback shared-secret node-host, TUI, and gateway clients as local for pairing decisions, so trusted local tools no longer reconnect as remote clients and fail with `pairing required`. (#69431) Thanks @SARAMALI15792. - Active Memory: degrade gracefully when memory recall fails during prompt building, logging a warning and letting the reply continue without memory context instead of failing the whole turn. (#69485) Thanks @Magicray1217. - Ollama: add provider-policy defaults for `baseUrl` and `models` so implicit local discovery can run before config validation rejects a minimal Ollama provider config. (#69370) Thanks @PratikRai0101. diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index f37b06bd1a8..3c680c231dd 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -6,6 +6,7 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; import process from "node:process"; +import { pathToFileURL } from "node:url"; import { writeBuildStamp } from "./build-stamp.mjs"; import { resolveBuildRequirement } from "./run-node.mjs"; @@ -180,6 +181,23 @@ function snapshotTree(rootName) { return stats; } +export function isIgnoredDistRuntimeWatchPath(entry) { + return ( + entry === "dist-runtime/extensions/node_modules" || + entry.startsWith("dist-runtime/extensions/node_modules/") + ); +} + +function summarizeDistRuntimeAddedPaths(added) { + const addedPaths = added.filter((entry) => entry.startsWith("dist-runtime/")); + const ignoredDependencyAddedPaths = addedPaths.filter(isIgnoredDistRuntimeWatchPath); + const topologyAddedPaths = addedPaths.filter((entry) => !isIgnoredDistRuntimeWatchPath(entry)); + return { + ignoredDependencyAddedPaths, + topologyAddedPaths, + }; +} + function writeSnapshot(snapshotDir) { ensureDir(snapshotDir); const pathEntries = [...listTreeEntries("dist"), ...listTreeEntries("dist-runtime")]; @@ -606,11 +624,15 @@ async function main() { const post = writeSnapshot(postDir); const diff = writeDiffArtifacts(options.outputDir, preDir, postDir); - const distRuntimeFileGrowth = post.distRuntime.files - pre.distRuntime.files; - const distRuntimeByteGrowth = post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes; - const distRuntimeAddedPaths = diff.added.filter((entry) => - entry.startsWith("dist-runtime/"), - ).length; + const distRuntimeAddedPathSummary = summarizeDistRuntimeAddedPaths(diff.added); + const distRuntimeAddedPaths = distRuntimeAddedPathSummary.topologyAddedPaths.length; + const distRuntimeIgnoredDependencyAddedPaths = + distRuntimeAddedPathSummary.ignoredDependencyAddedPaths.length; + const distRuntimeFileGrowth = distRuntimeAddedPaths; + const distRuntimeByteGrowth = + distRuntimeAddedPaths === 0 + ? 0 + : post.distRuntime.apparentBytes - pre.distRuntime.apparentBytes; const totalCpuMs = Math.round( (watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000, ); @@ -639,6 +661,7 @@ async function main() { distRuntimeByteGrowth, distRuntimeByteGrowthMax: options.distRuntimeByteGrowthMax, distRuntimeAddedPaths, + distRuntimeIgnoredDependencyAddedPaths, addedPaths: diff.added.length, removedPaths: diff.removed.length, watchExit: watchResult.exit, @@ -699,4 +722,6 @@ async function main() { process.exit(0); } -await main(); +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + await main(); +} diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 24e5a5bcb1f..a7e1fb43e5c 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -297,6 +297,7 @@ import { createOpenAIServiceTierWrapper, createOpenAIStringContentWrapper, createOpenAITextVerbosityWrapper, + createOpenAIThinkingLevelWrapper, resolveOpenAIFastMode, resolveOpenAIServiceTier, resolveOpenAITextVerbosity, @@ -434,7 +435,9 @@ function createTestOpenAIProviderWrapper( }); streamFn = createOpenAIStringContentWrapper(streamFn); return createOpenAIResponsesContextManagementWrapper( - createOpenAIReasoningCompatibilityWrapper(streamFn), + createOpenAIReasoningCompatibilityWrapper( + createOpenAIThinkingLevelWrapper(streamFn, params.context.thinkingLevel), + ), params.context.extraParams, ); } diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts new file mode 100644 index 00000000000..f80dbc16d12 --- /dev/null +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.test.ts @@ -0,0 +1,195 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Model } from "@mariozechner/pi-ai"; +import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import { describe, expect, it } from "vitest"; +import { createOpenAIThinkingLevelWrapper } from "./openai-stream-wrappers.js"; + +function createPayloadCapture(opts?: { initialReasoning?: unknown }) { + const payloads: Array> = []; + const baseStreamFn: StreamFn = (model, _context, options) => { + const payload: Record = { model: model.id }; + if (opts?.initialReasoning !== undefined) { + payload.reasoning = structuredClone(opts.initialReasoning); + } + options?.onPayload?.(payload, model); + payloads.push(structuredClone(payload)); + return createAssistantMessageEventStream(); + }; + return { baseStreamFn, payloads }; +} + +const codexModel = { + api: "openai-codex-responses", + provider: "openai-codex", + id: "gpt-5.1-codex", +} as Model<"openai-codex-responses">; + +const openaiModel = { + api: "openai-responses", + provider: "openai", + id: "gpt-5.2", +} as Model<"openai-responses">; + +describe("createOpenAIThinkingLevelWrapper", () => { + it("overrides effort on reasoning-capable model when thinkingLevel is medium", () => { + const { baseStreamFn, payloads } = createPayloadCapture({ + initialReasoning: { effort: "none" }, + }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "medium"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "medium" }); + }); + + it("overrides effort on reasoning-capable model when thinkingLevel is high", () => { + const { baseStreamFn, payloads } = createPayloadCapture({ + initialReasoning: { effort: "none" }, + }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "high"); + void wrapped(openaiModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "high" }); + }); + + it("removes reasoning when thinkingLevel is off on reasoning-capable model", () => { + const { baseStreamFn, payloads } = createPayloadCapture({ + initialReasoning: { effort: "medium" }, + }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "off"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]).not.toHaveProperty("reasoning"); + }); + + it("maps adaptive thinkingLevel to medium effort on reasoning-capable model", () => { + const { baseStreamFn, payloads } = createPayloadCapture({ + initialReasoning: { effort: "none" }, + }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "adaptive"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "medium" }); + }); + + it("replaces string disabled reasoning when thinkingLevel is enabled", () => { + const { baseStreamFn, payloads } = createPayloadCapture({ initialReasoning: "none" }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "low"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "low" }); + }); + + it("does not add reasoning for non-reasoning models without existing reasoning payload", () => { + const { baseStreamFn, payloads } = createPayloadCapture(); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "medium"); + void wrapped(openaiModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toBeUndefined(); + }); + + it("overrides existing reasoning.effort from upstream wrappers", () => { + const baseStreamFn: StreamFn = (model, _context, options) => { + const payload: Record = { + model: model.id, + reasoning: { effort: "none" }, + }; + options?.onPayload?.(payload, model); + return createAssistantMessageEventStream(); + }; + + const payloads: Array> = []; + const capture: StreamFn = (model, context, options) => { + return baseStreamFn(model, context, { + ...options, + onPayload: (payload, m) => { + options?.onPayload?.(payload, m); + payloads.push(structuredClone(payload as Record)); + }, + }); + }; + + const wrapped = createOpenAIThinkingLevelWrapper(capture, "medium"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "medium" }); + }); + + it("returns underlying streamFn unchanged when thinkingLevel is undefined", () => { + const { baseStreamFn } = createPayloadCapture(); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, undefined); + expect(wrapped).toBe(baseStreamFn); + }); + + it("preserves other reasoning properties when overriding effort", () => { + const baseStreamFn: StreamFn = (model, _context, options) => { + const payload: Record = { + model: model.id, + reasoning: { effort: "none", summary: "auto" }, + }; + options?.onPayload?.(payload, model); + return createAssistantMessageEventStream(); + }; + + const payloads: Array> = []; + const capture: StreamFn = (model, context, options) => { + return baseStreamFn(model, context, { + ...options, + onPayload: (payload, m) => { + options?.onPayload?.(payload, m); + payloads.push(structuredClone(payload as Record)); + }, + }); + }; + + const wrapped = createOpenAIThinkingLevelWrapper(capture, "high"); + void wrapped(codexModel, { messages: [] }, {}); + + expect(payloads[0]?.reasoning).toEqual({ effort: "high", summary: "auto" }); + }); + + it("does not inject reasoning for completions API on proxy routes", () => { + const { baseStreamFn, payloads } = createPayloadCapture(); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "medium"); + void wrapped( + { + api: "openai-completions", + provider: "openai", + id: "gpt-4o", + baseUrl: "https://proxy.example.com/v1", + } as Model<"openai-completions">, + { messages: [] }, + {}, + ); + + expect(payloads[0]?.reasoning).toBeUndefined(); + }); + + it("does not inject reasoning for proxy routes with custom baseUrl", () => { + const { baseStreamFn, payloads } = createPayloadCapture(); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "medium"); + void wrapped( + { + api: "openai-responses", + provider: "openai", + id: "gpt-5.2", + baseUrl: "https://proxy.example.com/v1", + } as Model<"openai-responses">, + { messages: [] }, + {}, + ); + + expect(payloads[0]?.reasoning).toBeUndefined(); + }); + + it("passes through all thinking levels correctly on reasoning-capable models", () => { + const levels = ["minimal", "low", "medium", "high", "xhigh"] as const; + for (const level of levels) { + const { baseStreamFn, payloads } = createPayloadCapture({ + initialReasoning: { effort: "none" }, + }); + const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, level); + void wrapped(codexModel, { messages: [] }, {}); + expect(payloads[0]?.reasoning).toEqual({ effort: level }); + } + }); +}); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index c8e83e1a1cc..f1b97c954ec 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -1,6 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import type { SimpleStreamOptions } from "@mariozechner/pi-ai"; import { streamSimple } from "@mariozechner/pi-ai"; +import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../../shared/string-coerce.js"; import { @@ -15,6 +16,7 @@ import { import { resolveOpenAITextVerbosity, type OpenAITextVerbosity } from "../openai-text-verbosity.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; import { log } from "./logger.js"; +import { mapThinkingLevelToReasoningEffort } from "./reasoning-effort-utils.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; type OpenAIServiceTier = "auto" | "default" | "flex" | "priority"; @@ -224,6 +226,43 @@ export function createOpenAIStringContentWrapper(baseStreamFn: StreamFn | undefi }; } +export function createOpenAIThinkingLevelWrapper( + baseStreamFn: StreamFn | undefined, + thinkingLevel?: ThinkLevel, +): StreamFn { + const underlying = baseStreamFn ?? streamSimple; + if (!thinkingLevel) { + return underlying; + } + return (model, context, options) => { + if (!shouldApplyOpenAIReasoningCompatibility(model)) { + return underlying(model, context, options); + } + return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => { + const existingReasoning = payloadObj.reasoning; + if (thinkingLevel === "off") { + if (existingReasoning !== undefined) { + delete payloadObj.reasoning; + } + return; + } + + if (existingReasoning === "none") { + payloadObj.reasoning = { effort: mapThinkingLevelToReasoningEffort(thinkingLevel) }; + return; + } + if ( + existingReasoning && + typeof existingReasoning === "object" && + !Array.isArray(existingReasoning) + ) { + (existingReasoning as Record).effort = + mapThinkingLevelToReasoningEffort(thinkingLevel); + } + }); + }; +} + export function createOpenAIFastModeWrapper(baseStreamFn: StreamFn | undefined): StreamFn { const underlying = baseStreamFn ?? streamSimple; return (model, context, options) => { diff --git a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts index cfb8f8d3629..ec5a6697277 100644 --- a/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/proxy-stream-wrappers.ts @@ -7,6 +7,7 @@ import { resolveProviderRequestPolicy } from "../provider-attribution.js"; import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js"; import { applyAnthropicEphemeralCacheControlMarkers } from "./anthropic-cache-control-payload.js"; import { isAnthropicModelRef } from "./anthropic-family-cache-semantics.js"; +import { mapThinkingLevelToReasoningEffort } from "./reasoning-effort-utils.js"; import { streamWithPayloadPatch } from "./stream-payload-utils.js"; const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE"; const KILOCODE_FEATURE_DEFAULT = "openclaw"; @@ -17,21 +18,6 @@ function resolveKilocodeAppHeaders(): Record { return { [KILOCODE_FEATURE_HEADER]: feature }; } -function mapThinkingLevelToOpenRouterReasoningEffort( - thinkingLevel: ThinkLevel, -): "none" | "minimal" | "low" | "medium" | "high" | "xhigh" { - if (thinkingLevel === "off") { - return "none"; - } - if (thinkingLevel === "adaptive") { - return "medium"; - } - if (thinkingLevel === "max") { - return "xhigh"; - } - return thinkingLevel; -} - function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void { if (!payload || typeof payload !== "object") { return; @@ -51,11 +37,11 @@ function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkL ) { const reasoningObj = existingReasoning as Record; if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) { - reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel); + reasoningObj.effort = mapThinkingLevelToReasoningEffort(thinkingLevel); } } else if (!existingReasoning) { payloadObj.reasoning = { - effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel), + effort: mapThinkingLevelToReasoningEffort(thinkingLevel), }; } } diff --git a/src/agents/pi-embedded-runner/reasoning-effort-utils.test.ts b/src/agents/pi-embedded-runner/reasoning-effort-utils.test.ts new file mode 100644 index 00000000000..5cf8c3101e4 --- /dev/null +++ b/src/agents/pi-embedded-runner/reasoning-effort-utils.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { mapThinkingLevelToReasoningEffort } from "./reasoning-effort-utils.js"; + +describe("mapThinkingLevelToReasoningEffort", () => { + it('maps "off" to "none"', () => { + expect(mapThinkingLevelToReasoningEffort("off")).toBe("none"); + }); + + it('maps "adaptive" to "medium"', () => { + expect(mapThinkingLevelToReasoningEffort("adaptive")).toBe("medium"); + }); + + it('maps "max" to "xhigh"', () => { + expect(mapThinkingLevelToReasoningEffort("max")).toBe("xhigh"); + }); + + it.each(["minimal", "low", "medium", "high", "xhigh"] as const)( + "passes through %s unchanged", + (level) => { + expect(mapThinkingLevelToReasoningEffort(level)).toBe(level); + }, + ); +}); diff --git a/src/agents/pi-embedded-runner/reasoning-effort-utils.ts b/src/agents/pi-embedded-runner/reasoning-effort-utils.ts new file mode 100644 index 00000000000..e5f6acd265b --- /dev/null +++ b/src/agents/pi-embedded-runner/reasoning-effort-utils.ts @@ -0,0 +1,16 @@ +import type { ThinkLevel } from "../../auto-reply/thinking.js"; + +export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh"; + +export function mapThinkingLevelToReasoningEffort(thinkingLevel: ThinkLevel): ReasoningEffort { + if (thinkingLevel === "off") { + return "none"; + } + if (thinkingLevel === "adaptive") { + return "medium"; + } + if (thinkingLevel === "max") { + return "xhigh"; + } + return thinkingLevel; +} diff --git a/src/plugin-sdk/provider-stream.ts b/src/plugin-sdk/provider-stream.ts index 6bed976e90a..389d98ce833 100644 --- a/src/plugin-sdk/provider-stream.ts +++ b/src/plugin-sdk/provider-stream.ts @@ -11,7 +11,9 @@ import { createOpenAIReasoningCompatibilityWrapper, createOpenAIResponsesContextManagementWrapper, createOpenAIServiceTierWrapper, + createOpenAIStringContentWrapper, createOpenAITextVerbosityWrapper, + createOpenAIThinkingLevelWrapper, resolveOpenAIFastMode, resolveOpenAIServiceTier, resolveOpenAITextVerbosity, @@ -118,8 +120,11 @@ export function buildProviderStreamFamilyHooks( config: ctx.config, agentDir: ctx.agentDir, }); + nextStreamFn = createOpenAIStringContentWrapper(nextStreamFn); return createOpenAIResponsesContextManagementWrapper( - createOpenAIReasoningCompatibilityWrapper(nextStreamFn), + createOpenAIReasoningCompatibilityWrapper( + createOpenAIThinkingLevelWrapper(nextStreamFn, ctx.thinkingLevel), + ), ctx.extraParams, ); }, diff --git a/test/scripts/check-gateway-watch-regression.test.ts b/test/scripts/check-gateway-watch-regression.test.ts new file mode 100644 index 00000000000..252713d061f --- /dev/null +++ b/test/scripts/check-gateway-watch-regression.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isIgnoredDistRuntimeWatchPath } from "../../scripts/check-gateway-watch-regression.mjs"; + +describe("check-gateway-watch-regression", () => { + it("ignores top-level dist-runtime extension dependency repairs", () => { + expect(isIgnoredDistRuntimeWatchPath("dist-runtime/extensions/node_modules")).toBe(true); + expect( + isIgnoredDistRuntimeWatchPath( + "dist-runtime/extensions/node_modules/playwright-core/index.js", + ), + ).toBe(true); + }); + + it("keeps plugin runtime graph paths counted", () => { + expect(isIgnoredDistRuntimeWatchPath("dist-runtime/extensions/openai/index.js")).toBe(false); + expect( + isIgnoredDistRuntimeWatchPath( + "dist-runtime/extensions/openai/node_modules/openclaw/index.js", + ), + ).toBe(false); + }); +});