Skip to content

Commit a13f5b1

Browse files
committed
fix: scope openai payload turn guard
1 parent 2f44db7 commit a13f5b1

4 files changed

Lines changed: 128 additions & 6 deletions

File tree

src/agents/openai-completions-compat.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ describe("resolveOpenAICompletionsCompatDefaults", () => {
8282
expect(defaults.supportsReasoningEffort).toBe(false);
8383
expect(defaults.maxTokensField).toBe("max_tokens");
8484
});
85+
86+
it("requires a non-empty user or assistant turn for ModelStudio-compatible providers", () => {
87+
expect(
88+
resolveOpenAICompletionsCompatDefaults({
89+
provider: "qwen",
90+
endpointClass: "modelstudio-native",
91+
knownProviderFamily: "modelstudio",
92+
}).requiresNonEmptyUserOrAssistantMessage,
93+
).toBe(true);
94+
});
95+
96+
it("does not require a non-empty user or assistant turn for generic local endpoints", () => {
97+
expect(
98+
resolveOpenAICompletionsCompatDefaults({
99+
provider: "vllm",
100+
endpointClass: "local",
101+
knownProviderFamily: "vllm",
102+
}).requiresNonEmptyUserOrAssistantMessage,
103+
).toBe(false);
104+
});
85105
});
86106

