fix: strip opencode image reasoning none

This commit is contained in:
Peter Steinberger
2026-04-25 04:35:00 +01:00
parent 96515891a2
commit 6f72b74cec
8 changed files with 203 additions and 5 deletions

View File

@@ -87,6 +87,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenAI-compatible: forward `prompt_cache_key` on Completions requests only for providers that opt in with `compat.supportsPromptCacheKey`, keeping default proxy payloads unchanged. Fixes #69272.
- Providers/OpenAI-compatible: skip null or non-object streaming chunks from custom providers instead of failing the turn after partial output. Fixes #51112.
- Providers/OpenAI-compatible: treat singular MLX-style `finish_reason: "tool_call"` as tool use instead of a provider error. Fixes #61499.
- Plugins/OpenCode: strip unsupported disabled Responses reasoning payloads for OpenCode image understanding. Fixes #70252.
- Providers/ElevenLabs: omit the MP3-only `Accept` header for PCM telephony synthesis, so Voice Call requests for `pcm_22050` no longer receive MP3 audio. Fixes #67340. Thanks @marcchabot.
- Providers/MiniMax TTS: mark MP3 output voice-compatible for Telegram voice-note delivery. Fixes #63540.
- Providers/Microsoft TTS: keep allowlisted bundled speech providers discoverable even when another speech plugin has already registered, so Edge/Microsoft TTS is available alongside OpenAI. Fixes #62117 and #66850.

View File

