mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 20:01:42 +00:00
* fix(agents): preserve reasoning_content replay across DeepSeek tier suffixes OpenCode Zen exposes DeepSeek V4 as `deepseek-v4-flash-free`, which keeps the upstream DeepSeek thinking-mode contract that requires `reasoning_content` to be passed back on follow-up requests. The existing replay allowlist only matched the bare ids (`deepseek-v4-flash`, `kimi-k2-thinking`, ...), so the tier-suffixed id missed every candidate and the sanitizer stripped `reasoning_content` from the assistant turn. DeepSeek then rejected the second API call with HTTP 400 and the session deadlocked. Strip the well-known tier suffixes (`-free`, `-paid`, `-trial`) when generating allowlist candidates so the base model id matches and the reasoning replay survives. Existing matching for prefixed / colon-suffixed routes is unchanged. Fixes #87575 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(agents): avoid spread-rebuild when iterating allowlist candidates oxlint flagged the [...candidates] spread as an unnecessary array copy. Use an explicit baseCount loop bound instead so we still iterate the original entries while pushing tier-stripped variants onto the same array. Co-authored-by: Cursor <cursoragent@cursor.com> * test(opencode): add live DeepSeek replay probe * test(opencode): avoid forced tool choice in live replay --------- Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
132 lines
3.7 KiB
TypeScript
132 lines
3.7 KiB
TypeScript
import {
|
|
completeSimple,
|
|
type AssistantMessage,
|
|
type Model,
|
|
type Tool,
|
|
} from "openclaw/plugin-sdk/llm";
|
|
import { extractNonEmptyAssistantText, isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
|
import { Type } from "typebox";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const OPENCODE_API_KEY =
|
|
process.env.OPENCODE_API_KEY?.trim() || process.env.OPENCODE_ZEN_API_KEY?.trim() || "";
|
|
const LIVE_MODEL_ID =
|
|
process.env.OPENCLAW_LIVE_OPENCODE_DEEPSEEK_MODEL?.trim() || "deepseek-v4-flash-free";
|
|
const LIVE = isLiveTestEnabled(["OPENCODE_LIVE_TEST"]) && OPENCODE_API_KEY.length > 0;
|
|
const describeLive = LIVE ? describe : describe.skip;
|
|
|
|
function resolveOpencodeDeepSeekLiveModel(): Model<"openai-completions"> {
|
|
return {
|
|
id: LIVE_MODEL_ID,
|
|
name: LIVE_MODEL_ID,
|
|
api: "openai-completions",
|
|
provider: "opencode",
|
|
baseUrl: "https://opencode.ai/zen/v1",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 65_536,
|
|
maxTokens: 8192,
|
|
};
|
|
}
|
|
|
|
function liveEchoTool(): Tool {
|
|
return {
|
|
name: "live_echo",
|
|
description: "Return the supplied value.",
|
|
parameters: Type.Object(
|
|
{
|
|
value: Type.String(),
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
};
|
|
}
|
|
|
|
function requireToolCall(message: AssistantMessage) {
|
|
const toolCall = message.content.find((block) => block.type === "toolCall");
|
|
if (toolCall?.type !== "toolCall") {
|
|
throw new Error(`OpenCode DeepSeek live model did not call a tool: ${message.stopReason}`);
|
|
}
|
|
return toolCall;
|
|
}
|
|
|
|
function hasReasoningContentReplay(message: AssistantMessage): boolean {
|
|
return message.content.some(
|
|
(block) => block.type === "thinking" && block.thinkingSignature === "reasoning_content",
|
|
);
|
|
}
|
|
|
|
describeLive("opencode plugin live", () => {
|
|
it("accepts DeepSeek V4 tier-suffixed thinking replay after a tool call", async () => {
|
|
const model = resolveOpencodeDeepSeekLiveModel();
|
|
const tool = liveEchoTool();
|
|
const firstOptions = {
|
|
apiKey: OPENCODE_API_KEY,
|
|
reasoning: "low",
|
|
maxTokens: 128,
|
|
} as const;
|
|
|
|
const first = await completeSimple(
|
|
model,
|
|
{
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "You must call the live_echo tool with value ok. Do not answer directly.",
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
tools: [tool],
|
|
},
|
|
firstOptions,
|
|
);
|
|
|
|
if (first.stopReason === "error") {
|
|
throw new Error(first.errorMessage || "OpenCode DeepSeek first turn returned an error");
|
|
}
|
|
|
|
const toolCall = requireToolCall(first);
|
|
expect(hasReasoningContentReplay(first)).toBe(true);
|
|
|
|
const second = await completeSimple(
|
|
model,
|
|
{
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: "You must call the live_echo tool with value ok. Do not answer directly.",
|
|
timestamp: Date.now() - 3,
|
|
},
|
|
first,
|
|
{
|
|
role: "toolResult",
|
|
toolCallId: toolCall.id,
|
|
toolName: toolCall.name,
|
|
content: [{ type: "text", text: "ok" }],
|
|
isError: false,
|
|
timestamp: Date.now() - 1,
|
|
},
|
|
{
|
|
role: "user",
|
|
content: "Reply with exactly: ok",
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
tools: [tool],
|
|
},
|
|
{
|
|
apiKey: OPENCODE_API_KEY,
|
|
reasoning: "low",
|
|
maxTokens: 64,
|
|
},
|
|
);
|
|
|
|
if (second.stopReason === "error") {
|
|
throw new Error(second.errorMessage || "OpenCode DeepSeek replay returned an error");
|
|
}
|
|
|
|
expect(extractNonEmptyAssistantText(second.content)).toMatch(/^ok[.!]?$/i);
|
|
}, 120_000);
|
|
});
|