diff --git a/packages/core/src/common/openai-client.ts b/packages/core/src/common/openai-client.ts index d3b56c08..670ec536 100644 --- a/packages/core/src/common/openai-client.ts +++ b/packages/core/src/common/openai-client.ts @@ -51,7 +51,7 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { }; } - const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + const cacheKey = `${settings.apiKey}::${settings.baseURL}::${settings.timeoutMs ?? ""}::${settings.maxRetries ?? ""}`; if (cachedOpenAI && cachedOpenAIKey === cacheKey) { return { client: cachedOpenAI, @@ -72,6 +72,8 @@ export function createOpenAIClient(projectRoot: string = process.cwd()): { cachedOpenAI = new OpenAI({ apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, + timeout: settings.timeoutMs, + maxRetries: settings.maxRetries, // eslint-disable-next-line @typescript-eslint/no-explicit-any fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }), }); diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index da0e2d94..ca3335dc 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -86,6 +86,8 @@ export type DeepcodingSettings = { autoCompactWindow?: number | string; model?: string; temperature?: number; + timeoutMs?: number; + maxRetries?: number; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; @@ -106,6 +108,8 @@ export type ResolvedDeepcodingSettings = { contextWindow: number; autoCompactWindow: number; temperature?: number; + timeoutMs?: number; + maxRetries?: number; thinkingEnabled: boolean; reasoningEffort: ReasoningEffort; debugLogEnabled: boolean; @@ -199,6 +203,52 @@ function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function parsePositiveInt(value: unknown): number | undefined { + if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value.trim()); + if (Number.isSafeInteger(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} + +function parseNonNegativeInt(value: unknown): number | undefined { + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value.trim()); + if (Number.isSafeInteger(parsed) && parsed >= 0) { + return parsed; + } + } + return undefined; +} + +function firstPositiveInt(...values: unknown[]): number | undefined { + for (const value of values) { + const parsed = parsePositiveInt(value); + if (parsed !== undefined) { + return parsed; + } + } + return undefined; +} + +function firstNonNegativeInt(...values: unknown[]): number | undefined { + for (const value of values) { + const parsed = parseNonNegativeInt(value); + if (parsed !== undefined) { + return parsed; + } + } + return undefined; +} + const VALID_PERMISSION_SCOPES = new Set([ "read-in-cwd", "read-out-cwd", @@ -561,6 +611,9 @@ export function resolveSettingsSources( parseTemperature(userSettings?.temperature) ?? parseTemperature(userEnv.TEMPERATURE); + const timeoutMs = firstPositiveInt(systemEnv.TIMEOUT_MS, projectSettings?.timeoutMs, userSettings?.timeoutMs); + const maxRetries = firstNonNegativeInt(systemEnv.MAX_RETRIES, projectSettings?.maxRetries, userSettings?.maxRetries); + const debugLogEnabled = parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? parseBoolean(projectSettings?.debugLogEnabled) ?? @@ -593,6 +646,8 @@ export function resolveSettingsSources( contextWindow, autoCompactWindow, temperature, + timeoutMs, + maxRetries, thinkingEnabled, reasoningEffort, debugLogEnabled, diff --git a/packages/core/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts index 93e8dc29..96e13eb0 100644 --- a/packages/core/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -11,6 +11,26 @@ import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } fr const TEST_PROCESS_ENV = {}; +test("resolveSettings reads timeoutMs and maxRetries (#13)", () => { + const resolved = resolveSettings( + { timeoutMs: 120000, maxRetries: 3, env: { API_KEY: "sk-test" } }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.timeoutMs, 120000); + assert.equal(resolved.maxRetries, 3); +}); + +test("resolveSettings prefers env TIMEOUT_MS and MAX_RETRIES over settings (#13)", () => { + const resolved = resolveSettings( + { timeoutMs: 120000, maxRetries: 3, env: { API_KEY: "sk-test" } }, + { model: "default-model", baseURL: "https://default.example.com" }, + { DEEPCODE_TIMEOUT_MS: "90000", DEEPCODE_MAX_RETRIES: "5" } + ); + assert.equal(resolved.timeoutMs, 90000); + assert.equal(resolved.maxRetries, 5); +}); + test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( {