Skip to content

Commit d93e6f4

Browse files
committed
fix(textGeneration): flag max_tokens-truncated responses as interrupted
When a model hit its max_tokens budget mid-generation, the provider returns finish_reason: "length". The stream adapters treated "length" identically to a clean "stop" (marking the final token special), so generate.ts computed interrupted=false and the truncated message was persisted as if it were complete. No log, no interrupted flag, no UI signal. This was especially visible with reasoning models: e.g. GLM-5.2 (max_tokens 49152) could spend its entire budget on <think> reasoning and never emit a final answer, leaving the user with a 160KB think-dump cut off mid-code and no way to tell it was truncated or to continue it. - Stream adapters (openAIChatToTextGenerationStream, openAICompletionToTextGenerationStream, and the non-streaming single adapter) now mark the final output truncated:true when finish_reason === "length". - generate.ts forces interrupted=true on a truncated output and logs a warning. - The MCP flow tracks finish_reason and emits its FinalAnswer with interrupted set accordingly, plus a warning. - Adds a regression test covering the length vs stop cases.
1 parent 7f00b3f commit d93e6f4

5 files changed

Lines changed: 96 additions & 4 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from "vitest";
2+
import type OpenAI from "openai";
3+
import type { Stream } from "openai/streaming";
4+
import { openAIChatToTextGenerationStream } from "./openAIChatToTextGenerationStream";
5+
6+
type Chunk = OpenAI.Chat.Completions.ChatCompletionChunk;
7+
8+
function mockStream(chunks: unknown[]): Stream<Chunk> {
9+
async function* gen() {
10+
for (const c of chunks) yield c as Chunk;
11+
}
12+
return gen() as unknown as Stream<Chunk>;
13+
}
14+
15+
type Output = { token: { special: boolean }; generated_text: string | null; truncated?: boolean };
16+
17+
async function collect(stream: AsyncIterable<unknown>): Promise<Output[]> {
18+
const out: Output[] = [];
19+
for await (const o of stream) out.push(o as Output);
20+
return out;
21+
}
22+
23+
describe("openAIChatToTextGenerationStream truncation handling", () => {
24+
it("flags truncated=true when the stream ends with finish_reason 'length'", async () => {
25+
// Mimics a reasoning model (e.g. GLM-5.2) that spends its entire max_tokens budget on
26+
// reasoning_content and hits the limit before ever emitting a final answer.
27+
const chunks = [
28+
{ choices: [{ index: 0, delta: { reasoning_content: "let me think about the kebab" } }] },
29+
{ choices: [{ index: 0, delta: { reasoning_content: " a bit more, drafting code" } }] },
30+
{ choices: [{ index: 0, delta: {}, finish_reason: "length" }] },
31+
];
32+
33+
const outputs = await collect(openAIChatToTextGenerationStream(mockStream(chunks)));
34+
const final = outputs.find((o) => o.generated_text !== null);
35+
36+
expect(final).toBeDefined();
37+
expect(final?.truncated).toBe(true);
38+
// The reasoning is still surfaced as an (unterminated) <think> block.
39+
expect(final?.generated_text).toContain("<think>");
40+
});
41+
42+
it("does not flag truncated on a normal finish_reason 'stop'", async () => {
43+
const chunks = [
44+
{ choices: [{ index: 0, delta: { content: "Hello" } }] },
45+
{ choices: [{ index: 0, delta: { content: " world" }, finish_reason: "stop" }] },
46+
];
47+
48+
const outputs = await collect(openAIChatToTextGenerationStream(mockStream(chunks)));
49+
const final = outputs.find((o) => o.generated_text !== null);
50+
51+
expect(final?.generated_text).toBe("Hello world");
52+
expect(final?.truncated).toBeFalsy();
53+
});
54+
});

