diff --git a/apps/sim/providers/openai/core.deadline.test.ts b/apps/sim/providers/openai/core.deadline.test.ts new file mode 100644 index 00000000000..6faf0e6a599 --- /dev/null +++ b/apps/sim/providers/openai/core.deadline.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + * + * A non-streaming generation is silent on the wire until it finishes, so nothing but an + * explicit deadline can bound it. A streaming one emits continuously and must NOT carry + * a total deadline, or a long answer still arriving normally gets cut off. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', () => ({ + isFunctionToolCall: () => false, + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + enforceStrictSchema: (schema: unknown) => schema, + prepareToolExecution: () => ({ toolParams: {}, executionParams: {} }), + prepareToolsWithUsageControl: (tools: unknown[]) => ({ + tools, + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + trackForcedToolUsage: () => ({ hasUsedForcedTool: false, usedForcedTools: [] }), + supportsReasoningEffort: () => false, +})) + +const COMPLETED = { + id: 'resp_1', + status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, +} + +describe('provider request deadline', () => { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as never + + beforeEach(() => vi.clearAllMocks()) + + function run(fetchMock: unknown, request: Partial = {}) { + return executeResponsesProviderRequest( + { apiKey: 'k', model: 'gpt-5.5', messages: [{ role: 'user', content: 'hi' }], ...request }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: 'gpt-5.5', + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger, + fetch: fetchMock as typeof fetch, + } + ) + } + + function okResponse() { + return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(COMPLETED) } + } + + /** Ten minutes, matching the OpenAI client's own documented default. */ + it('matches the vendor default rather than inventing a number', () => { + expect(PROVIDER_REQUEST_TIMEOUT_MS).toBe(600_000) + }) + + it('arms a deadline on a non-streaming request', async () => { + const fetchMock = vi.fn().mockResolvedValue(okResponse()) + await run(fetchMock) + + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal + expect(signal).toBeInstanceOf(AbortSignal) + expect(signal.aborted).toBe(false) + }) + + /** + * The runtime's idle timer already bounds a stalled stream correctly. A total deadline + * here would kill a long answer that is still arriving. + */ + it('does not arm a deadline on a streaming request', async () => { + const fetchMock = vi.fn().mockResolvedValue(okResponse()) + await run(fetchMock, { stream: true }).catch(() => {}) + + const streamCall = fetchMock.mock.calls.find( + (c) => JSON.parse(c[1].body as string).stream === true + ) + expect(streamCall).toBeDefined() + expect(streamCall?.[1].signal).toBeUndefined() + }) + + /** A user pressing Stop must still win over the deadline. */ + it('preserves the caller signal alongside the deadline', async () => { + const controller = new AbortController() + const fetchMock = vi.fn().mockResolvedValue(okResponse()) + await run(fetchMock, { abortSignal: controller.signal }) + + const signal = fetchMock.mock.calls[0][1].signal as AbortSignal + expect(signal.aborted).toBe(false) + controller.abort() + expect(signal.aborted).toBe(true) + }) +}) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 77848c08937..32842063889 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -17,6 +17,7 @@ import { import { executeProviderTool } from '@/providers/runtime-context' import { createStreamingExecution } from '@/providers/streaming-execution' import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared' +import { PROVIDER_REQUEST_TIMEOUT_MS } from '@/providers/timeouts' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' @@ -410,6 +411,26 @@ export async function executeResponsesProviderRequest( let reasoningSummariesUnavailable = false + /** + * Bounds a non-streaming request, and deliberately leaves a streaming one alone. + * + * A non-streaming generation is silent on the wire until it completes, so there is no + * liveness signal an idle timer could act on — the deadline has to be explicit. A + * streaming response emits continuously, which is precisely what the runtime's idle + * timer is built for, and a total deadline there would cut off a long answer that is + * still arriving normally. + * + * The caller's own signal is preserved: a user pressing Stop must still win. + */ + const withRequestDeadline = ( + abortSignal: AbortSignal | undefined, + streaming: boolean + ): AbortSignal | undefined => { + if (streaming) return abortSignal + const deadline = AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS) + return abortSignal ? AbortSignal.any([abortSignal, deadline]) : deadline + } + /** * The single point every Responses request leaves through, so a stall waiting for * headers is named on the streaming paths too — they call @@ -426,7 +447,7 @@ export async function executeResponsesProviderRequest( method: 'POST', headers: config.headers, body: JSON.stringify(payload), - signal: abortSignal, + signal: withRequestDeadline(abortSignal, payload.stream === true), }) } catch (error) { throw annotateTransportFailure(error, 'awaiting-response-headers', startedAt) diff --git a/apps/sim/providers/timeouts.ts b/apps/sim/providers/timeouts.ts new file mode 100644 index 00000000000..76fbc79311e --- /dev/null +++ b/apps/sim/providers/timeouts.ts @@ -0,0 +1,14 @@ +/** + * Deadline for a single non-streaming provider request, matching the OpenAI client's own + * documented default (`node_modules/openai/client.d.ts`: `[opts.timeout=10 minutes]`). + * + * Without an explicit value the request inherits whatever the runtime imposes — under Bun + * that is an undocumented ~300s idle timer, half the vendor's default and chosen by nobody. + * Measured production failures at 295.8s and 278.9s were generations still in progress, not + * stalled connections. + * + * Deliberately its own module rather than the `@/providers` barrel: that barrel is replaced + * wholesale by `vi.mock` in 21 test files, so an export added there resolves to `undefined` + * in all of them. + */ +export const PROVIDER_REQUEST_TIMEOUT_MS = 600_000