-
Notifications
You must be signed in to change notification settings - Fork 891
fix(kosong): repair inline newlines from Alibaba gateway streams #2375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,64 @@ const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { | |
| maxLength: 64, | ||
| }; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Alibaba gateway newline repair | ||
| // --------------------------------------------------------------------------- | ||
| // 部分模型(如 Qwen)经阿里 token plan / dashscope 网关返回的流式响应,会在 | ||
| // 中文行内短语边界插入多余换行符,破坏 Markdown 列表项与表格结构(列表续行 | ||
| // 丢失缩进、表格行被拆散)。此处仅对阿里系网关启用修复:把“非结构性换行” | ||
| // 替换为空格,保留真正的结构性换行。其他渠道不受影响。 | ||
|
|
||
| /** 判断 baseUrl 是否为阿里系网关(token plan / dashscope 等)。 */ | ||
| export function isAlibabaGateway(baseUrl: string | undefined): boolean { | ||
| if (!baseUrl) return false; | ||
| return /aliyuncs\.com|dashscope/i.test(baseUrl); | ||
| } | ||
|
|
||
| /** | ||
| * 判断两行之间的换行是否为“结构性换行”(应保留)。 | ||
| * 结构性换行包括:空行(段落分隔)、后行以块级标记开头(标题/列表/引用/表格/ | ||
| * 代码围栏/水平线)、前行是标题。其余视为行内多余换行,应替换为空格。 | ||
| */ | ||
| export function isStructuralNewline(prevLine: string, nextLine: string): boolean { | ||
| if (prevLine === '' || nextLine === '') return true; | ||
| if (/^(?:#{1,6}\s|\s*[-*+]\s|\s*\d+\.\s|\s*>|\||`{3,}|~{3,})/.test(nextLine)) return true; | ||
| if (/^#{1,6}\s/.test(prevLine)) return true; | ||
| // 前行是代码围栏(开始或结束)时,其后换行必须保留,否则围栏会与相邻行粘连。 | ||
| if (/^(`{3,}|~{3,})/.test(prevLine.trim())) return true; | ||
| if (/^\s*([-*_])\s*\1\s*\1/.test(nextLine)) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /** 非流式完整文本修复:逐行合并非结构性换行,保留代码块与块级结构。 */ | ||
| export function repairFullText(text: string): string { | ||
| const lines = text.split('\n'); | ||
| if (lines.length <= 1) return text; | ||
| const result: string[] = [lines[0]!]; | ||
| let inCodeBlock = false; | ||
| if (/^(`{3,}|~{3,})/.test(lines[0]!.trim())) inCodeBlock = true; | ||
| for (let i = 1; i < lines.length; i++) { | ||
| const prev = result[result.length - 1]!; | ||
| const curr = lines[i]!; | ||
| if (inCodeBlock) { | ||
| result.push(curr); | ||
| if (/^(`{3,}|~{3,})\s*$/.test(curr.trim())) inCodeBlock = false; | ||
| continue; | ||
| } | ||
| if (/^(`{3,}|~{3,})/.test(curr.trim())) { | ||
| result.push(curr); | ||
| inCodeBlock = true; | ||
| continue; | ||
| } | ||
| if (isStructuralNewline(prev, curr)) { | ||
| result.push(curr); | ||
| } else { | ||
| result[result.length - 1] = prev + ' ' + curr; | ||
| } | ||
| } | ||
| return result.join('\n'); | ||
| } | ||
|
|
||
| function responseFormatToOpenAI(format: ResponseFormat): Record<string, unknown> { | ||
| if (format.type === 'json_object') { | ||
| return { type: 'json_object' }; | ||
|
|
@@ -326,12 +384,18 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { | |
| private _finishReason: FinishReason | null = null; | ||
| private _rawFinishReason: string | null = null; | ||
| private readonly _iter: AsyncGenerator<StreamedMessagePart>; | ||
| // 阿里网关换行修复状态:仅当 _needsNlRepair 为 true 时使用。 | ||
| private readonly _needsNlRepair: boolean; | ||
| private _nlBuffer = ''; | ||
| private _nlInCodeBlock = false; | ||
|
|
||
| constructor( | ||
| response: OpenAI.Chat.ChatCompletion | AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, | ||
| isStream: boolean, | ||
| reasoningKeyDialect: ReasoningKeyDialect, | ||
| needsNlRepair = false, | ||
| ) { | ||
| this._needsNlRepair = needsNlRepair; | ||
| if (isStream) { | ||
| this._iter = this._convertStreamResponse( | ||
| response as AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, | ||
|
|
@@ -345,6 +409,60 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * 对单个 text delta 做行缓冲换行修复。 | ||
| * 流式 delta 可能在任意位置切断一行,因此遇到位于 buffer 末尾的换行时先 | ||
| * 暂存,等下一个 chunk 到来再判断该换行是否结构性。返回可立即 yield 的文本。 | ||
| */ | ||
| private _processNlRepair(content: string): string { | ||
| this._nlBuffer += content; | ||
| let output = ''; | ||
| while (true) { | ||
| const nlIdx = this._nlBuffer.indexOf('\n'); | ||
| if (nlIdx === -1) break; | ||
| const before = this._nlBuffer.slice(0, nlIdx); | ||
| const after = this._nlBuffer.slice(nlIdx + 1); | ||
|
Comment on lines
+423
to
+424
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an Alibaba stream contains a paragraph break such as Useful? React with 👍 / 👎. |
||
| // 换行落在 buffer 末尾,下一行尚未到达,暂存待后续 chunk 判断。 | ||
| if (after === '') { | ||
| this._nlBuffer = before + '\n'; | ||
| break; | ||
| } | ||
| if (this._nlInCodeBlock) { | ||
| output += before + '\n'; | ||
| if (/^(`{3,}|~{3,})\s*$/.test(before.trim())) this._nlInCodeBlock = false; | ||
| this._nlBuffer = after; | ||
| continue; | ||
| } | ||
| if (/^(`{3,}|~{3,})/.test(before.trim())) { | ||
| output += before + '\n'; | ||
| this._nlInCodeBlock = true; | ||
| this._nlBuffer = after; | ||
| continue; | ||
| } | ||
| if (/^(`{3,}|~{3,})/.test(after.trim())) { | ||
| output += before + '\n'; | ||
| this._nlInCodeBlock = true; | ||
| this._nlBuffer = after; | ||
|
Comment on lines
+442
to
+445
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When ordinary text precedes an untyped fenced code block in the same buffered Alibaba stream, this branch sets Useful? React with 👍 / 👎. |
||
| continue; | ||
| } | ||
| if (isStructuralNewline(before, after)) { | ||
| output += before + '\n'; | ||
| } else { | ||
| output += before + ' '; | ||
| } | ||
| this._nlBuffer = after; | ||
| } | ||
| return output; | ||
| } | ||
|
|
||
| /** 流结束时 flush 残留 buffer(不再等待后续 chunk)。 */ | ||
| private _flushNlRepair(): string | null { | ||
| if (this._nlBuffer === '') return null; | ||
| const result = this._nlBuffer; | ||
| this._nlBuffer = ''; | ||
| return result; | ||
| } | ||
|
|
||
| get id(): string | null { | ||
| return this._id; | ||
| } | ||
|
|
@@ -392,7 +510,9 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { | |
| } | ||
|
|
||
| if (message.content) { | ||
| yield { type: 'text', text: message.content } satisfies StreamedMessagePart; | ||
| // 阿里网关可能在非流式响应文本中插入行内多余换行,按需修复。 | ||
| const text = this._needsNlRepair ? repairFullText(message.content) : message.content; | ||
| yield { type: 'text', text } satisfies StreamedMessagePart; | ||
| } | ||
|
|
||
| if (message.tool_calls) { | ||
|
|
@@ -448,7 +568,15 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { | |
|
|
||
| // text content | ||
| if (delta.content) { | ||
| yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; | ||
| if (this._needsNlRepair) { | ||
| // 阿里网关行内多余换行修复:经行缓冲判断后产出可立即下发的文本。 | ||
| const repaired = this._processNlRepair(delta.content); | ||
| if (repaired) { | ||
| yield { type: 'text', text: repaired } satisfies StreamedMessagePart; | ||
| } | ||
| } else { | ||
| yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; | ||
| } | ||
| } | ||
|
|
||
| // tool calls — preserve `index` on every yielded part so the generate | ||
|
|
@@ -459,6 +587,13 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { | |
| } | ||
| } | ||
| } | ||
| // 流结束:flush 行缓冲中残留的文本(含此前暂存的末尾换行)。 | ||
| if (this._needsNlRepair) { | ||
| const flushed = this._flushNlRepair(); | ||
| if (flushed) { | ||
| yield { type: 'text', text: flushed } satisfies StreamedMessagePart; | ||
| } | ||
| } | ||
| } catch (error: unknown) { | ||
| throw convertOpenAIError(error); | ||
| } | ||
|
|
@@ -640,7 +775,12 @@ export class OpenAILegacyChatProvider implements ChatProvider { | |
| createParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, | ||
| options?.signal ? { signal: options.signal } : undefined, | ||
| )) as unknown as OpenAI.Chat.ChatCompletion | AsyncIterable<OpenAI.Chat.ChatCompletionChunk>; | ||
| return new OpenAILegacyStreamedMessage(response, this._stream, this._reasoningKeyDialect); | ||
| return new OpenAILegacyStreamedMessage( | ||
| response, | ||
| this._stream, | ||
| this._reasoningKeyDialect, | ||
| isAlibabaGateway(this._baseUrl), | ||
| ); | ||
| } catch (error: unknown) { | ||
| throw convertOpenAIError(error); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { StreamedMessagePart, TextPart } from '#/message'; | ||
| import { | ||
| isAlibabaGateway, | ||
| isStructuralNewline, | ||
| OpenAILegacyChatProvider, | ||
| repairFullText, | ||
| } from '#/providers/openai-legacy'; | ||
|
|
||
| const ALIBABA_BASE_URL = 'https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1'; | ||
|
|
||
| describe('isAlibabaGateway', () => { | ||
| it('matches token-plan maas gateway', () => { | ||
| expect(isAlibabaGateway(ALIBABA_BASE_URL)).toBe(true); | ||
| }); | ||
| it('matches dashscope gateway', () => { | ||
| expect(isAlibabaGateway('https://dashscope.aliyuncs.com/compatible-mode/v1')).toBe(true); | ||
| }); | ||
| it('does not match openai', () => { | ||
| expect(isAlibabaGateway('https://api.openai.com/v1')).toBe(false); | ||
| }); | ||
| it('does not match deepseek', () => { | ||
| expect(isAlibabaGateway('https://api.deepseek.com')).toBe(false); | ||
| }); | ||
| it('returns false for undefined', () => { | ||
| expect(isAlibabaGateway(undefined)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('isStructuralNewline', () => { | ||
| it('empty prev or next line is structural', () => { | ||
| expect(isStructuralNewline('', 'hello')).toBe(true); | ||
| expect(isStructuralNewline('hello', '')).toBe(true); | ||
| }); | ||
| it('next line heading / list / quote / table / fence is structural', () => { | ||
| expect(isStructuralNewline('text', '## Title')).toBe(true); | ||
| expect(isStructuralNewline('text', '- item')).toBe(true); | ||
| expect(isStructuralNewline('text', ' * item')).toBe(true); | ||
| expect(isStructuralNewline('text', '1. item')).toBe(true); | ||
| expect(isStructuralNewline('text', '> quote')).toBe(true); | ||
| expect(isStructuralNewline('text', '| col |')).toBe(true); | ||
| expect(isStructuralNewline('text', '```ts')).toBe(true); | ||
| }); | ||
| it('prev line heading is structural', () => { | ||
| expect(isStructuralNewline('## Title', 'Some text')).toBe(true); | ||
| }); | ||
| it('horizontal rule is structural', () => { | ||
| expect(isStructuralNewline('text', '---')).toBe(true); | ||
| expect(isStructuralNewline('text', '***')).toBe(true); | ||
| }); | ||
| it('plain inline continuation is NOT structural', () => { | ||
| expect(isStructuralNewline('- 裁剪决策已写入文件,', '后续同步遇到新增')).toBe(false); | ||
| expect(isStructuralNewline('| ur/ee2/saas.git |', '✓ cacd2b |')).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('repairFullText', () => { | ||
| it('leaves single line unchanged', () => { | ||
| expect(repairFullText('hello world')).toBe('hello world'); | ||
| }); | ||
| it('merges list item continuation lines', () => { | ||
| expect(repairFullText('- 裁剪决策已写入文件,\n后续同步遇到新增\n应用可直接查阅')) | ||
| .toBe('- 裁剪决策已写入文件, 后续同步遇到新增 应用可直接查阅'); | ||
| }); | ||
| it('merges a broken table row', () => { | ||
| const input = '| 仓库 | 状态 |\n|------|------|\n| ur/ee2/saas.git |\n✓ cacd2b |'; | ||
| const expected = '| 仓库 | 状态 |\n|------|------|\n| ur/ee2/saas.git | ✓ cacd2b |'; | ||
| expect(repairFullText(input)).toBe(expected); | ||
| }); | ||
| it('preserves normal list items', () => { | ||
| const input = '- item1\n- item2\n- item3'; | ||
| expect(repairFullText(input)).toBe(input); | ||
| }); | ||
| it('preserves paragraph separation', () => { | ||
| expect(repairFullText('para1\n\npara2')).toBe('para1\n\npara2'); | ||
| }); | ||
| it('preserves heading followed by body', () => { | ||
| expect(repairFullText('## Title\nSome text')).toBe('## Title\nSome text'); | ||
| }); | ||
| it('preserves fenced code block', () => { | ||
| const input = '```\nline1\nline2\n```'; | ||
| expect(repairFullText(input)).toBe(input); | ||
| }); | ||
| it('preserves code block embedded in text', () => { | ||
| const input = 'before\n```js\nconst a = 1\nconst b = 2\n```\nafter'; | ||
| expect(repairFullText(input)).toBe(input); | ||
| }); | ||
| it('preserves ordered list', () => { | ||
| const input = '1. first\n2. second\n3. third'; | ||
| expect(repairFullText(input)).toBe(input); | ||
| }); | ||
| it('handles mixed heading and broken table', () => { | ||
| const input = '## 推送结果\n\n| 仓库 | 状态 |\n|------|------|\n| saas.git |\n✓ done |'; | ||
| const expected = '## 推送结果\n\n| 仓库 | 状态 |\n|------|------|\n| saas.git | ✓ done |'; | ||
| expect(repairFullText(input)).toBe(expected); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Alibaba gateway newline repair (provider integration)', () => { | ||
| function mockStream(provider: OpenAILegacyChatProvider, deltas: string[]): void { | ||
| async function* mockedStream(): AsyncIterable<Record<string, unknown>> { | ||
| for (const content of deltas) { | ||
| yield { id: 'c1', choices: [{ index: 0, delta: { content } }] }; | ||
| } | ||
| yield { id: 'c1', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }; | ||
| } | ||
| (provider as any)._client.chat.completions.create = vi | ||
| .fn() | ||
| .mockResolvedValue(mockedStream()); | ||
| } | ||
|
|
||
| async function collectText(provider: OpenAILegacyChatProvider): Promise<string> { | ||
| const stream = await provider.generate('', [], [ | ||
| { role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }, | ||
| ]); | ||
| const parts: StreamedMessagePart[] = []; | ||
| for await (const part of stream) parts.push(part); | ||
| return parts.filter((p): p is TextPart => p.type === 'text').map((p) => p.text).join(''); | ||
| } | ||
|
|
||
| it('repairs inline newlines for Alibaba baseUrl (streaming)', async () => { | ||
| const provider = new OpenAILegacyChatProvider({ | ||
| model: 'qwen3.8-max-preview', | ||
| apiKey: 'test-key', | ||
| baseUrl: ALIBABA_BASE_URL, | ||
| stream: true, | ||
| }); | ||
| mockStream(provider, ['- 裁剪决策已写入文件,\n', '后续同步遇到新增\n', '应用可直接查阅']); | ||
| expect(await collectText(provider)).toBe('- 裁剪决策已写入文件, 后续同步遇到新增 应用可直接查阅'); | ||
| }); | ||
|
|
||
| it('does NOT repair for non-Alibaba baseUrl (streaming)', async () => { | ||
| const provider = new OpenAILegacyChatProvider({ | ||
| model: 'gpt-4.1', | ||
| apiKey: 'test-key', | ||
| baseUrl: 'https://api.openai.com/v1', | ||
| stream: true, | ||
| }); | ||
| mockStream(provider, ['- 裁剪决策已写入文件,\n', '后续同步遇到新增\n', '应用可直接查阅']); | ||
| expect(await collectText(provider)).toBe('- 裁剪决策已写入文件,\n后续同步遇到新增\n应用可直接查阅'); | ||
| }); | ||
|
|
||
| it('preserves structural newlines for Alibaba baseUrl (streaming)', async () => { | ||
| const provider = new OpenAILegacyChatProvider({ | ||
| model: 'qwen3.8-max-preview', | ||
| apiKey: 'test-key', | ||
| baseUrl: ALIBABA_BASE_URL, | ||
| stream: true, | ||
| }); | ||
| mockStream(provider, ['- item1\n', '- item2\n', '- item3']); | ||
| expect(await collectText(provider)).toBe('- item1\n- item2\n- item3'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Alibaba streams, a normal text delta that contains no newline only grows
_nlBufferand then exits this loop withoutput === ''; the streaming path only yields when_processNlRepairreturns text, so a long single-line paragraph is hidden until a later newline or the final flush. This regresses the streaming UX for common responses where tokens arrive for seconds without a newline; keep only the newline boundary pending and continue yielding safe text as deltas arrive.Useful? React with 👍 / 👎.