mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix: scope openai payload turn guard
This commit is contained in:
@@ -82,6 +82,26 @@ describe("resolveOpenAICompletionsCompatDefaults", () => {
|
||||
expect(defaults.supportsReasoningEffort).toBe(false);
|
||||
expect(defaults.maxTokensField).toBe("max_tokens");
|
||||
});
|
||||
|
||||
it("requires a non-empty user or assistant turn for ModelStudio-compatible providers", () => {
|
||||
expect(
|
||||
resolveOpenAICompletionsCompatDefaults({
|
||||
provider: "qwen",
|
||||
endpointClass: "modelstudio-native",
|
||||
knownProviderFamily: "modelstudio",
|
||||
}).requiresNonEmptyUserOrAssistantMessage,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require a non-empty user or assistant turn for generic local endpoints", () => {
|
||||
expect(
|
||||
resolveOpenAICompletionsCompatDefaults({
|
||||
provider: "vllm",
|
||||
endpointClass: "local",
|
||||
knownProviderFamily: "vllm",
|
||||
}).requiresNonEmptyUserOrAssistantMessage,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectOpenAICompletionsCompat", () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ type OpenAICompletionsCompatDefaults = {
|
||||
visibleReasoningDetailTypes: string[];
|
||||
supportsStrictMode: boolean;
|
||||
requiresReasoningContentOnAssistantMessages: boolean;
|
||||
requiresNonEmptyUserOrAssistantMessage: boolean;
|
||||
};
|
||||
|
||||
type DetectedOpenAICompletionsCompat = {
|
||||
@@ -51,6 +52,10 @@ export function resolveOpenAICompletionsCompatDefaults(
|
||||
knownProviderFamily === "modelstudio" ||
|
||||
endpointClass === "moonshot-native" ||
|
||||
endpointClass === "modelstudio-native";
|
||||
const isModelStudioLike =
|
||||
knownProviderFamily === "modelstudio" ||
|
||||
endpointClass === "modelstudio-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(provider, "dashscope", "modelstudio", "qwen"));
|
||||
const isZai =
|
||||
endpointClass === "zai-native" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "zai"));
|
||||
@@ -112,6 +117,7 @@ export function resolveOpenAICompletionsCompatDefaults(
|
||||
visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [],
|
||||
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
|
||||
requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi,
|
||||
requiresNonEmptyUserOrAssistantMessage: isModelStudioLike,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1028,13 +1028,13 @@ describe("openai transport stream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses OpenAI-compatible chat streams with no user or assistant payload turns", async () => {
|
||||
it("refuses ModelStudio chat streams with no user or assistant payload turns", async () => {
|
||||
const model = {
|
||||
id: "mlx-community/Qwen3-30B-A3B-6bit",
|
||||
name: "Qwen3 MLX",
|
||||
id: "qwen-coder-plus",
|
||||
name: "qwen-coder-plus",
|
||||
api: "openai-completions",
|
||||
provider: "mlx",
|
||||
baseUrl: "http://127.0.0.1:9/v1",
|
||||
provider: "qwen",
|
||||
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -1068,6 +1068,96 @@ describe("openai transport stream", () => {
|
||||
expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request");
|
||||
});
|
||||
|
||||
it("allows generic OpenAI-compatible chat streams without the ModelStudio turn guard", async () => {
|
||||
let capturedRoles: string[] | undefined;
|
||||
const server = createServer((req, res) => {
|
||||
let body = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
const parsed = JSON.parse(body) as { messages?: Array<{ role?: string }> };
|
||||
capturedRoles = parsed.messages?.map((message) => message.role ?? "");
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-system-only",
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: "generic-openai-compatible",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "OK" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
);
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-system-only",
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: "generic-openai-compatible",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})}\n\n`,
|
||||
);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("Missing loopback server address");
|
||||
}
|
||||
const model = {
|
||||
id: "generic-openai-compatible",
|
||||
name: "Generic OpenAI Compatible",
|
||||
api: "openai-completions",
|
||||
provider: "custom-openai-compatible",
|
||||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 4096,
|
||||
maxTokens: 256,
|
||||
} satisfies Model<"openai-completions">;
|
||||
const stream = createOpenAICompletionsTransportStreamFn()(
|
||||
model,
|
||||
{
|
||||
systemPrompt: "runtime-only system prompt",
|
||||
messages: [],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ apiKey: "test-key" } as never,
|
||||
);
|
||||
|
||||
let doneReason: string | undefined;
|
||||
for await (const event of stream as AsyncIterable<{ type: string; reason?: string }>) {
|
||||
if (event.type === "done") {
|
||||
doneReason = event.reason;
|
||||
}
|
||||
}
|
||||
|
||||
expect(capturedRoles).toEqual(["system"]);
|
||||
expect(doneReason).toBe("stop");
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("parses JSON chat completions returned to streaming requests", async () => {
|
||||
let capturedStreamFlag: unknown;
|
||||
const server = createServer((req, res) => {
|
||||
|
||||
@@ -2419,7 +2419,10 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
|
||||
enforceCodeModeResponsesToolSurface(params);
|
||||
assertCodeModeResponsesToolSurface(params);
|
||||
}
|
||||
assertOpenAICompletionsPayloadHasConversationTurn(params, model);
|
||||
const compat = getCompat(model as OpenAIModeModel);
|
||||
if (compat.requiresNonEmptyUserOrAssistantMessage) {
|
||||
assertOpenAICompletionsPayloadHasConversationTurn(params, model);
|
||||
}
|
||||
const responseStream = (await client.chat.completions.create(
|
||||
params as never,
|
||||
buildOpenAISdkRequestOptions(model, options?.signal),
|
||||
@@ -2869,6 +2872,7 @@ function detectCompat(model: OpenAIModeModel) {
|
||||
supportsStrictMode: compatDefaults.supportsStrictMode,
|
||||
requiresReasoningContentOnAssistantMessages:
|
||||
compatDefaults.requiresReasoningContentOnAssistantMessages,
|
||||
requiresNonEmptyUserOrAssistantMessage: compatDefaults.requiresNonEmptyUserOrAssistantMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2891,6 +2895,7 @@ function getCompat(model: OpenAIModeModel): {
|
||||
strictMessageKeys: boolean;
|
||||
visibleReasoningDetailTypes: string[];
|
||||
requiresReasoningContentOnAssistantMessages: boolean;
|
||||
requiresNonEmptyUserOrAssistantMessage: boolean;
|
||||
} {
|
||||
const detected = detectCompat(model);
|
||||
const compat = model.compat ?? {};
|
||||
@@ -2924,6 +2929,7 @@ function getCompat(model: OpenAIModeModel): {
|
||||
compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes,
|
||||
requiresReasoningContentOnAssistantMessages:
|
||||
detected.requiresReasoningContentOnAssistantMessages,
|
||||
requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user