Skip to content

Commit 0e5e062

Browse files
Pablo Vitassoclaude
andcommitted
refactor(api): rewrite undici fetch wrapper with proper types
- Use Dispatcher.HttpMethod for method typing - Properly narrow body type for undici request - Fix import typo (installUndisciFetchWrapper -> installUndiciFetchWrapper) - Add clone(): never return type for FetchResponse Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ab73d84 commit 0e5e062

3 files changed

Lines changed: 65 additions & 76 deletions

File tree

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { ModelInfo } from "@klaus-code/types"
55

66
// Install undici-based fetch wrapper when using OpenAI-compatible providers
77
// This fixes connection issues with localhost on Node.js 20+
8-
import { installUndisciFetchWrapper } from "../utils/undici-fetch-wrapper"
9-
installUndisciFetchWrapper()
8+
import { installUndiciFetchWrapper } from "../utils/undici-fetch-wrapper"
9+
installUndiciFetchWrapper()
1010

1111
import { type ApiHandlerOptions, getModelMaxOutputTokens } from "../../shared/api"
1212
import { TagMatcher } from "../../utils/tag-matcher"

src/api/providers/router-provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { type ModelInfo, type ModelRecord } from "@klaus-code/types"
44

55
// Install undici-based fetch wrapper when using router-based providers
66
// This fixes connection issues with localhost on Node.js 20+
7-
import { installUndisciFetchWrapper } from "../utils/undici-fetch-wrapper"
8-
installUndisciFetchWrapper()
7+
import { installUndiciFetchWrapper } from "../utils/undici-fetch-wrapper"
8+
installUndiciFetchWrapper()
99

1010
import { ApiHandlerOptions, RouterName } from "../../shared/api"
1111

Lines changed: 61 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,35 @@
11
/**
2-
* Wrapper to fix OpenAI SDK connection issues with Node.js 20+ and localhost
3-
*
4-
* Problem: Node.js 20+ uses undici for fetch(), which has bugs with localhost connections.
5-
* Both native fetch() and OpenAI SDK fail silently on localhost in streaming mode.
6-
*
7-
* Solution: Use undici.Client directly instead of fetch(), which works correctly.
8-
* This wrapper replaces global fetch with an undici-based implementation.
2+
* Undici-based fetch wrapper for fixing Node.js 20+ localhost streaming issues
93
*/
104

11-
import * as undici from "undici"
5+
import { Client, Dispatcher } from "undici"
126

13-
const clientCache = new Map<string, undici.Client>()
7+
const clientCache = new Map<string, Client>()
148

