Skip to content

Commit 19ba330

Browse files
committed
feat: Add vitest tests and zod validation for streaming responses
1 parent d97d1f6 commit 19ba330

7 files changed

Lines changed: 303 additions & 130 deletions

File tree

bun.lock

Lines changed: 73 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"github-copilot",
88
"openai-compatible"
99
],
10-
"homepage": "https://github.com/ericc-ch/copilot-api",
1110
"bugs": "https://github.com/ericc-ch/copilot-api/issues",
11+
"homepage": "https://github.com/ericc-ch/copilot-api",
1212
"repository": {
1313
"type": "git",
1414
"url": "git+https://github.com/ericc-ch/copilot-api.git"
@@ -29,7 +29,8 @@
2929
"prepack": "bun run build",
3030
"prepare": "simple-git-hooks",
3131
"release": "bumpp && bun publish --access public",
32-
"start": "NODE_ENV=production bun run ./src/main.ts"
32+
"start": "NODE_ENV=production bun run ./src/main.ts",
33+
"test": "vitest"
3334
},
3435
"simple-git-hooks": {
3536
"pre-commit": "bunx lint-staged"
@@ -45,17 +46,20 @@
4546
"hono": "^4.7.2",
4647
"ofetch": "^1.4.1",
4748
"pathe": "^2.0.3",
48-
"srvx": "^0.1.4"
49+
"srvx": "^0.1.4",
50+
"zod": "^3.24.2"
4951
},
5052
"devDependencies": {
5153
"@echristian/eslint-config": "^0.0.23",
52-
"@types/bun": "^1.2.3",
54+
"@types/bun": "^1.2.4",
5355
"bumpp": "^10.0.3",
5456
"eslint": "^9.21.0",
5557
"knip": "^5.45.0",
5658
"lint-staged": "^15.4.3",
5759
"simple-git-hooks": "^2.11.1",
60+
"tinyexec": "^0.3.2",
5861
"tsup": "^8.4.0",
59-
"typescript": "^5.7.3"
62+
"typescript": "^5.8.2",
63+
"vitest": "^3.0.7"
6064
}
6165
}

src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import { logger } from "./lib/logger"
99
import { initializePort } from "./lib/port"
1010
import { server } from "./server"
1111

