fix(ai): openai-completions - throw error on missing finish-reason
- require \ before treating \ streams as successful - add regression coverage for truncated streams without \ - closes #4345
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)).
|
- Fixed GitHub Copilot model availability to ignore generic `GH_TOKEN` and `GITHUB_TOKEN` environment variables, requiring OAuth login or `COPILOT_GITHUB_TOKEN` instead ([#4485](https://github.com/earendil-works/pi/issues/4485)).
|
||||||
|
- Fixed `openai-completions` streams to surface an error when the stream ends before any terminal `finish_reason`, so truncated responses can retry instead of being accepted as success ([#4345](https://github.com/earendil-works/pi/issues/4345)).
|
||||||
- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents.
|
- Fixed Bedrock proxy handling to preserve `NO_PROXY` exclusions while using HTTP(S)-only proxy agents.
|
||||||
- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID.
|
- Fixed GitHub Copilot Claude test coverage to use the current Claude Sonnet 4.6 model ID.
|
||||||
- Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off.
|
- Fixed OpenAI Responses requests for models that support disabling reasoning to send `reasoning.effort: "none"` when thinking is off.
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
|
|
||||||
let textBlock: TextContent | null = null;
|
let textBlock: TextContent | null = null;
|
||||||
let thinkingBlock: ThinkingContent | null = null;
|
let thinkingBlock: ThinkingContent | null = null;
|
||||||
|
let hasFinishReason = false;
|
||||||
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
|
const toolCallBlocksByIndex = new Map<number, StreamingToolCallBlock>();
|
||||||
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
|
const toolCallBlocksById = new Map<string, StreamingToolCallBlock>();
|
||||||
const blocks = output.content as StreamingBlock[];
|
const blocks = output.content as StreamingBlock[];
|
||||||
@@ -288,6 +289,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
if (finishReasonResult.errorMessage) {
|
if (finishReasonResult.errorMessage) {
|
||||||
output.errorMessage = finishReasonResult.errorMessage;
|
output.errorMessage = finishReasonResult.errorMessage;
|
||||||
}
|
}
|
||||||
|
hasFinishReason = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (choice.delta) {
|
if (choice.delta) {
|
||||||
@@ -390,6 +392,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA
|
|||||||
if (output.stopReason === "error") {
|
if (output.stopReason === "error") {
|
||||||
throw new Error(output.errorMessage || "Provider returned an error stop reason");
|
throw new Error(output.errorMessage || "Provider returned an error stop reason");
|
||||||
}
|
}
|
||||||
|
if (!hasFinishReason) {
|
||||||
|
throw new Error("Stream ended without finish_reason");
|
||||||
|
}
|
||||||
|
|
||||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||||
stream.end();
|
stream.end();
|
||||||
|
|||||||
@@ -441,6 +441,38 @@ describe("openai-completions tool_choice", () => {
|
|||||||
expect(response.content).toEqual([{ type: "text", text: "OK" }]);
|
expect(response.content).toEqual([{ type: "text", text: "OK" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("errors when a stream ends after only null finish_reason chunks", async () => {
|
||||||
|
mockState.chunks = [
|
||||||
|
{
|
||||||
|
id: "chatcmpl-truncated",
|
||||||
|
choices: [{ delta: { content: "partial answer" }, finish_reason: null }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-truncated",
|
||||||
|
choices: [{ delta: { content: "partial answer" }, finish_reason: null }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
|
||||||
|
const model = { ...baseModel, api: "openai-completions" } as const;
|
||||||
|
const response = await streamSimple(
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Reply with a longer sentence",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ apiKey: "test" },
|
||||||
|
).result();
|
||||||
|
|
||||||
|
expect(response.stopReason).toBe("error");
|
||||||
|
expect(response.errorMessage).toBe("Stream ended without finish_reason");
|
||||||
|
});
|
||||||
|
|
||||||
it("coalesces tool call deltas by stable index when provider mutates ids mid-stream", async () => {
|
it("coalesces tool call deltas by stable index when provider mutates ids mid-stream", async () => {
|
||||||
mockState.chunks = [
|
mockState.chunks = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user