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
14 changes: 14 additions & 0 deletions apps/sim/providers/openai/core.transport-phase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,20 @@ describe('OpenAI transport phase annotation', () => {
expect(error.message.match(/phase=/g)).toHaveLength(1)
})

/**
* Streaming calls the request helper directly and never reaches `postResponses`, so a
* header stall on a chat or SSE run used to surface as the bare runtime string with no
* phase, elapsed time, or request id.
*/
it('names the header phase on a streaming request too', async () => {
const error = await run(vi.fn().mockRejectedValue(timeoutError()), {
stream: true,
}).catch((e) => e)

expect(error.message).toContain('phase=awaiting-response-headers')
expect(error.message).toMatch(/elapsedMs=\d+/)
})

it('leaves a healthy response entirely unaffected', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
Expand Down
44 changes: 26 additions & 18 deletions apps/sim/providers/openai/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,29 @@ export async function executeResponsesProviderRequest(

let reasoningSummariesUnavailable = false

/**
* The single point every Responses request leaves through, so a stall waiting for
* headers is named on the streaming paths too — they call
* {@link fetchResponsesWithSummaryFallback} directly and never reach `postResponses`,
* which is where the annotation used to live.
*/
const postOnce = async (
payload: Record<string, unknown>,
abortSignal: AbortSignal | undefined,
startedAt: number
): Promise<Response> => {
try {
return await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(payload),
signal: abortSignal,
})
} catch (error) {
throw annotateTransportFailure(error, 'awaiting-response-headers', startedAt)
}
}

const fetchResponsesWithSummaryFallback = async (
requestedBody: Record<string, unknown>,
startedAt: number,
Expand All @@ -418,12 +441,7 @@ export async function executeResponsesProviderRequest(
const body = reasoningSummariesUnavailable
? (stripReasoningSummary(requestedBody) ?? requestedBody)
: requestedBody
const response = await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(body),
signal: abortSignal,
})
const response = await postOnce(body, abortSignal, startedAt)
if (response.ok) return response

const message = await parseErrorResponse(response, startedAt)
Expand All @@ -439,12 +457,7 @@ export async function executeResponsesProviderRequest(
`${config.providerLabel} rejected reasoning summaries (organization not verified); retrying without summary`,
{ model: config.modelName }
)
const retryResponse = await fetchImpl(config.endpoint, {
method: 'POST',
headers: config.headers,
body: JSON.stringify(strippedBody),
signal: abortSignal,
})
const retryResponse = await postOnce(strippedBody, abortSignal, startedAt)
if (!retryResponse.ok) {
const retryMessage = await parseErrorResponse(retryResponse, startedAt)
throw new Error(
Expand All @@ -459,12 +472,7 @@ export async function executeResponsesProviderRequest(
): Promise<OpenAI.Responses.Response> => {
const startedAt = Date.now()

let response: Response
try {
response = await fetchResponsesWithSummaryFallback(body, startedAt)
} catch (error) {
throw annotateTransportFailure(error, 'awaiting-response-headers', startedAt)
}
const response = await fetchResponsesWithSummaryFallback(body, startedAt)

const responseMeta = { ...describeResponse(response), ttfbMs: Date.now() - startedAt }

Expand Down
Loading