12-
async function runServer(options: {
12+
export async function runServer(options: {
1313
port: number
1414
verbose: boolean
1515
logFile?: string
16-
}) {
16+
}): Promise<void> {
1717
if (options.verbose) {
1818
consola.level = 5
1919
consola.info("Verbose logging enabled")

src/routes/chat-completions/handler.ts

Lines changed: 60 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -37,74 +37,54 @@ function createCondensedStreamingResponse(
3737
}
3838
}
3939

40-
export async function handlerStreaming(c: Context) {
41-
const models = modelsCache.getModels()
42-
let payload = await c.req.json<ChatCompletionsPayload>()
43-
44-
if (isNullish(payload.max_tokens)) {
45-
const selectedModel = models?.data.find(
46-
(model) => model.id === payload.model,
47-
)
48-
49-
payload = {
50-
...payload,
51-
max_tokens: selectedModel?.capabilities.limits.max_output_tokens,
52-
}
53-
}
54-
55-
// Convert request headers to a regular object from Headers
56-
const requestHeaders = c.req.header()
57-
58-
// Log the request at the beginning for both streaming and non-streaming cases
59-
await logger.logRequest("/chat/completions", "POST", payload, requestHeaders)
60-
61-
if (payload.stream) {
40+
function handleStreaming(c: Context, payload: ChatCompletionsPayload) {
41+
return streamSSE(c, async (stream) => {
6242
const response = await chatCompletionsStream(payload)
6343

6444
// For collecting the complete streaming response
6545
let collectedContent = ""
6646
let finalChunk: ChatCompletionChunk | null = null
6747

68-
return streamSSE(c, async (stream) => {
69-
for await (const chunk of response) {
70-
await stream.writeSSE(chunk as SSEMessage)
48+
for await (const chunk of response) {
49+
await stream.writeSSE(chunk as SSEMessage)
7150

72-
if (!logger.options.enabled) continue
51+
if (!logger.options.enabled) continue
7352

74-
// Check if chunk data is "DONE" or not a valid JSON string
75-
if (!chunk.data || chunk.data === "[DONE]") {
76-
continue // Skip processing this chunk for logging
77-
}
53+
// Check if chunk data is "DONE" or not a valid JSON string
54+
if (!chunk.data || chunk.data === "[DONE]") {
55+
continue // Skip processing this chunk for logging
56+
}
7857

79-
try {
80-
const data = JSON.parse(chunk.data) as ChatCompletionChunk
58+
try {
59+
const data = JSON.parse(chunk.data) as ChatCompletionChunk
8160

82-
// Keep track of the latest chunk for metadata
83-
finalChunk = data
61+
// Keep track of the latest chunk for metadata
62+
finalChunk = data
8463

85-
// Accumulate content from each delta
86-
if (typeof data.choices[0].delta.content === "string") {
87-
collectedContent += data.choices[0].delta.content
88-
}
89-
} catch (error) {
90-
// Handle JSON parsing errors gracefully
91-
consola.error(`Error parsing SSE chunk data`, error)
92-
// Continue processing other chunks
64+
// Accumulate content from each delta
65+
if (typeof data.choices[0].delta.content === "string") {
66+
collectedContent += data.choices[0].delta.content
9367
}
68+
} catch (error) {
69+
// Handle JSON parsing errors gracefully
70+
consola.error(`Error parsing SSE chunk data`, error)
71+
// Continue processing other chunks
9472
}
73+
}
9574

96-
// After streaming completes, log the condensed response
97-
if (finalChunk) {
98-
const condensedResponse = createCondensedStreamingResponse(
99-
finalChunk,
100-
collectedContent,
101-
)
75+
// After streaming completes, log the condensed response
76+
if (finalChunk) {
77+
const condensedResponse = createCondensedStreamingResponse(
78+
finalChunk,
79+
collectedContent,
80+
)
10281

103-
await logger.logResponse("/chat/completions", condensedResponse, {})
104-
}
105-
})
106-
}
82+
await logger.logResponse("/chat/completions", condensedResponse, {})
83+
}
84+
})
85+
}
10786

87+
async function handleNonStreaming(c: Context, payload: ChatCompletionsPayload) {
10888
const response = await chatCompletions(payload)
10989

11090
// Get response headers if any
@@ -115,3 +95,31 @@ export async function handlerStreaming(c: Context) {
11595

11696
return c.json(response)
11797
}
98+
99+
export async function handleCompletion(c: Context) {
100+
const models = modelsCache.getModels()
101+
let payload = await c.req.json<ChatCompletionsPayload>()
102+
103+
if (isNullish(payload.max_tokens)) {
104+
const selectedModel = models?.data.find(
105+
(model) => model.id === payload.model,
106+
)
107+
108+
payload = {
109+
...payload,
110+
max_tokens: selectedModel?.capabilities.limits.max_output_tokens,
111+
}
112+
}
113+
114+
// Convert request headers to a regular object from Headers
115+
const requestHeaders = c.req.header()
116+
117+
// Log the request at the beginning for both streaming and non-streaming cases
118+
await logger.logRequest("/chat/completions", "POST", payload, requestHeaders)
119+
120+
if (payload.stream) {
121+
return handleStreaming(c, payload)
122+
}
123+
124+
return handleNonStreaming(c, payload)
125+
}

src/routes/chat-completions/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { Hono, type Context } from "hono"
66
import { FetchError } from "ofetch"
77

88
import { logger } from "../../lib/logger"
9-
import { handlerStreaming } from "./handler"
9+
import { handleCompletion } from "./handler"
1010

1111
export const completionRoutes = new Hono()
1212

1313
completionRoutes.post("/", async (c) => {
1414
try {
15-
return await handlerStreaming(c)
15+
return await handleCompletion(c)
1616
} catch (error) {
1717
consola.error("Error occurred:", error)
1818
return handleError(c, error)
Lines changed: 73 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,73 @@
1-
interface ContentFilterResults {
2-
error: {
3-
code: string
4-
message: string
5-
}
6-
hate: {
7-
filtered: boolean
8-
severity: string
9-
}
10-
self_harm: {
11-
filtered: boolean
12-
severity: string
13-
}
14-
sexual: {
15-
filtered: boolean
16-
severity: string
17-
}
18-
violence: {
19-
filtered: boolean
20-
severity: string
21-
}
22-
}
23-
24-
interface ContentFilterOffsets {
25-
check_offset: number
26-
start_offset: number
27-
end_offset: number
28-
}
29-
30-
interface Delta {
31-
content?: string
32-
role?: string
33-
}
34-
35-
interface Choice {
36-
index: number
37-
content_filter_offsets?: ContentFilterOffsets
38-
content_filter_results?: ContentFilterResults
39-
delta: Delta
40-
finish_reason?: string | null
41-
}
42-
43-
interface PromptFilterResult {
44-
content_filter_results: ContentFilterResults
45-
prompt_index: number
46-
}
47-
48-
interface Usage {
49-
completion_tokens: number
50-
prompt_tokens: number
51-
total_tokens: number
52-
}
53-
54-
export interface ChatCompletionChunk {
55-
choices: [Choice]
56-
created: number
57-
object: "chat.completion.chunk"
58-
id: string
59-
model: string
60-
system_fingerprint?: string
61-
prompt_filter_results?: Array<PromptFilterResult>
62-
usage?: Usage | null
63-
}
1+
import * as z from "zod"
2+
3+
const ContentFilterResultsSchema = z.object({
4+
error: z.object({
5+
code: z.string(),
6+
message: z.string(),
7+
}),
8+
hate: z.object({
9+
filtered: z.boolean(),
10+
severity: z.string(),
11+
}),
12+
self_harm: z.object({
13+
filtered: z.boolean(),
14+
severity: z.string(),
15+
}),
16+
sexual: z.object({
17+
filtered: z.boolean(),
18+
severity: z.string(),
19+
}),
20+
violence: z.object({
21+
filtered: z.boolean(),
22+
severity: z.string(),
23+
}),
24+
})
25+
26+
const ContentFilterOffsetsSchema = z.object({
27+
check_offset: z.number(),
28+
start_offset: z.number(),
29+
end_offset: z.number(),
30+
})
31+
32+
const DeltaSchema = z.object({
33+
content: z.string().optional(),
34+
role: z.string().optional(),
35+
})
36+
37+
const ChoiceSchema = z.object({
38+
index: z.number(),
39+
content_filter_offsets: ContentFilterOffsetsSchema.optional(),
40+
content_filter_results: ContentFilterResultsSchema.optional(),
41+
delta: DeltaSchema,
42+
finish_reason: z.string().nullable().optional(),
43+
})
44+
45+
const PromptFilterResultSchema = z.object({
46+
content_filter_results: ContentFilterResultsSchema,
47+
prompt_index: z.number(),
48+
})
49+
50+
const UsageSchema = z.object({
51+
completion_tokens: z.number(),
52+
prompt_tokens: z.number(),
53+
total_tokens: z.number(),
54+
})
55+
56+
export const ChatCompletionChunkSchema = z.object({
57+
choices: z.array(ChoiceSchema),
58+
created: z.number(),
59+
object: z.literal("chat.completion.chunk"),
60+
id: z.string(),
61+
model: z.string(),
62+
system_fingerprint: z.string().optional(),
63+
prompt_filter_results: z.array(PromptFilterResultSchema).optional(),
64+
usage: UsageSchema.nullable().optional(),
65+
})
66+
67+
export type ContentFilterResults = z.infer<typeof ContentFilterResultsSchema>
68+
export type ContentFilterOffsets = z.infer<typeof ContentFilterOffsetsSchema>
69+
export type Delta = z.infer<typeof DeltaSchema>
70+
export type Choice = z.infer<typeof ChoiceSchema>
71+
export type PromptFilterResult = z.infer<typeof PromptFilterResultSchema>
72+
export type Usage = z.infer<typeof UsageSchema>
73+
export type ChatCompletionChunk = z.infer<typeof ChatCompletionChunkSchema>

0 commit comments

Comments
 (0)