Skip to content

Commit 95708cc

Browse files
committed
feat: SSE parser and buffered chat consumption
1 parent cdc1f10 commit 95708cc

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/** Buffered consumption of the knowledge chat SSE stream: deltas concatenate into one complete answer. */
2+
3+
import type { ChatStreamChunk } from './api-types.js'
4+
import { KbApiError } from './client.js'
5+
import { parseSseStream } from './sse.js'
6+
7+
export interface ChatResult {
8+
answer: string
9+
requestId?: string
10+
}
11+
12+
/**
13+
* Consume one chat SSE response to completion.
14+
* @param res - the SSE response from KbClient.postSse.
15+
* @returns the concatenated answer and the last seen request id.
16+
*/
17+
export async function consumeChatStream(res: Response): Promise<ChatResult> {
18+
if (!res.body) throw new KbApiError('knowledge chat returned no response body')
19+
let answer = ''
20+
let requestId: string | undefined
21+
for await (const event of parseSseStream(res.body)) {
22+
if (event.data === '[DONE]') break
23+
if (event.event === 'error') {
24+
let message = `knowledge chat stream error: ${event.data}`
25+
try {
26+
const err = JSON.parse(event.data) as { code?: string; message?: string }
27+
if (err.message) message = `knowledge chat stream error${err.code ? ` (${err.code})` : ''}: ${err.message}`
28+
} catch { /* non-JSON error payload: keep the raw data in the message */ }
29+
throw new KbApiError(message)
30+
}
31+
let parsed: ChatStreamChunk
32+
try {
33+
parsed = JSON.parse(event.data) as ChatStreamChunk
34+
} catch { continue } // unparseable keep-alive/comment payloads carry no answer content
35+
if (parsed.request_id) requestId = parsed.request_id
36+
for (const choice of parsed.output?.choices ?? []) {
37+
if (choice.message?.content) answer += choice.message.content
38+
}
39+
}
40+
return { answer, requestId }
41+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/** Minimal SSE parser for the knowledge chat stream: `event:`/`data:` lines, events split on blank lines. */
2+
3+
export interface SseEvent {
4+
event?: string
5+
data: string
6+
}
7+
8+
/**
9+
* Parse one SSE byte stream into events.
10+
* @param body - the response body stream.
11+
* @returns events in stream order; multi-`data:` events join with newlines per the SSE spec.
12+
*/
13+
export async function* parseSseStream(body: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
14+
const decoder = new TextDecoder()
15+
let buffer = ''
16+
let event: string | undefined
17+
let data: string[] = []
18+
19+
const flush = (): SseEvent | undefined => {
20+
if (data.length === 0) return undefined
21+
const out = { event, data: data.join('\n') }
22+
event = undefined
23+
data = []
24+
return out
25+
}
26+
27+
const reader = body.getReader()
28+
while (true) {
29+
const { done, value } = await reader.read()
30+
buffer += done ? '' : decoder.decode(value, { stream: true })
31+
let newline: number
32+
while ((newline = buffer.indexOf('\n')) !== -1) {
33+
const line = buffer.slice(0, newline).replace(/\r$/, '')
34+
buffer = buffer.slice(newline + 1)
35+
if (line === '') {
36+
const out = flush()
37+
if (out) yield out
38+
} else if (line.startsWith('event:')) {
39+
event = line.slice(6).trim()
40+
} else if (line.startsWith('data:')) {
41+
data.push(line.slice(5).trimStart())
42+
}
43+
// comment/id/retry lines are irrelevant to this API and are skipped
44+
}
45+
if (done) {
46+
const out = flush()
47+
if (out) yield out
48+
return
49+
}
50+
}
51+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { consumeChatStream } from '../src/chat.js'
3+
4+
function sse(text: string): Response {
5+
return new Response(text, { status: 200 })
6+
}
7+
8+
function chunk(content: string, finish = ''): string {
9+
return `data: ${JSON.stringify({ output: { choices: [{ message: { content }, finish_reason: finish }] }, request_id: 'r-1' })}\n\n`
10+
}
11+
12+
describe('consumeChatStream', () => {
13+
it('concatenates delta content across chunks until [DONE]', async () => {
14+
const res = sse(chunk('Hello') + chunk(' world', 'stop') + 'data: [DONE]\n\n')
15+
const out = await consumeChatStream(res)
16+
expect(out.answer).toBe('Hello world')
17+
expect(out.requestId).toBe('r-1')
18+
})
19+
20+
it('ignores step_change progress chunks with empty content', async () => {
21+
const progress = `data: ${JSON.stringify({ output: { choices: [{ message: { content: '', extra: { step_change: 'tool_calling' } }, finish_reason: '' }] } })}\n\n`
22+
const res = sse(progress + chunk('answer', 'stop') + 'data: [DONE]\n\n')
23+
expect((await consumeChatStream(res)).answer).toBe('answer')
24+
})
25+
26+
it('throws on an SSE error event with the server message', async () => {
27+
const res = sse('event: error\ndata: {"code":"Throttling","message":"rate limited"}\n\n')
28+
await expect(consumeChatStream(res)).rejects.toThrow(/Throttling.*rate limited/)
29+
})
30+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseSseStream } from '../src/sse.js'
3+
4+
function streamOf(text: string): ReadableStream<Uint8Array> {
5+
return new Response(text).body as ReadableStream<Uint8Array>
6+
}
7+
8+
async function collect(text: string) {
9+
const events: { event?: string; data: string }[] = []
10+
for await (const e of parseSseStream(streamOf(text))) events.push(e)
11+
return events
12+
}
13+
14+
describe('parseSseStream', () => {
15+
it('yields data events split on blank lines', async () => {
16+
const events = await collect('data: {"a":1}\n\ndata: [DONE]\n\n')
17+
expect(events).toEqual([{ event: undefined, data: '{"a":1}' }, { event: undefined, data: '[DONE]' }])
18+
})
19+
20+
it('carries the event field and parses CRLF lines', async () => {
21+
const events = await collect('event: error\r\ndata: {"message":"boom"}\r\n\r\n')
22+
expect(events[0]).toEqual({ event: 'error', data: '{"message":"boom"}' })
23+
})
24+
25+
it('flushes a final event not terminated by a blank line', async () => {
26+
const events = await collect('data: tail\n')
27+
expect(events).toEqual([{ event: undefined, data: 'tail' }])
28+
})
29+
})

0 commit comments

Comments
 (0)