Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 143 additions & 3 deletions packages/kosong/src/providers/openai-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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>,
Expand All @@ -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;
Comment on lines +421 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Alibaba streams incremental without newlines

For Alibaba streams, a normal text delta that contains no newline only grows _nlBuffer and then exits this loop with output === ''; the streaming path only yields when _processNlRepair returns 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 👍 / 👎.

const before = this._nlBuffer.slice(0, nlIdx);
const after = this._nlBuffer.slice(nlIdx + 1);
Comment on lines +423 to +424

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve blank lines in streamed newline repair

When an Alibaba stream contains a paragraph break such as para1\n\npara2, after is the entire remaining buffer (\npara2) rather than the next line. That makes isStructuralNewline miss the empty line and replace the first newline with a space, producing para1 \npara2; the non-stream repair path preserves the same blank line, so streamed Markdown paragraphs are corrupted.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't enter code-block state before consuming the fence

When ordinary text precedes an untyped fenced code block in the same buffered Alibaba stream, this branch sets _nlInCodeBlock while the opening fence line is still in _nlBuffer; on the next loop that opening fence is processed by the in-code-block branch and is mistaken for the closing fence, so subsequent code lines are merged by the repair heuristic. This corrupts common fenced code blocks that start after prose.

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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
154 changes: 154 additions & 0 deletions packages/kosong/test/openai-legacy-newline-repair.test.ts
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');
});
});