Skip to content

Commit acbcb93

Browse files
committed
fix(providers): refuse an empty Responses input before sending instead of retrying a guaranteed 400
1 parent 3aad201 commit acbcb93

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

‎apps/sim/providers/openai/core.response-status.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
} from '@sim/testing/mocks/providers-conversation-history.mock'
1212
import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
14+
import { classifyFailure } from '@/lib/core/errors/failure-log'
15+
import { isNonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
1416
import { executeResponsesProviderRequest } from '@/providers/openai/core'
1517
import type { ProviderRequest, ProviderResponse } from '@/providers/types'
1618

@@ -113,6 +115,41 @@ describe('OpenAI non-streaming response status handling', () => {
113115
expect(fetchMock).not.toHaveBeenCalled()
114116
})
115117

118+
it('refuses a request with no input items before sending, as a nonretryable author failure', async () => {
119+
const fetchMock = vi.fn().mockResolvedValue(
120+
jsonResponse(
121+
{
122+
error: {
123+
message:
124+
'One of input or previous_response_id or prompt or conversation must be provided.',
125+
},
126+
},
127+
400
128+
)
129+
)
130+
const error = await run(fetchMock, {
131+
systemPrompt: '',
132+
messages: [
133+
{ role: 'system', content: '' },
134+
{ role: 'user', content: '' },
135+
],
136+
}).catch((caught: unknown) => caught)
137+
138+
expect(error).toMatchObject({ name: 'EmptyProviderInputError' })
139+
expect(isNonRetryableExecutionError(error)).toBe(true)
140+
expect(classifyFailure(error)).toBe('user')
141+
expect(fetchMock).not.toHaveBeenCalled()
142+
})
143+
144+
it('still sends a request that carries only a system prompt', async () => {
145+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE))
146+
await run(fetchMock, { systemPrompt: 'You are terse.', messages: [] })
147+
148+
expect(fetchMock).toHaveBeenCalledTimes(1)
149+
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
150+
expect(body.input).toEqual([{ role: 'system', content: 'You are terse.' }])
151+
})
152+
116153
it('lets an estimated oversized request reach the provider and propagates its actual context rejection', async () => {
117154
mockConversationContext.mockReturnValue({ agentConversation: {} })
118155
const fetchMock = vi.fn().mockResolvedValue({

‎apps/sim/providers/openai/core.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
3030
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
3131
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
3232
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
33-
import { ProviderError } from '@/providers/types'
33+
import { EmptyProviderInputError, ProviderError } from '@/providers/types'
3434
import {
3535
enforceStrictSchema,
3636
prepareToolExecution,
@@ -188,6 +188,7 @@ export async function executeResponsesProviderRequest(
188188
}
189189

190190
const initialInput = buildResponsesInputFromMessages(allMessages, config.providerId)
191+
if (initialInput.length === 0) throw new EmptyProviderInputError()
191192

192193
/**
193194
* `stream` is always explicit: OpenAI defaults it to false, but Responses-compatible

‎apps/sim/providers/types.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution'
2+
import { UserFailure } from '@/lib/core/errors/user-failure'
23
import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config'
34
import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types'
45

@@ -294,4 +295,20 @@ export class ProviderError extends Error {
294295
}
295296
}
296297

298+
/**
299+
* A request with no input items to send: every system and user message was absent or resolved
300+
* to empty. The provider would reject it on every attempt, so it is the author's to fix and is
301+
* never retried or handed to a fallback model.
302+
*/
303+
export class EmptyProviderInputError extends UserFailure {
304+
readonly retryable = false
305+
306+
constructor() {
307+
super(
308+
'The Agent has no messages to send. Add a system prompt or user message, or check that the value it references (for example <start.input>) is not empty.'
309+
)
310+
this.name = 'EmptyProviderInputError'
311+
}
312+
}
313+
297314
export const providers: Record<string, ProviderConfig> = {}

0 commit comments

Comments
 (0)