Skip to content

Commit

Permalink
feat: improve error handling (#13)
Browse files Browse the repository at this point in the history
* feat: error handling with ChatCompletionsError

* refactor: rename to ChatError
  • Loading branch information
nekomeowww authored Dec 18, 2024
1 parent f917fd0 commit a84dc89
Show file tree
Hide file tree
Showing 7 changed files with 97 additions and 62 deletions.
14 changes: 11 additions & 3 deletions packages/generate-text/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { type AssistantMessageResponse, chatCompletion, type ChatCompletionOptions, type FinishReason, type Message, type Tool } from '@xsai/shared-chat'
import {
type AssistantMessageResponse,
chatCompletion,
type ChatCompletionOptions,
ChatError,
type FinishReason,
type Message,
type Tool,
} from '@xsai/shared-chat'

export interface GenerateTextOptions extends ChatCompletionOptions {
/** @default 1 */
Expand Down Expand Up @@ -68,8 +76,8 @@ export const generateText = async (options: GenerateTextOptions): Promise<Genera
})

if (!res.ok) {
const error = await res.text()
throw new Error(`Error(${res.status}): ${error}`)
const error = new ChatError(`Remote sent ${res.status} response`, res)
error.cause = new Error(await res.text())
}

const data: GenerateTextResponse = await res.json()
Expand Down
2 changes: 2 additions & 0 deletions packages/generate-text/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ describe('@xsai/generate-text', () => {

expect(text).toStrictEqual('YES')
})

// TODO: error handling
})
9 changes: 9 additions & 0 deletions packages/shared-chat/src/error/chat-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class ChatError extends Error {
response?: Response

constructor(message: string, response?: Response) {
super(message)
this.name = 'ChatError'
this.response = response
}
}
1 change: 1 addition & 0 deletions packages/shared-chat/src/error/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './chat-error'
1 change: 1 addition & 0 deletions packages/shared-chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './error'
export type * from './types'
export * from './utils'
130 changes: 71 additions & 59 deletions packages/stream-text/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { chatCompletion, type ChatCompletionOptions, type FinishReason } from '@xsai/shared-chat'
import {
chatCompletion,
type ChatCompletionOptions,
ChatError,
type FinishReason,
} from '@xsai/shared-chat'

export interface StreamTextOptions extends ChatCompletionOptions {
/** if you want to disable stream, use `@xsai/generate-text` */
Expand Down Expand Up @@ -50,68 +55,75 @@ const dataErrorPrefix = `{"error":`
/**
* @experimental WIP, does not support function calling (tools).
*/
export const streamText = async (options: StreamTextOptions): Promise<StreamTextResult> =>
await chatCompletion({
export const streamText = async (options: StreamTextOptions): Promise<StreamTextResult> => {
const res = await chatCompletion({
...options,
stream: true,
})
.then((res) => {
if (!res.body) {
return Promise.reject(res)
if (!res.ok) {
const error = new ChatError(`Remote sent ${res.status} response`, res)
error.cause = new Error(await res.text())
}
if (!res.body) {
throw new ChatError('Response body is empty from remote server', res)
}
if (!(res.body instanceof ReadableStream)) {
const error = new ChatError(`Expected Response body to be a ReadableStream, but got ${String(res.body)}`, res)
error.cause = new Error(`Content-Type is ${res.headers.get('Content-Type')}`)
}

const decoder = new TextDecoder()

let finishReason: string | undefined
let usage: StreamTextResponseUsage | undefined
let buffer = ''

const rawChunkStream = res.body.pipeThrough(new TransformStream({
transform: (chunk, controller) => {
buffer += decoder.decode(chunk)
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''

for (const line of lines) {
// Some cases:
// - Empty chunk
// - :ROUTER PROCESSING from OpenRouter
if (!line || !line.startsWith(dataHeaderPrefix)) {
continue
}

if (line.startsWith(dataErrorPrefix)) {
// About controller error: https://developer.mozilla.org/en-US/docs/Web/API/TransformStreamDefaultController/error
controller.error(new Error(`Error from server: ${line}`))
break
}

const lineWithoutPrefix = line.slice(dataHeaderPrefix.length)
if (lineWithoutPrefix === '[DONE]') {
controller.terminate()
break
}

const data: StreamTextResponse = JSON.parse(lineWithoutPrefix)
controller.enqueue(data)

if (data.choices[0].finish_reason) {
finishReason = data.choices[0].finish_reason
}
if (data.usage) {
usage = data.usage
}
}
},
}))

const decoder = new TextDecoder()

let finishReason: string | undefined
let usage: StreamTextResponseUsage | undefined
let buffer = ''

const rawChunkStream = res.body.pipeThrough(new TransformStream({
transform: (chunk, controller) => {
buffer += decoder.decode(chunk)
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''

for (const line of lines) {
// Some cases:
// - Empty chunk
// - :ROUTER PROCESSING from OpenRouter
if (!line || !line.startsWith(dataHeaderPrefix)) {
continue
}

if (line.startsWith(dataErrorPrefix)) {
// About controller error: https://developer.mozilla.org/en-US/docs/Web/API/TransformStreamDefaultController/error
controller.error(new Error(`Error from server: ${line}`))
continue
}

const lineWithoutPrefix = line.slice(dataHeaderPrefix.length)
if (lineWithoutPrefix === '[DONE]') {
controller.terminate()
continue
}

const data: StreamTextResponse = JSON.parse(lineWithoutPrefix)
controller.enqueue(data)

if (data.choices[0].finish_reason) {
finishReason = data.choices[0].finish_reason
}
if (data.usage) {
usage = data.usage
}
}
},
}))

const [chunkStream, rawTextStream] = rawChunkStream.tee()

const textStream = rawTextStream.pipeThrough(new TransformStream({
transform: (chunk, controller) => controller.enqueue(chunk.choices[0].delta.content),
}))

return { chunkStream, finishReason, textStream, usage }
})
const [chunkStream, rawTextStream] = rawChunkStream.tee()

const textStream = rawTextStream.pipeThrough(new TransformStream({
transform: (chunk, controller) => controller.enqueue(chunk.choices[0].delta.content),
}))

return { chunkStream, finishReason, textStream, usage }
}

export default streamText
2 changes: 2 additions & 0 deletions packages/stream-text/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,6 @@ describe('@xsai/stream-text', () => {

expect(chunk).toMatchSnapshot()
})

// TODO: error handling
})

0 comments on commit a84dc89

Please sign in to comment.