mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
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:
committed by
GitHub
parent
5ca46a6554
commit
57af2bbff0
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,7 +48,7 @@ type SupportedOpenAICompatFields = Pick<
|
||||
|
||||
type SupportedOpenAIResponsesCompatFields = Pick<
|
||||
OpenAIResponsesCompat,
|
||||
"sendSessionIdHeader" | "supportsLongCacheRetention"
|
||||
"sendSessionIdHeader" | "supportsLongCacheRetention" | "supportsTemperature"
|
||||
>;
|
||||
|
||||
type SupportedAnthropicMessagesCompatFields = Pick<
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user