diff --git a/.changeset/forward-generation-timeout-to-requests.md b/.changeset/forward-generation-timeout-to-requests.md new file mode 100644 index 00000000..94640cad --- /dev/null +++ b/.changeset/forward-generation-timeout-to-requests.md @@ -0,0 +1,7 @@ +--- +"@open-codesign/desktop": patch +"@open-codesign/core": patch +"@open-codesign/providers": patch +--- + +Apply Settings → Advanced → Generation timeout to each model HTTP request as well as the overall run, so long turns against local LM Studio / Ollama / vLLM endpoints are no longer cut off by the provider SDK's 10-minute default. diff --git a/apps/desktop/src/main/generation-ipc.test.ts b/apps/desktop/src/main/generation-ipc.test.ts index 6bf26206..373708f9 100644 --- a/apps/desktop/src/main/generation-ipc.test.ts +++ b/apps/desktop/src/main/generation-ipc.test.ts @@ -5,6 +5,7 @@ import { armGenerationTimeout, cancelGenerationRequest, extractGenerationTimeoutError, + generationRequestTimeoutMs, listInFlightGenerations, withInFlightGeneration, withInFlightGenerationForDesign, @@ -469,6 +470,18 @@ describe('armGenerationTimeout', () => { }); }); +describe('generationRequestTimeoutMs', () => { + it('matches the configured generation timeout instead of the SDK 10-minute default', () => { + expect(generationRequestTimeoutMs(1200)).toBe(1_200_000); + expect(generationRequestTimeoutMs(7200)).toBe(7_200_000); + }); + + it('uses the largest timer delay when the generation timeout is disabled or huge', () => { + expect(generationRequestTimeoutMs(0)).toBe(2_147_483_647); + expect(generationRequestTimeoutMs(10 ** 9)).toBe(2_147_483_647); + }); +}); + describe('extractGenerationTimeoutError', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/desktop/src/main/generation-ipc.ts b/apps/desktop/src/main/generation-ipc.ts index cf5a1e2a..2a57d2c5 100644 --- a/apps/desktop/src/main/generation-ipc.ts +++ b/apps/desktop/src/main/generation-ipc.ts @@ -104,6 +104,20 @@ export function listInFlightGenerations( .sort((a, b) => a.designId.localeCompare(b.designId)); } +// Node's setTimeout caps delay at int32 (~24.8 days). Larger values overflow +// and fire immediately, which would abort generation instantly. +const TIMEOUT_MAX_MS = 2_147_483_647; + +/** + * Per-request HTTP timeout matching the run-level generation timeout, so the + * provider SDK's 10-minute default no longer cuts long local-model turns. + * A disabled run timeout (`0`) maps to the largest delay timers accept. + */ +export function generationRequestTimeoutMs(timeoutSec: number): number { + if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) return TIMEOUT_MAX_MS; + return Math.min(timeoutSec * 1000, TIMEOUT_MAX_MS); +} + export interface GenerationTimeoutLogger { warn: (event: string, payload: Record) => void; } @@ -146,9 +160,6 @@ export async function armGenerationTimeout( } if (timeoutSec === 0) return () => {}; - // Node's setTimeout caps delay at int32 (~24.8 days). Larger values overflow - // and fire immediately, which would abort generation instantly. - const TIMEOUT_MAX_MS = 2_147_483_647; const ms = Math.min(timeoutSec * 1000, TIMEOUT_MAX_MS); const handle = setTimeout(() => { diff --git a/apps/desktop/src/main/ipc/generate.ts b/apps/desktop/src/main/ipc/generate.ts index cb976e63..189921af 100644 --- a/apps/desktop/src/main/ipc/generate.ts +++ b/apps/desktop/src/main/ipc/generate.ts @@ -42,6 +42,7 @@ import { armGenerationTimeout, cancelGenerationRequest, extractGenerationTimeoutError, + generationRequestTimeoutMs, listInFlightGenerations, withInFlightGenerationForDesign, } from '../generation-ipc'; @@ -706,6 +707,9 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const toolStartedAt = new Map(); const templatesRoot = path_module.join(app.getPath('userData'), 'templates'); const currentWorkspaceRoot = () => requireWorkspaceRootForDesign(designId).workspaceRoot; + const requestTimeoutMs = generationRequestTimeoutMs( + (await readPreferences()).generationTimeoutSec, + ); const [frames, designSkills, initialWorkspaceFiles] = await Promise.all([ loadFrameTemplates(path_module.join(templatesRoot, 'frames')), loadDesignSkills(path_module.join(templatesRoot, 'design-skills')), @@ -804,6 +808,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe const judgeOpts: Parameters[2] = { apiKey: input.apiKey ?? '', maxTokens, + timeoutMs: requestTimeoutMs, userImages, ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}), ...(input.wire ? { wire: input.wire } : {}), @@ -836,6 +841,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe return generateViaAgent( { ...input, + requestTimeoutMs, templatesRoot, askBridge: (askInput, signal) => requestAsk(id, askInput, () => getMainWindow(), { diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 7cc86563..8b9958ea 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -308,7 +308,10 @@ vi.mock('./skills/loader.js', async () => { }; }); +const streamSimpleMock = vi.hoisted(() => vi.fn()); + vi.mock('@mariozechner/pi-ai', () => ({ + streamSimple: streamSimpleMock, getModel: (provider: string, modelId: string) => ({ id: modelId, name: modelId, @@ -614,6 +617,44 @@ describe('generateViaAgent()', () => { ]); }); + it('forwards the request timeout to every pi-ai stream call', async () => { + scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; + await generateViaAgent({ + prompt: 'design a dashboard', + history: [], + model: { provider: 'custom-lmstudio', modelId: 'qwen3.6-35b-a3b' }, + apiKey: 'sk-test', + baseUrl: 'http://127.0.0.1:1234/v1', + wire: 'openai-chat', + requestTimeoutMs: 7_200_000, + }); + + const streamFn = agentCalls[0]?.options.streamFn; + expect(streamFn).toBeDefined(); + const model = agentCalls[0]?.options.initialState?.model; + if (streamFn === undefined || model === undefined) throw new Error('expected streamFn'); + const context = { messages: [] }; + const signal = new AbortController().signal; + streamFn(model, context, { apiKey: 'sk-test', signal }); + expect(streamSimpleMock).toHaveBeenCalledWith(model, context, { + apiKey: 'sk-test', + signal, + timeoutMs: 7_200_000, + }); + }); + + it("keeps pi-agent-core's default stream when no request timeout is configured", async () => { + scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; + await generateViaAgent({ + prompt: 'design a dashboard', + history: [], + model: { provider: 'anthropic', modelId: 'claude-sonnet-4-6' }, + apiKey: 'sk-test', + }); + + expect(agentCalls[0]?.options.streamFn).toBeUndefined(); + }); + it('uses conservative OpenAI-chat compat for DeepInfra agent models', async () => { scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT }; await generateViaAgent({ diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 94f2ab8e..b7d96f55 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -27,10 +27,11 @@ import { type AgentTool, type AgentToolResult, } from '@mariozechner/pi-agent-core'; -import type { - ImageContent as PiAiImageContent, - Message as PiAiMessage, - Model as PiAiModel, +import { + type ImageContent as PiAiImageContent, + type Message as PiAiMessage, + type Model as PiAiModel, + streamSimple, } from '@mariozechner/pi-ai'; import type { RetryDecision, RetryReason } from '@open-codesign/providers'; import { @@ -1287,6 +1288,7 @@ async function generateViaAgentInternal( // original lets the post-agent branch rethrow it as-is, so the renderer // sees the same code the initial IPC-level resolution would emit. let capturedGetApiKeyError: unknown = null; + const requestTimeoutMs = input.requestTimeoutMs; // Factory for creating agents with a given message history. Used for both // the initial agent and retry agents that continue the interrupted transcript. @@ -1342,6 +1344,12 @@ async function generateViaAgentInternal( return initialApiKey || 'open-codesign-keyless'; }, ...(onPayload !== undefined ? { onPayload } : {}), + ...(requestTimeoutMs !== undefined + ? { + streamFn: (model, context, options) => + streamSimple(model, context, { ...options, timeoutMs: requestTimeoutMs }), + } + : {}), }); retryAgent.subscribe((event) => { deps.activeMessages?.handleEvent(event, () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66be4fad..aec17b70 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -270,6 +270,12 @@ export interface GenerateInput { */ mode?: Extract | undefined; signal?: AbortSignal | undefined; + /** + * Per-HTTP-request timeout forwarded to pi-ai's `timeoutMs`. Without it the + * OpenAI / Anthropic SDKs cut every request at their 10-minute default, + * regardless of the run-level generation timeout. + */ + requestTimeoutMs?: number | undefined; onRetry?: ((info: RetryReason) => void) | undefined; logger?: CoreLogger | undefined; /** diff --git a/packages/providers/src/index.test.ts b/packages/providers/src/index.test.ts index 4c42f7c1..6293bca8 100644 --- a/packages/providers/src/index.test.ts +++ b/packages/providers/src/index.test.ts @@ -602,6 +602,28 @@ describe('complete', () => { ); }); + it('forwards the per-request timeout to pi-ai', async () => { + completeSimpleMock.mockResolvedValueOnce({ + content: [{ type: 'text', text: 'OK' }], + stopReason: 'stop', + usage: { input: 1, output: 1, cost: { total: 0 } }, + }); + + await complete( + { provider: 'custom-lmstudio', modelId: 'qwen3.6-35b-a3b' }, + [{ role: 'user', content: 'Reply OK' }], + { + apiKey: '', + allowKeyless: true, + wire: 'openai-chat', + baseUrl: 'http://127.0.0.1:1234/v1', + timeoutMs: 7_200_000, + }, + ); + + expect(completeSimpleMock.mock.calls[0]?.[2].timeoutMs).toBe(7_200_000); + }); + it('omits pi-ai reasoning option when caller explicitly sets reasoning off', async () => { getModelMock.mockReturnValue(undefined); completeSimpleMock.mockImplementationOnce(async (_model, _context, opts) => { diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 1c9055dc..b36755be 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -56,6 +56,8 @@ export interface GenerateOptions { * placeholder while auth is supplied by `httpHeaders` or by the gateway. */ allowKeyless?: boolean; + /** Per-HTTP-request timeout forwarded to pi-ai; SDKs default to 10 minutes. */ + timeoutMs?: number; } export interface GenerateResult { @@ -389,6 +391,7 @@ export async function complete( reasoning?: PiReasoningLevel; headers?: Record; onPayload?: (payload: unknown) => unknown; + timeoutMs?: number; }, ) => Promise; }; @@ -417,12 +420,14 @@ export async function complete( reasoning?: PiReasoningLevel; headers?: Record; onPayload?: (payload: unknown) => unknown; + timeoutMs?: number; } = { apiKey, }; if (opts.baseUrl !== undefined) piOpts.baseUrl = opts.baseUrl; if (opts.signal !== undefined) piOpts.signal = opts.signal; if (opts.maxTokens !== undefined) piOpts.maxTokens = opts.maxTokens; + if (opts.timeoutMs !== undefined) piOpts.timeoutMs = opts.timeoutMs; const reasoning = opts.reasoning ?? requiredReasoningDefault(effectiveModelId); if (reasoning !== undefined && reasoning !== 'off') piOpts.reasoning = reasoning; if (opts.httpHeaders !== undefined) piOpts.headers = { ...opts.httpHeaders }; diff --git a/packages/providers/src/request-timeout.test.ts b/packages/providers/src/request-timeout.test.ts new file mode 100644 index 00000000..47415172 --- /dev/null +++ b/packages/providers/src/request-timeout.test.ts @@ -0,0 +1,46 @@ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { complete } from './index'; + +let server: Server | undefined; + +afterEach(async () => { + server?.closeAllConnections(); + await new Promise((resolve) => server?.close(() => resolve()) ?? resolve()); + server = undefined; +}); + +async function startStalledOpenAIServer(): Promise<{ baseUrl: string; requests: () => number }> { + let requests = 0; + server = createServer(() => { + requests += 1; + }); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { baseUrl: `http://127.0.0.1:${port}/v1`, requests: () => requests }; +} + +describe('complete request timeout (#199)', () => { + it('applies timeoutMs to the provider SDK HTTP request instead of its 10-minute default', async () => { + const stalled = await startStalledOpenAIServer(); + const started = Date.now(); + + await expect( + complete( + { provider: 'custom-lmstudio', modelId: 'local-model' }, + [{ role: 'user', content: 'Reply OK' }], + { + apiKey: '', + allowKeyless: true, + wire: 'openai-chat', + baseUrl: stalled.baseUrl, + timeoutMs: 200, + }, + ), + ).rejects.toThrow(/timed out/i); + + expect(stalled.requests()).toBeGreaterThan(0); + expect(Date.now() - started).toBeLessThan(15_000); + }, 20_000); +});