Skip to content

Commit b2544b8

Browse files
committed
perf: reuse OpenAI client and add undici keep-alive Agent with connection warmup
Extract OpenAI client creation logic into src/common/openai-client.ts: - Custom undici Agent with 60s keepAlive timeout (default is 4s) - Module-level client instance cache (reuse across calls) - Fire-and-forget connection warmup on first creation (3s timeout) - getMachineId() helper The App.tsx now simply imports and re-exports createOpenAIClient from the new common module, keeping UI concerns separate from HTTP/client lifecycle management.
1 parent a99b0bd commit b2544b8

5 files changed

Lines changed: 138 additions & 65 deletions

File tree

package-lock.json

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

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"ink-gradient": "^4.0.0",
4949
"openai": "^6.35.0",
5050
"react": "^19.2.5",
51+
"undici": "^7.25.0",
5152
"zod": "^4.4.3"
5253
},
5354
"devDependencies": {

src/common/openai-client.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import * as fs from "fs";
2+
import * as os from "os";
3+
import * as path from "path";
4+
import OpenAI from "openai";
5+
import { Agent, fetch as undiciFetch } from "undici";
6+
import { resolveCurrentSettings } from "../ui/App";
7+
8+
// Custom undici Agent with a 60-second keepAlive timeout. The default
9+
// global fetch (undici) only keeps connections alive for 4 seconds, which
10+
// is too short for a CLI where the user may spend 10–30 seconds reading
11+
// output between prompts. By passing a dedicated Agent to undiciFetch we
12+
// keep connections reusable for a full minute after the last request.
13+
const keepAliveAgent = new Agent({ keepAliveTimeout: 60_000 });
14+
15+
// Module-level cache for the OpenAI client instance. The client itself is
16+
// a stateless fetch wrapper, so it is safe to share across calls as long as
17+
// the apiKey + baseURL stay the same. Model, thinking-mode and other
18+
// settings are always read fresh from the project / user config files.
19+
let cachedOpenAI: OpenAI | null = null;
20+
let cachedOpenAIKey = "";
21+
22+
export function createOpenAIClient(projectRoot: string = process.cwd()): {
23+
client: OpenAI | null;
24+
model: string;
25+
baseURL: string;
26+
thinkingEnabled: boolean;
27+
reasoningEffort: "high" | "max";
28+
debugLogEnabled: boolean;
29+
notify?: string;
30+
webSearchTool?: string;
31+
env: Record<string, string>;
32+
machineId?: string;
33+
} {
34+
const settings = resolveCurrentSettings(projectRoot);
35+
if (!settings.apiKey) {
36+
return {
37+
client: null,
38+
model: settings.model,
39+
baseURL: settings.baseURL,
40+
thinkingEnabled: settings.thinkingEnabled,
41+
reasoningEffort: settings.reasoningEffort,
42+
debugLogEnabled: settings.debugLogEnabled,
43+
notify: settings.notify,
44+
webSearchTool: settings.webSearchTool,
45+
env: settings.env,
46+
machineId: getMachineId(),
47+
};
48+
}
49+
50+
const cacheKey = `${settings.apiKey}::${settings.baseURL}`;
51+
if (cachedOpenAI && cachedOpenAIKey === cacheKey) {
52+
return {
53+
client: cachedOpenAI,
54+
model: settings.model,
55+
baseURL: settings.baseURL,
56+
thinkingEnabled: settings.thinkingEnabled,
57+
reasoningEffort: settings.reasoningEffort,
58+
debugLogEnabled: settings.debugLogEnabled,
59+
notify: settings.notify,
60+
webSearchTool: settings.webSearchTool,
61+
env: settings.env,
62+
machineId: getMachineId(),
63+
};
64+
}
65+
66+
cachedOpenAI = new OpenAI({
67+
apiKey: settings.apiKey,
68+
baseURL: settings.baseURL || undefined,
69+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
70+
fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }),
71+
});
72+
cachedOpenAIKey = cacheKey;
73+
74+
// Fire-and-forget warmup: pre-establish TCP+TLS connection to the API
75+
// server while the user is composing their first prompt. Bounded by a
76+
// short timeout so a slow / unreachable API never blocks process exit.
77+
void (async () => {
78+
const ac = new AbortController();
79+
const timer = setTimeout(() => ac.abort(), 3000);
80+
try {
81+
await cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {});
82+
} finally {
83+
clearTimeout(timer);
84+
}
85+
})();
86+
87+
return {
88+
client: cachedOpenAI,
89+
model: settings.model,
90+
baseURL: settings.baseURL,
91+
thinkingEnabled: settings.thinkingEnabled,
92+
reasoningEffort: settings.reasoningEffort,
93+
debugLogEnabled: settings.debugLogEnabled,
94+
notify: settings.notify,
95+
webSearchTool: settings.webSearchTool,
96+
env: settings.env,
97+
machineId: getMachineId(),
98+
};
99+
}
100+
101+
function getMachineId(): string | undefined {
102+
try {
103+
const idPath = path.join(os.homedir(), ".deepcode", "machine-id");
104+
if (fs.existsSync(idPath)) {
105+
const raw = fs.readFileSync(idPath, "utf8").trim();
106+
if (raw) {
107+
return raw;
108+
}
109+
}
110+
const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`;
111+
fs.mkdirSync(path.dirname(idPath), { recursive: true });
112+
fs.writeFileSync(idPath, generated, "utf8");
113+
return generated;
114+
} catch {
115+
return undefined;
116+
}
117+
}

src/ui/App.tsx

Lines changed: 9 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import chalk from "chalk";
44
import * as fs from "fs";
55
import * as os from "os";
66
import * as path from "path";
7-
import OpenAI from "openai";
7+
import { createOpenAIClient } from "../common/openai-client";
88
import {
99
type LlmStreamProgress,
1010
type MessageMeta,
@@ -166,6 +166,13 @@ export function App({ projectRoot, initialPrompt, onRestart }: AppProps): React.
166166
void refreshSkills();
167167
}, [refreshSessionsList, refreshSkills]);
168168

169+
// Eagerly create the OpenAI client on mount so the TCP+TLS connection
170+
// warmup (fire-and-forget inside createOpenAIClient) starts before the
171+
// user sends their first prompt.
172+
useEffect(() => {
173+
createOpenAIClient(projectRoot);
174+
}, [projectRoot]);
175+
169176
useLayoutEffect(() => {
170177
const settings = resolveCurrentSettings(projectRoot);
171178
void sessionManager.initMcpServers(settings.mcpServers);
@@ -838,69 +845,7 @@ export function resolveCurrentSettings(projectRoot: string = process.cwd()): Res
838845
);
839846
}
840847

841-
export function createOpenAIClient(projectRoot: string = process.cwd()): {
842-
client: OpenAI | null;
843-
model: string;
844-
baseURL: string;
845-
thinkingEnabled: boolean;
846-
reasoningEffort: "high" | "max";
847-
debugLogEnabled: boolean;
848-
notify?: string;
849-
webSearchTool?: string;
850-
env: Record<string, string>;
851-
machineId?: string;
852-
} {
853-
const settings = resolveCurrentSettings(projectRoot);
854-
if (!settings.apiKey) {
855-
return {
856-
client: null,
857-
model: settings.model,
858-
baseURL: settings.baseURL,
859-
thinkingEnabled: settings.thinkingEnabled,
860-
reasoningEffort: settings.reasoningEffort,
861-
debugLogEnabled: settings.debugLogEnabled,
862-
notify: settings.notify,
863-
webSearchTool: settings.webSearchTool,
864-
env: settings.env,
865-
machineId: getMachineId(),
866-
};
867-
}
868-
869-
const client = new OpenAI({
870-
apiKey: settings.apiKey,
871-
baseURL: settings.baseURL || undefined,
872-
});
873-
return {
874-
client,
875-
model: settings.model,
876-
baseURL: settings.baseURL,
877-
thinkingEnabled: settings.thinkingEnabled,
878-
reasoningEffort: settings.reasoningEffort,
879-
debugLogEnabled: settings.debugLogEnabled,
880-
notify: settings.notify,
881-
webSearchTool: settings.webSearchTool,
882-
env: settings.env,
883-
machineId: getMachineId(),
884-
};
885-
}
886-
887-
function getMachineId(): string | undefined {
888-
try {
889-
const idPath = path.join(os.homedir(), ".deepcode", "machine-id");
890-
if (fs.existsSync(idPath)) {
891-
const raw = fs.readFileSync(idPath, "utf8").trim();
892-
if (raw) {
893-
return raw;
894-
}
895-
}
896-
const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`;
897-
fs.mkdirSync(path.dirname(idPath), { recursive: true });
898-
fs.writeFileSync(idPath, generated, "utf8");
899-
return generated;
900-
} catch {
901-
return undefined;
902-
}
903-
}
848+
export { createOpenAIClient } from "../common/openai-client";
904849

905850
function getUserSettingsPath(): string {
906851
return path.join(os.homedir(), ".deepcode", "settings.json");

src/ui/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ export {
1111
writeProjectSettings,
1212
writeModelConfigSelection,
1313
resolveCurrentSettings,
14-
createOpenAIClient,
1514
buildPromptDraftFromSessionMessage,
1615
} from "./App";
16+
export { createOpenAIClient } from "../common/openai-client";
1717
export { default as AppContainer } from "./AppContainer";
1818
export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt";
1919
export { MessageView } from "./components";

0 commit comments

Comments
 (0)