Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/forward-generation-timeout-to-requests.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions apps/desktop/src/main/generation-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
generationRequestTimeoutMs,
listInFlightGenerations,
withInFlightGeneration,
withInFlightGenerationForDesign,
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 14 additions & 3 deletions apps/desktop/src/main/generation-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => void;
}
Expand Down Expand Up @@ -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(() => {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
generationRequestTimeoutMs,
listInFlightGenerations,
withInFlightGenerationForDesign,
} from '../generation-ipc';
Expand Down Expand Up @@ -706,6 +707,9 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
const toolStartedAt = new Map<string, number>();
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')),
Expand Down Expand Up @@ -804,6 +808,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
const judgeOpts: Parameters<typeof complete>[2] = {
apiKey: input.apiKey ?? '',
maxTokens,
timeoutMs: requestTimeoutMs,
userImages,
...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
...(input.wire ? { wire: input.wire } : {}),
Expand Down Expand Up @@ -836,6 +841,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
return generateViaAgent(
{
...input,
requestTimeoutMs,
templatesRoot,
askBridge: (askInput, signal) =>
requestAsk(id, askInput, () => getMainWindow(), {
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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, () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,12 @@ export interface GenerateInput {
*/
mode?: Extract<PromptComposeOptions['mode'], 'create'> | 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;
/**
Expand Down
22 changes: 22 additions & 0 deletions packages/providers/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -389,6 +391,7 @@ export async function complete(
reasoning?: PiReasoningLevel;
headers?: Record<string, string>;
onPayload?: (payload: unknown) => unknown;
timeoutMs?: number;
},
) => Promise<PiAssistantMessage>;
};
Expand Down Expand Up @@ -417,12 +420,14 @@ export async function complete(
reasoning?: PiReasoningLevel;
headers?: Record<string, string>;
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 };
Expand Down
46 changes: 46 additions & 0 deletions packages/providers/src/request-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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);
});
Loading