87107
describe("detectOpenAICompletionsCompat", () => {

src/agents/openai-completions-compat.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type OpenAICompletionsCompatDefaults = {
2121
visibleReasoningDetailTypes: string[];
2222
supportsStrictMode: boolean;
2323
requiresReasoningContentOnAssistantMessages: boolean;
24+
requiresNonEmptyUserOrAssistantMessage: boolean;
2425
};
2526

2627
type DetectedOpenAICompletionsCompat = {
@@ -51,6 +52,10 @@ export function resolveOpenAICompletionsCompatDefaults(
5152
knownProviderFamily === "modelstudio" ||
5253
endpointClass === "moonshot-native" ||
5354
endpointClass === "modelstudio-native";
55+
const isModelStudioLike =
56+
knownProviderFamily === "modelstudio" ||
57+
endpointClass === "modelstudio-native" ||
58+
(isDefaultRoute && isDefaultRouteProvider(provider, "dashscope", "modelstudio", "qwen"));
5459
const isZai =
5560
endpointClass === "zai-native" ||
5661
(isDefaultRoute && isDefaultRouteProvider(input.provider, "zai"));
@@ -112,6 +117,7 @@ export function resolveOpenAICompletionsCompatDefaults(
112117
visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [],
113118
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
114119
requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi,
120+
requiresNonEmptyUserOrAssistantMessage: isModelStudioLike,
115121
};
116122
}
117123

src/agents/openai-transport-stream.test.ts

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,13 +1028,13 @@ describe("openai transport stream", () => {
10281028
}
10291029
});
10301030

1031-
it("refuses OpenAI-compatible chat streams with no user or assistant payload turns", async () => {
1031+
it("refuses ModelStudio chat streams with no user or assistant payload turns", async () => {
10321032
const model = {
1033-
id: "mlx-community/Qwen3-30B-A3B-6bit",
1034-
name: "Qwen3 MLX",
1033+
id: "qwen-coder-plus",
1034+
name: "qwen-coder-plus",
10351035
api: "openai-completions",
1036-
provider: "mlx",
1037-
baseUrl: "http://127.0.0.1:9/v1",
1036+
provider: "qwen",
1037+
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
10381038
reasoning: false,
10391039
input: ["text"],
10401040
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1068,6 +1068,96 @@ describe("openai transport stream", () => {
10681068
expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request");
10691069
});
10701070

1071+
it("allows generic OpenAI-compatible chat streams without the ModelStudio turn guard", async () => {
1072+
let capturedRoles: string[] | undefined;
1073+
const server = createServer((req, res) => {
1074+
let body = "";
1075+
req.setEncoding("utf8");
1076+
req.on("data", (chunk) => {
1077+
body += chunk;
1078+
});
1079+
req.on("end", () => {
1080+
const parsed = JSON.parse(body) as { messages?: Array<{ role?: string }> };
1081+
capturedRoles = parsed.messages?.map((message) => message.role ?? "");
1082+
res.writeHead(200, {
1083+
"content-type": "text/event-stream; charset=utf-8",
1084+
"cache-control": "no-cache",
1085+
connection: "keep-alive",
1086+
});
1087+
const created = Math.floor(Date.now() / 1000);
1088+
res.write(
1089+
`data: ${JSON.stringify({
1090+
id: "chatcmpl-system-only",
1091+
object: "chat.completion.chunk",
1092+
created,
1093+
model: "generic-openai-compatible",
1094+
choices: [
1095+
{
1096+
index: 0,
1097+
delta: { role: "assistant", content: "OK" },
1098+
finish_reason: null,
1099+
},
1100+
],
1101+
})}\n\n`,
1102+
);
1103+
res.write(
1104+
`data: ${JSON.stringify({
1105+
id: "chatcmpl-system-only",
1106+
object: "chat.completion.chunk",
1107+
created,
1108+
model: "generic-openai-compatible",
1109+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
1110+
})}\n\n`,
1111+
);
1112+
res.write("data: [DONE]\n\n");
1113+
res.end();
1114+
});
1115+
});
1116+
1117+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
1118+
try {
1119+
const address = server.address();
1120+
if (!address || typeof address === "string") {
1121+
throw new Error("Missing loopback server address");
1122+
}
1123+
const model = {
1124+
id: "generic-openai-compatible",
1125+
name: "Generic OpenAI Compatible",
1126+
api: "openai-completions",
1127+
provider: "custom-openai-compatible",
1128+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
1129+
reasoning: false,
1130+
input: ["text"],
1131+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1132+
contextWindow: 4096,
1133+
maxTokens: 256,
1134+
} satisfies Model<"openai-completions">;
1135+
const stream = createOpenAICompletionsTransportStreamFn()(
1136+
model,
1137+
{
1138+
systemPrompt: "runtime-only system prompt",
1139+
messages: [],
1140+
tools: [],
1141+
} as never,
1142+
{ apiKey: "test-key" } as never,
1143+
);
1144+
1145+
let doneReason: string | undefined;
1146+
for await (const event of stream as AsyncIterable<{ type: string; reason?: string }>) {
1147+
if (event.type === "done") {
1148+
doneReason = event.reason;
1149+
}
1150+
}
1151+
1152+
expect(capturedRoles).toEqual(["system"]);
1153+
expect(doneReason).toBe("stop");
1154+
} finally {
1155+
await new Promise<void>((resolve, reject) => {
1156+
server.close((error) => (error ? reject(error) : resolve()));
1157+
});
1158+
}
1159+
});
1160+
10711161
it("parses JSON chat completions returned to streaming requests", async () => {
10721162
let capturedStreamFlag: unknown;
10731163
const server = createServer((req, res) => {

src/agents/openai-transport-stream.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2419,7 +2419,10 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
24192419
enforceCodeModeResponsesToolSurface(params);
24202420
assertCodeModeResponsesToolSurface(params);
24212421
}
2422-
assertOpenAICompletionsPayloadHasConversationTurn(params, model);
2422+
const compat = getCompat(model as OpenAIModeModel);
2423+
if (compat.requiresNonEmptyUserOrAssistantMessage) {
2424+
assertOpenAICompletionsPayloadHasConversationTurn(params, model);
2425+
}
24232426
const responseStream = (await client.chat.completions.create(
24242427
params as never,
24252428
buildOpenAISdkRequestOptions(model, options?.signal),
@@ -2869,6 +2872,7 @@ function detectCompat(model: OpenAIModeModel) {
28692872
supportsStrictMode: compatDefaults.supportsStrictMode,
28702873
requiresReasoningContentOnAssistantMessages:
28712874
compatDefaults.requiresReasoningContentOnAssistantMessages,
2875+
requiresNonEmptyUserOrAssistantMessage: compatDefaults.requiresNonEmptyUserOrAssistantMessage,
28722876
};
28732877
}
28742878

@@ -2891,6 +2895,7 @@ function getCompat(model: OpenAIModeModel): {
28912895
strictMessageKeys: boolean;
28922896
visibleReasoningDetailTypes: string[];
28932897
requiresReasoningContentOnAssistantMessages: boolean;
2898+
requiresNonEmptyUserOrAssistantMessage: boolean;
28942899
} {
28952900
const detected = detectCompat(model);
28962901
const compat = model.compat ?? {};
@@ -2924,6 +2929,7 @@ function getCompat(model: OpenAIModeModel): {
29242929
compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes,
29252930
requiresReasoningContentOnAssistantMessages:
29262931
detected.requiresReasoningContentOnAssistantMessages,
2932+
requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage,
29272933
};
29282934
}
29292935

0 commit comments

Comments
 (0)