fix: scope openai payload turn guard

This commit is contained in:
Onur Solmaz
2026-05-25 19:49:04 +08:00
committed by clawsweeper
parent 26813952b6
commit 4ebaf4ff0a
4 changed files with 128 additions and 6 deletions
@@ -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", () => {
+6
View File
@@ -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,
};
}
+95 -5
View File
@@ -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) => {
+7 -1
View File
@@ -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,
};
}