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
This commit is contained in:
Peter Steinberger
2026-07-11 09:32:16 -07:00
committed by GitHub
parent 5ca46a6554
commit 57af2bbff0
18 changed files with 172 additions and 11 deletions
+2
View File
@@ -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 {
+1
View File
@@ -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 {
@@ -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" };
@@ -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();
@@ -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<OpenAIResponsesCompat> {
type ResolvedOpenAIResponsesCompat = Required<
Pick<OpenAIResponsesCompat, "sendSessionIdHeader" | "supportsLongCacheRetention">
>;
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<OpenAIResponsesCo
}
function getPromptCacheRetention(
compat: Required<OpenAIResponsesCompat>,
compat: ResolvedOpenAIResponsesCompat,
cacheRetention: CacheRetention,
): "24h" | undefined {
return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined;
+2
View File
@@ -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. */
@@ -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({
@@ -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"],
+11 -1
View File
@@ -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
@@ -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.
+1
View File
@@ -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()),
});
@@ -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<typeof import("../../../plugins/doctor-contract-registry.js")>()),
// 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,
+1 -1
View File
@@ -48,7 +48,7 @@ type SupportedOpenAICompatFields = Pick<
type SupportedOpenAIResponsesCompatFields = Pick<
OpenAIResponsesCompat,
"sendSessionIdHeader" | "supportsLongCacheRetention"
"sendSessionIdHeader" | "supportsLongCacheRetention" | "supportsTemperature"
>;
type SupportedAnthropicMessagesCompatFields = Pick<
+1
View File
@@ -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(),
+20
View File
@@ -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);
});
});
@@ -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(
@@ -11,19 +11,36 @@ const model = {
compat: { requiresOpenAiAnthropicToolPayload: true },
} as unknown as Model<"anthropic-messages">;
function runWrapper(payload: Record<string, unknown>) {
function runWrapper(payload: Record<string, unknown>, nextModel = model) {
const payloads: Array<Record<string, unknown>> = [];
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: [
@@ -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);
},