15-
function getOrCreateClient(baseURL: string): undici.Client {
9+
function getOrCreateClient(baseURL: string): Client {
1610
if (!clientCache.has(baseURL)) {
17-
clientCache.set(baseURL, new undici.Client(baseURL))
11+
clientCache.set(baseURL, new Client(baseURL))
1812
}
1913
return clientCache.get(baseURL)!
2014
}
2115

2216
interface FetchHeaders {
23-
[key: string]: string | string[]
17+
[key: string]: string | string[] | undefined
2418
}
2519

26-
/**
27-
* Wrapper for headers that implements the Headers interface
28-
*/
2920
class HeadersWrapper {
30-
private headersMap: Map<string, string>
21+
private headersMap = new Map<string, string>()
3122

3223
constructor(headers: FetchHeaders) {
33-
this.headersMap = new Map()
3424
for (const [key, value] of Object.entries(headers)) {
25+
if (value === undefined) continue
3526
const headerValue = Array.isArray(value) ? value.join(",") : String(value)
3627
this.headersMap.set(key.toLowerCase(), headerValue)
3728
}
3829
}
3930

4031
get(name: string): string | null {
41-
return this.headersMap.get(name.toLowerCase()) || null
32+
return this.headersMap.get(name.toLowerCase()) ?? null
4233
}
4334

4435
has(name: string): boolean {
@@ -49,19 +40,11 @@ class HeadersWrapper {
4940
return this.headersMap.entries()
5041
}
5142

52-
keys(): IterableIterator<string> {
53-
return this.headersMap.keys()
54-
}
55-
56-
values(): IterableIterator<string> {
57-
return this.headersMap.values()
58-
}
59-
6043
[Symbol.iterator](): IterableIterator<[string, string]> {
6144
return this.headersMap.entries()
6245
}
6346

64-
forEach(callback: (value: string, key: string, parent: HeadersWrapper) => void, thisArg?: any): void {
47+
forEach(callback: (value: string, key: string, parent: HeadersWrapper) => void, thisArg?: unknown): void {
6548
this.headersMap.forEach((value, key) => {
6649
callback.call(thisArg, value, key, this)
6750
})
@@ -74,7 +57,7 @@ class FetchResponse {
7457
statusText: string
7558
headers: HeadersWrapper
7659
body: AsyncIterable<Buffer>
77-
bodyUsed: boolean = false
60+
bodyUsed = false
7861

7962
constructor(statusCode: number, headers: FetchHeaders, body: AsyncIterable<Buffer>) {
8063
this.status = statusCode
@@ -84,88 +67,94 @@ class FetchResponse {
8467
this.body = body
8568
}
8669

87-
async json() {
88-
let data = ""
89-
for await (const chunk of this.body) {
90-
data += chunk.toString()
91-
}
92-
return JSON.parse(data)
70+
async json(): Promise<any> {
71+
const text = await this.text()
72+
return JSON.parse(text)
9373
}
9474

95-
async text() {
75+
async text(): Promise<string> {
9676
let data = ""
9777
for await (const chunk of this.body) {
9878
data += chunk.toString()
9979
}
10080
return data
10181
}
10282

103-
async blob() {
83+
async arrayBuffer(): Promise<ArrayBuffer> {
84+
const buffer = await this.blob()
85+
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)
86+
}
87+
88+
async blob(): Promise<Buffer> {
10489
let data = Buffer.alloc(0)
10590
for await (const chunk of this.body) {
10691
data = Buffer.concat([data, chunk])
10792
}
10893
return data
10994
}
11095

111-
async arrayBuffer() {
112-
const blob = await this.blob()
113-
return blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength)
114-
}
115-
116-
clone() {
117-
throw new Error("Response.clone() not implemented in undici wrapper")
96+
clone(): never {
97+
throw new Error("Response.clone() not implemented")
11898
}
11999
}
120100

121101
/**
122-
* Undici-based fetch wrapper that works with OpenAI SDK
102+
* Create undici-based fetch
123103
*/
124-
export function createUndicsiFetch() {
125-
return async function fetch(url: string | URL, options?: RequestInit & { timeout?: number }): Promise<Response> {
104+
export function createUndiciFetch() {
105+
return async function fetch(url: string | URL, options?: RequestInit & { timeout?: number }): Promise<any> {
126106
const urlObj = new URL(url)
127107
const baseURL = `${urlObj.protocol}//${urlObj.host}`
128-
const path = urlObj.pathname + (urlObj.search || "")
108+
const path = urlObj.pathname + (urlObj.search ?? "")
129109

130110
const client = getOrCreateClient(baseURL)
131111

112+
const method: Dispatcher.HttpMethod = (options?.method?.toUpperCase() ?? "GET") as Dispatcher.HttpMethod
113+
114+
const headers = (options?.headers as Record<string, string>) ?? undefined
115+
116+
// Narrow body for undici
117+
let body: string | Buffer | Uint8Array | null | undefined = undefined
118+
119+
if (typeof options?.body === "string") body = options.body
120+
else if (options?.body instanceof Buffer) body = options.body
121+
else if (options?.body instanceof Uint8Array) body = options.body
122+
else if (options?.body == null) body = undefined
123+
else body = String(options.body)
124+
132125
try {
133-
const response = await client.request({
126+
const response: Dispatcher.ResponseData = await client.request({
134127
path,
135-
method: options?.method || "GET",
136-
headers: options?.headers as Record<string, string>,
137-
body: options?.body,
128+
method,
129+
headers,
130+
body,
138131
})
139132

140-
return new FetchResponse(response.statusCode, response.headers as FetchHeaders, response.body) as any
133+
return new FetchResponse(response.statusCode, response.headers as FetchHeaders, response.body)
141134
} catch (error) {
142135
throw new Error(`Fetch failed: ${error instanceof Error ? error.message : String(error)}`)
143136
}
144137
}
145138
}
146139

147140
/**
148-
* Install the undici-based fetch wrapper as global fetch
149-
* Call this at the top of your application initialization
141+
* Install global fetch override
150142
*/
151-
export function installUndisciFetchWrapper() {
152-
if (typeof globalThis !== "undefined") {
153-
// Store original fetch for debugging/fallback
154-
const originalFetch = (globalThis as any).fetch
155-
156-
// Override global fetch
157-
;(globalThis as any).fetch = createUndicsiFetch()
158-
159-
console.log("[undici-fetch-wrapper] Global fetch replaced with undici-based implementation")
160-
161-
// Return cleanup function
162-
return () => {
163-
;(globalThis as any).fetch = originalFetch
164-
// Close all cached clients
165-
for (const client of clientCache.values()) {
166-
client.close().catch(() => {})
167-
}
168-
clientCache.clear()
143+
export function installUndiciFetchWrapper(): (() => void) | void {
144+
if (typeof globalThis === "undefined") {
145+
return
146+
}
147+
148+
const originalFetch = (globalThis as any).fetch
149+
;(globalThis as any).fetch = createUndiciFetch()
150+
151+
console.log("[undici-fetch-wrapper] Global fetch replaced with undici Client implementation")
152+
153+
return () => {
154+
;(globalThis as any).fetch = originalFetch
155+
for (const client of clientCache.values()) {
156+
client.close().catch(() => {})
169157
}
158+
clientCache.clear()
170159
}
171160
}

0 commit comments

Comments
 (0)