From 57af2bbff0994355288a94fde3ad6d3c8ffd37f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 09:32:16 -0700 Subject: [PATCH] fix: clear remaining release validation blockers (#104555) * test: align provider tool call lifecycles * test: isolate plugin install repair migrations * fix: accept catalog temperature compatibility * fix: disable GPT-5.6 tool reasoning on completions * style: avoid shadowed stream model --- extensions/lmstudio/src/stream.test.ts | 2 ++ extensions/xai/stream.test.ts | 1 + .../providers/openai-reasoning-effort.test.ts | 7 ++++ .../src/providers/openai-reasoning-effort.ts | 7 ++++ packages/ai/src/providers/openai-responses.ts | 8 +++-- packages/llm-core/src/types.ts | 2 ++ .../openai-tool-projection.live.test.ts | 4 +-- src/agents/openai-transport-stream.test.ts | 34 ++++++++++++++++++ src/agents/openai-transport-stream.ts | 12 ++++++- src/agents/sessions/model-registry.test.ts | 35 +++++++++++++++++++ src/agents/sessions/model-registry.ts | 1 + .../missing-configured-plugin-install.test.ts | 10 ++++++ src/config/types.models.ts | 2 +- src/config/zod-schema.core.ts | 1 + src/config/zod-schema.models.test.ts | 20 +++++++++++ ...ic-family-tool-payload-compat.live.test.ts | 2 ++ ...thropic-family-tool-payload-compat.test.ts | 25 ++++++++++--- .../anthropic-family-tool-payload-compat.ts | 10 +++++- 18 files changed, 172 insertions(+), 11 deletions(-) diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index b3d2c01a1b3e..59da8b9417e6 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -677,6 +677,7 @@ describe("lmstudio stream wrapper", () => { "start", "toolcall_start", "toolcall_delta", + "toolcall_end", "done", ]); const done = events.find((event) => event.type === "done") as { @@ -723,6 +724,7 @@ describe("lmstudio stream wrapper", () => { "start", "toolcall_start", "toolcall_delta", + "toolcall_end", "done", ]); const done = events.find((event) => event.type === "done") as { diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts index 05df72b92c5c..a66d3416c018 100644 --- a/extensions/xai/stream.test.ts +++ b/extensions/xai/stream.test.ts @@ -284,6 +284,7 @@ describe("xai stream wrappers", () => { "start", "toolcall_start", "toolcall_delta", + "toolcall_end", "done", ]); const done = events.find((event) => event.type === "done") as { diff --git a/packages/ai/src/providers/openai-reasoning-effort.test.ts b/packages/ai/src/providers/openai-reasoning-effort.test.ts index d6fdcc435438..369c2d3342a2 100644 --- a/packages/ai/src/providers/openai-reasoning-effort.test.ts +++ b/packages/ai/src/providers/openai-reasoning-effort.test.ts @@ -1,6 +1,7 @@ // Verifies model-specific OpenAI reasoning-effort normalization and disablement. import { describe, expect, it } from "vitest"; import { + isOpenAIGpt56Model, resolveOpenAIReasoningEffortForModel, resolveOpenAISupportedReasoningEfforts, supportsOpenAIReasoningEffort, @@ -8,6 +9,12 @@ import { } from "./openai-reasoning-effort.js"; describe("OpenAI reasoning effort support", () => { + it("recognizes GPT-5.6 model ids and deployment names", () => { + expect(isOpenAIGpt56Model({ id: "gpt-5.6-luna" })).toBe(true); + expect(isOpenAIGpt56Model({ id: "prod-luna", name: "GPT-5.6 (Azure)" })).toBe(true); + expect(isOpenAIGpt56Model({ id: "gpt-5.5" })).toBe(false); + }); + it("preserves disabled and max effort for the GPT-5.6 series", () => { const sol = { provider: "openai", id: "gpt-5.6-sol" }; const terra = { provider: "openai", id: "gpt-5.6-terra" }; diff --git a/packages/ai/src/providers/openai-reasoning-effort.ts b/packages/ai/src/providers/openai-reasoning-effort.ts index 60293d454681..18f0c77a9a36 100644 --- a/packages/ai/src/providers/openai-reasoning-effort.ts +++ b/packages/ai/src/providers/openai-reasoning-effort.ts @@ -67,6 +67,13 @@ export function isOpenAIGpt55Model(model: OpenAIReasoningModel): boolean { return /^gpt-5\.5(?:-|$)/u.test(id) || /^gpt-5\.5(?:\s|\(|-|$)/u.test(name); } +/** Return whether a model is the GPT-5.6 family. */ +export function isOpenAIGpt56Model(model: OpenAIReasoningModel): boolean { + const id = normalizeModelId(typeof model.id === "string" ? model.id : undefined); + const name = normalizeModelId(typeof model.name === "string" ? model.name : undefined); + return /^gpt-5\.6(?:-|$)/u.test(id) || /^gpt-5\.6(?:\s|\(|-|$)/u.test(name); +} + /** Normalize user-facing reasoning effort names to API effort names. */ export function normalizeOpenAIReasoningEffort(effort: string): string { const trimmed = effort.trim(); diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 5156cb00e462..c6c641bf8526 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -30,7 +30,11 @@ import { buildBaseOptions } from "./simple-options.js"; const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]); -function getCompat(model: Model<"openai-responses">): Required { +type ResolvedOpenAIResponsesCompat = Required< + Pick +>; + +function getCompat(model: Model<"openai-responses">): ResolvedOpenAIResponsesCompat { return { sendSessionIdHeader: model.compat?.sendSessionIdHeader ?? true, supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true, @@ -38,7 +42,7 @@ function getCompat(model: Model<"openai-responses">): Required, + compat: ResolvedOpenAIResponsesCompat, cacheRetention: CacheRetention, ): "24h" | undefined { return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined; diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts index 83e6b4c0652a..0d421770add8 100644 --- a/packages/llm-core/src/types.ts +++ b/packages/llm-core/src/types.ts @@ -475,6 +475,8 @@ export interface OpenAICompletionsCompat { /** Compatibility settings for OpenAI Responses APIs. */ export interface OpenAIResponsesCompat { + /** Whether the model accepts the `temperature` parameter. Default: true. */ + supportsTemperature?: boolean; /** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */ sendSessionIdHeader?: boolean; /** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */ diff --git a/src/agents/openai-tool-projection.live.test.ts b/src/agents/openai-tool-projection.live.test.ts index 89e5ac70da0e..8696f747ca34 100644 --- a/src/agents/openai-tool-projection.live.test.ts +++ b/src/agents/openai-tool-projection.live.test.ts @@ -88,7 +88,7 @@ describeLive("OpenAI tool projection live", () => { }); }, 45_000); - it("calls a GPT-5.5 Chat Completions function without incompatible reasoning effort", async () => { + it("calls a GPT-5.6 Chat Completions function with reasoning disabled", async () => { const model = { id: modelId, name: modelId, @@ -115,7 +115,7 @@ describeLive("OpenAI tool projection live", () => { }, }, }); - expect(params).not.toHaveProperty("reasoning_effort"); + expect(params.reasoning_effort).toBe("none"); const { stream_options: _streamOptions, ...nonStreamingParams } = params; const response = await client.chat.completions.create({ diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 589001d39dee..302a550fd196 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -8245,6 +8245,40 @@ describe("openai transport stream", () => { }, ); + it("disables reasoning for OpenAI gpt-5.6 Chat Completions tool payloads", () => { + const params = buildOpenAICompletionsParams( + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + 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: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + } as never, + { + reasoning: "low", + } as never, + ) as { reasoning_effort?: unknown; tools?: unknown }; + + expect(params.tools).toHaveLength(1); + expect(params.reasoning_effort).toBe("none"); + }); + it.each([ ["Azure OpenAI", "https://example.openai.azure.com/openai/v1"], ["Foundry", "https://example.services.ai.azure.com/openai/v1"], diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 0e088781c4d5..3ac3b81b3172 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -12,6 +12,7 @@ import { isOpenAICompatibleAzureResponsesBaseUrl, isOpenAIGpt54MiniModel, isOpenAIGpt55Model, + isOpenAIGpt56Model, isResponsesTextContentPartType, isResponsesTextDeltaEventType, mapOpenAIStopReason, @@ -4828,6 +4829,11 @@ export function buildOpenAICompletionsParams( params.tools.length > 0 && (isOpenAIGpt54MiniModel(model) || (isOpenAIGpt55Model(model) && isKnownOpenAICompletionsEndpoint(model))); + const disableChatCompletionsToolReasoning = + Array.isArray(params.tools) && + params.tools.length > 0 && + isOpenAIGpt56Model(model) && + isKnownOpenAICompletionsEndpoint(model); const handledQwenThinkingFormat = applyQwenOpenAICompletionsThinkingParams({ compatThinkingFormat: compat.thinkingFormat, modelReasoning: model.reasoning, @@ -4840,7 +4846,11 @@ export function buildOpenAICompletionsParams( payload: params, requestedEffort: completionsReasoningEffort, }); - if ( + if (disableChatCompletionsToolReasoning) { + // GPT-5.6 Chat Completions defaults reasoning on, but rejects function + // tools unless reasoning is explicitly disabled. + params.reasoning_effort = "none"; + } else if ( compat.thinkingFormat === "openrouter" && model.reasoning && resolvedCompletionsReasoningEffort diff --git a/src/agents/sessions/model-registry.test.ts b/src/agents/sessions/model-registry.test.ts index e5d87ab483b0..7e2e86220433 100644 --- a/src/agents/sessions/model-registry.test.ts +++ b/src/agents/sessions/model-registry.test.ts @@ -174,6 +174,41 @@ describe("ModelRegistry models.json auth", () => { expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1"); }); + it("preserves response-model temperature compatibility from generated catalogs", () => { + const modelsPath = writeModelsJsonWithPluginCatalog({ + root: { providers: {} }, + pluginRelativePath: join("plugins", "openai", PLUGIN_MODEL_CATALOG_FILE), + pluginCatalog: { + generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + apiKey: "test-token-placeholder", + models: [ + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + compat: { supportsTemperature: false }, + }, + ], + }, + }, + }, + }); + + const registry = ModelRegistry.create( + AuthStorage.inMemory({ openai: { type: "api_key", key: "test-token-placeholder" } }), + modelsPath, + { pluginMetadataSnapshot: pluginOwnerSnapshot("openai", "openai") }, + ); + + expect(registry.getError()).toBeUndefined(); + expect(registry.find("openai", "gpt-5.6-luna")?.compat).toMatchObject({ + supportsTemperature: false, + }); + }); + it("loads richer generated catalog metadata without widening runtime inputs", () => { // Generated catalogs can report video/audio support. Keep those rows while // projecting their metadata to the runtime execution contract. diff --git a/src/agents/sessions/model-registry.ts b/src/agents/sessions/model-registry.ts index f44a64381c11..b422a37a05c9 100644 --- a/src/agents/sessions/model-registry.ts +++ b/src/agents/sessions/model-registry.ts @@ -130,6 +130,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ }); const OpenAIResponsesCompatSchema = Type.Object({ + supportsTemperature: Type.Optional(Type.Boolean()), sendSessionIdHeader: Type.Optional(Type.Boolean()), supportsLongCacheRetention: Type.Optional(Type.Boolean()), }); diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 1ff1e8cd8093..2f1213fa751f 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -173,6 +173,16 @@ vi.mock("../../../plugins/manifest-contract-eligibility.js", async (importOrigin loadManifestMetadataSnapshot: mocks.loadPluginMetadataSnapshot, })); +vi.mock("../../../plugins/doctor-contract-registry.js", async (importOriginal) => ({ + ...(await importOriginal()), + // Plugin-owned compatibility is outside this install-repair suite. Avoid scanning + // the real plugin registry when the legacy-config fixture reaches that follow-up pass. + applyPluginDoctorCompatibilityMigrations: (cfg: OpenClawConfig) => ({ + config: cfg, + changes: [], + }), +})); + vi.mock("../../../plugins/official-external-plugin-catalog.js", () => ({ getOfficialExternalPluginCatalogManifest: mocks.getOfficialExternalPluginCatalogManifest, listOfficialExternalChannelEnvVars: mocks.listOfficialExternalChannelEnvVars, diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 234c1337f093..4912eb18cb08 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -48,7 +48,7 @@ type SupportedOpenAICompatFields = Pick< type SupportedOpenAIResponsesCompatFields = Pick< OpenAIResponsesCompat, - "sendSessionIdHeader" | "supportsLongCacheRetention" + "sendSessionIdHeader" | "supportsLongCacheRetention" | "supportsTemperature" >; type SupportedAnthropicMessagesCompatFields = Pick< diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index f7180894c38d..c81bbf1a2c90 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -224,6 +224,7 @@ const ModelCompatSchema = z supportsPromptCacheKey: z.boolean().optional(), supportsDeveloperRole: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), + supportsTemperature: z.boolean().optional(), supportsUsageInStreaming: z.boolean().optional(), supportsTools: z.boolean().optional(), supportsStrictMode: z.boolean().optional(), diff --git a/src/config/zod-schema.models.test.ts b/src/config/zod-schema.models.test.ts index 0a334a7a15a9..6d2b257e3970 100644 --- a/src/config/zod-schema.models.test.ts +++ b/src/config/zod-schema.models.test.ts @@ -101,4 +101,24 @@ describe("ModelsConfigSchema", () => { expect(result.success).toBe(true); }); + + it("accepts catalog-declared temperature compatibility", () => { + const result = ModelsConfigSchema.safeParse({ + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [ + { + id: "gpt-5.6-luna", + name: "GPT-5.6 Luna", + compat: { supportsTemperature: false }, + }, + ], + }, + }, + }); + + expect(result.success).toBe(true); + }); }); diff --git a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts index 554bede78ea2..57e2ec50e865 100644 --- a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.live.test.ts @@ -49,6 +49,7 @@ describeLive("OpenAI-compatible Anthropic tool payload wrapper live", () => { }, ], tool_choice: { type: "custom", custom: { name: "live_probe" } }, + reasoning_effort: "low", max_completion_tokens: 128, }; options?.onPayload?.(payload, model); @@ -67,6 +68,7 @@ describeLive("OpenAI-compatible Anthropic tool payload wrapper live", () => { if (!projectedPayload) { throw new Error("wrapper did not produce a payload"); } + expect(projectedPayload.reasoning_effort).toBe("none"); const client = new OpenAI({ apiKey: OPENAI_KEY }); const response = await client.chat.completions.create( diff --git a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.test.ts b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.test.ts index 2b66091e70f6..75382dd96813 100644 --- a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.test.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.test.ts @@ -11,19 +11,36 @@ const model = { compat: { requiresOpenAiAnthropicToolPayload: true }, } as unknown as Model<"anthropic-messages">; -function runWrapper(payload: Record) { +function runWrapper(payload: Record, nextModel = model) { const payloads: Array> = []; - const baseStreamFn: StreamFn = (nextModel, context, options) => { - options?.onPayload?.(payload, nextModel); + const baseStreamFn: StreamFn = (streamModel, context, options) => { + options?.onPayload?.(payload, streamModel); payloads.push(structuredClone(payload)); return createAssistantMessageEventStream(); }; const wrapped = createOpenAIAnthropicToolPayloadCompatibilityWrapper(baseStreamFn); - void wrapped(model, { messages: [] }, {}); + void wrapped(nextModel, { messages: [] }, {}); return payloads[0]; } describe("createOpenAIAnthropicToolPayloadCompatibilityWrapper", () => { + it("disables GPT-5.6 reasoning when projecting function tools", () => { + const payload = runWrapper( + { + reasoning_effort: "low", + tools: [ + { + name: "lookup", + parameters: { type: "object", properties: {} }, + }, + ], + }, + { ...model, id: "gpt-5.6-luna" }, + ); + + expect(payload?.reasoning_effort).toBe("none"); + }); + it("skips unreadable schemas while preserving a healthy pinned tool", () => { const payload = runWrapper({ tools: [ diff --git a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts index 67c7e375bc3c..69a1554294ef 100644 --- a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts @@ -1,4 +1,4 @@ -import { projectRuntimeToolInputSchema } from "@openclaw/ai/internal/openai"; +import { isOpenAIGpt56Model, projectRuntimeToolInputSchema } from "@openclaw/ai/internal/openai"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; // Anthropic-family tool payload compatibility wraps provider tool payload shapes. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -527,6 +527,14 @@ export function createAnthropicToolPayloadCompatibilityWrapper( payloadObj.tool_choice = toolChoice; } } + if ( + isOpenAIGpt56Model(model) && + toolProjection?.tools.some((tool) => tool.type === "function") + ) { + // GPT-5.6 Chat Completions rejects function tools while reasoning + // is enabled and defaults reasoning on when the field is omitted. + payloadObj.reasoning_effort = "none"; + } } return originalOnPayload?.(payload, model); },