@@ -1,8 +1,29 @@
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import { registerProviderPlugin } from "../../test/helpers/plugins/provider-registration.js";
import { expectPassthroughReplayPolicy } from "../../test/helpers/provider-replay-policy.ts";
import plugin from "./index.js";
describe("opencode provider plugin", () => {
it("registers image media understanding through the OpenCode plugin", async () => {
const { mediaProviders } = await registerProviderPlugin({
plugin,
id: "opencode",
name: "OpenCode Zen Provider",
});
expect(mediaProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "opencode",
capabilities: ["image"],
defaultModels: { image: "gpt-5-nano" },
describeImage: expect.any(Function),
describeImages: expect.any(Function),
}),
]),
);
});
it("owns passthrough-gemini replay policy for Gemini-backed models", async () => {
await expectPassthroughReplayPolicy({
plugin,

View File

@@ -6,6 +6,7 @@ import {
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
const PROVIDER_ID = "opencode";
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
@@ -49,5 +50,6 @@ export default definePluginEntry({
...PASSTHROUGH_GEMINI_REPLAY_HOOKS,
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
});
api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider);
},
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
opencodeMediaUnderstandingProvider,
stripOpencodeDisabledResponsesReasoningPayload,
} from "./media-understanding-provider.js";
describe("opencode media understanding provider", () => {
it("strips disabled Responses reasoning payloads", () => {
const payload = {
reasoning: { effort: "none" },
include: ["reasoning.encrypted_content"],
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
expect(payload).toEqual({
include: ["reasoning.encrypted_content"],
store: false,
});
});
it("keeps supported Responses reasoning payloads", () => {
const payload = {
reasoning: { effort: "low" },
store: false,
};
stripOpencodeDisabledResponsesReasoningPayload(payload);
expect(payload).toEqual({
reasoning: { effort: "low" },
store: false,
});
});
it("declares OpenCode image understanding support", () => {
expect(opencodeMediaUnderstandingProvider).toEqual(
expect.objectContaining({
id: "opencode",
capabilities: ["image"],
defaultModels: { image: "gpt-5-nano" },
describeImage: expect.any(Function),
describeImages: expect.any(Function),
}),
);
});
});

View File

@@ -0,0 +1,42 @@
import type { ProviderStreamOptions } from "@mariozechner/pi-ai";
import {
describeImageWithModelPayloadTransform,
describeImagesWithModelPayloadTransform,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function stripOpencodeDisabledResponsesReasoningPayload(payload: unknown): void {
if (!isRecord(payload)) {
return;
}
const reasoning = payload.reasoning;
if (reasoning === "none") {
delete payload.reasoning;
return;
}
if (!isRecord(reasoning) || reasoning.effort !== "none") {
return;
}
delete payload.reasoning;
}
const stripDisabledResponsesReasoning: ProviderStreamOptions["onPayload"] = (payload) => {
stripOpencodeDisabledResponsesReasoningPayload(payload);
return undefined;
};
export const opencodeMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "opencode",
capabilities: ["image"],
defaultModels: {
image: "gpt-5-nano",
},
describeImage: (request) =>
describeImageWithModelPayloadTransform(request, stripDisabledResponsesReasoning),
describeImages: (request) =>
describeImagesWithModelPayloadTransform(request, stripDisabledResponsesReasoning),
};

View File

@@ -7,3 +7,9 @@ export const describeImageWithModel = bindImageRuntime((runtime) => runtime.desc
export const describeImagesWithModel = bindImageRuntime(
(runtime) => runtime.describeImagesWithModel,
);
export const describeImageWithModelPayloadTransform = bindImageRuntime(
(runtime) => runtime.describeImageWithModelPayloadTransform,
);
export const describeImagesWithModelPayloadTransform = bindImageRuntime(
(runtime) => runtime.describeImagesWithModelPayloadTransform,
);

View File

@@ -95,6 +95,38 @@ function isImageModelNoTextError(err: unknown): boolean {
return err instanceof Error && /^Image model returned no text\b/.test(err.message);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(value) && typeof (value as { then?: unknown }).then === "function";
}
function composeImageDescriptionPayloadHandlers(
first: ProviderStreamOptions["onPayload"] | undefined,
second: ProviderStreamOptions["onPayload"] | undefined,
): ProviderStreamOptions["onPayload"] | undefined {
if (!first) {
return second;
}
if (!second) {
return first;
}
return (payload, payloadModel) => {
const runSecond = (firstResult: unknown) => {
const nextPayload = firstResult === undefined ? payload : firstResult;
const secondResult = second(nextPayload, payloadModel);
const coerceResult = (resolvedSecond: unknown) =>
resolvedSecond === undefined ? firstResult : resolvedSecond;
return isPromiseLike(secondResult)
? Promise.resolve(secondResult).then(coerceResult)
: coerceResult(secondResult);
};
const firstResult = first(payload, payloadModel);
if (isPromiseLike(firstResult)) {
return Promise.resolve(firstResult).then(runSecond);
}
return runSecond(firstResult);
};
}
async function resolveImageRuntime(params: {
cfg: ImageDescriptionRequest["cfg"];
agentDir: string;
@@ -231,8 +263,9 @@ async function resolveMinimaxVlmFallbackRuntime(params: {
};
}
export async function describeImagesWithModel(
async function describeImagesWithModelInternal(
params: ImagesDescriptionRequest,
options: { onPayload?: ProviderStreamOptions["onPayload"] } = {},
): Promise<ImagesDescriptionResult> {
const prompt = params.prompt ?? "Describe the image.";
let apiKey: string;
@@ -284,13 +317,15 @@ export async function describeImagesWithModel(
: undefined;
const maxTokens = resolveImageToolMaxTokens(model.maxTokens, params.maxTokens ?? 512);
const completeImage = async (onPayload?: ProviderStreamOptions["onPayload"]) =>
await complete(model, context, {
const completeImage = async (onPayload?: ProviderStreamOptions["onPayload"]) => {
const payloadHandler = composeImageDescriptionPayloadHandlers(onPayload, options.onPayload);
return await complete(model, context, {
apiKey,
maxTokens,
signal: controller.signal,
...(onPayload ? { onPayload } : {}),
...(payloadHandler ? { onPayload: payloadHandler } : {}),
});
};
try {
const message = await completeImage();
@@ -319,6 +354,19 @@ export async function describeImagesWithModel(
}
}
export async function describeImagesWithModel(
params: ImagesDescriptionRequest,
): Promise<ImagesDescriptionResult> {
return await describeImagesWithModelInternal(params);
}
export async function describeImagesWithModelPayloadTransform(
params: ImagesDescriptionRequest,
onPayload: ProviderStreamOptions["onPayload"],
): Promise<ImagesDescriptionResult> {
return await describeImagesWithModelInternal(params, { onPayload });
}
export async function describeImageWithModel(
params: ImageDescriptionRequest,
): Promise<ImageDescriptionResult> {
@@ -342,3 +390,31 @@ export async function describeImageWithModel(
cfg: params.cfg,
});
}
export async function describeImageWithModelPayloadTransform(
params: ImageDescriptionRequest,
onPayload: ProviderStreamOptions["onPayload"],
): Promise<ImageDescriptionResult> {
return await describeImagesWithModelPayloadTransform(
{
images: [
{
buffer: params.buffer,
fileName: params.fileName,
mime: params.mime,
},
],
model: params.model,
provider: params.provider,
prompt: params.prompt,
maxTokens: params.maxTokens,
timeoutMs: params.timeoutMs,
profile: params.profile,
preferredProfile: params.preferredProfile,
authStore: params.authStore,
agentDir: params.agentDir,
cfg: params.cfg,
},
onPayload,
);
}

View File

@@ -15,7 +15,9 @@ export type {
export {
describeImageWithModel,
describeImageWithModelPayloadTransform,
describeImagesWithModel,
describeImagesWithModelPayloadTransform,
} from "../media-understanding/image-runtime.js";
export {
buildOpenAiCompatibleVideoRequestBody,