src/lib/server/endpoints/openai/openAIChatToTextGenerationStream.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ export async function* openAIChatToTextGenerationStream(
124124

125125
// Accumulate the combined token into the full text
126126
generatedText += combined;
127-
const output: TextGenerationStreamOutput = {
127+
// `finish_reason: "length"` means the model hit its max_tokens budget mid-generation
128+
// (e.g. exhausted the budget while still reasoning). Surface it so the pipeline can flag
129+
// the answer as interrupted instead of silently persisting a truncated message as complete.
130+
const truncated = last && choices?.[0]?.finish_reason === "length";
131+
const output: TextGenerationStreamOutput & { truncated?: boolean } = {
128132
token: {
129133
id: tokenId++,
130134
text: combined,
@@ -133,6 +137,7 @@ export async function* openAIChatToTextGenerationStream(
133137
},
134138
generated_text: last ? generatedText : null,
135139
details: null,
140+
...(truncated ? { truncated: true } : {}),
136141
};
137142
yield output;
138143

@@ -187,6 +192,8 @@ export async function* openAIChatToTextGenerationSingle(
187192
content = `<think>${r}</think>` + content;
188193
}
189194
const tokenId = 0;
195+
// Hit the max_tokens budget before completing (see streaming adapter above).
196+
const truncated = completion.choices?.[0]?.finish_reason === "length";
190197

191198
// Yield the content as a single token
192199
yield {
@@ -198,6 +205,7 @@ export async function* openAIChatToTextGenerationSingle(
198205
},
199206
generated_text: content,
200207
details: null,
208+
...(truncated ? { truncated: true } : {}),
201209
...(getRouterMetadata
202210
? (() => {
203211
const metadata = getRouterMetadata();
@@ -207,6 +215,7 @@ export async function* openAIChatToTextGenerationSingle(
207215
})()
208216
: {}),
209217
} as TextGenerationStreamOutput & {
218+
truncated?: boolean;
210219
routerMetadata?: { route?: string; model?: string; provider?: string };
211220
};
212221
}

src/lib/server/endpoints/openai/openAICompletionToTextGenerationStream.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ export async function* openAICompletionToTextGenerationStream(
1717
if (text) {
1818
generatedText = generatedText + text;
1919
}
20-
const output: TextGenerationStreamOutput = {
20+
// `finish_reason: "length"` => hit max_tokens before completing; surface it so the
21+
// pipeline flags the answer as interrupted rather than persisting a truncated message.
22+
const truncated = last && choices?.[0]?.finish_reason === "length";
23+
const output: TextGenerationStreamOutput & { truncated?: boolean } = {
2124
token: {
2225
id: tokenId++,
2326
text,
@@ -26,6 +29,7 @@ export async function* openAICompletionToTextGenerationStream(
2629
},
2730
generated_text: last ? generatedText : null,
2831
details: null,
32+
...(truncated ? { truncated: true } : {}),
2933
};
3034
yield output;
3135
}

src/lib/server/textGeneration/generate.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ export async function* generate(
109109
text = text.slice(0, text.length - stopToken.length);
110110
}
111111

112+
// `finish_reason: "length"` means the model hit its max_tokens budget mid-generation
113+
// (e.g. exhausted the budget while still reasoning, never reaching the answer). The
114+
// provider marks the final token as a normal stop, so flag it explicitly as interrupted
115+
// and log it instead of silently saving a truncated answer as if it were complete.
116+
if ((output as { truncated?: boolean }).truncated) {
117+
interrupted = true;
118+
logger.warn(
119+
{ conversationId: conv._id.toString(), model: model.id, length: text.length },
120+
"Generation truncated: model hit max_tokens (finish_reason=length) before completing"
121+
);
122+
}
123+
112124
let finalAnswer = text;
113125
if (modelReasoning && modelReasoning.type === "regex" && modelReasoning.regex) {
114126
const regex = new RegExp(modelReasoning.regex);

src/lib/server/textGeneration/mcp/runMcpFlow.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,12 @@ export async function* runMcpFlow({
508508
let firstToolDeltaLogged = false;
509509
let sawToolCall = false;
510510
let tokenCount = 0;
511+
let finishReason: string | null = null;
511512
for await (const chunk of completionStream) {
512513
const choice = chunk.choices?.[0];
514+
// Captured before the delta guard: the terminating chunk often carries the
515+
// finish_reason with an empty/absent delta.
516+
if (choice?.finish_reason) finishReason = choice.finish_reason;
513517
const delta = choice?.delta;
514518
if (!delta) continue;
515519

@@ -744,13 +748,22 @@ export async function* runMcpFlow({
744748
if (!streamedContent && lastAssistantContent.trim().length > 0) {
745749
yield { type: MessageUpdateType.Stream, token: lastAssistantContent };
746750
}
751+
// `finish_reason: "length"` means the model hit its max_tokens budget before
752+
// completing; flag it as interrupted instead of saving a truncated answer as complete.
753+
const truncated = finishReason === "length";
754+
if (truncated) {
755+
logger.warn(
756+
{ conversationId: conv._id.toString(), length: lastAssistantContent.length, loop },
757+
"[mcp] generation truncated: model hit max_tokens (finish_reason=length) before completing"
758+
);
759+
}
747760
yield {
748761
type: MessageUpdateType.FinalAnswer,
749762
text: lastAssistantContent,
750-
interrupted: false,
763+
interrupted: truncated,
751764
};
752765
logger.info(
753-
{ length: lastAssistantContent.length, loop },
766+
{ length: lastAssistantContent.length, loop, truncated },
754767
"[mcp] final answer emitted (no tool_calls)"
755768
);
756769
return "completed";

0 commit comments

Comments
 (0)