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
37 changes: 37 additions & 0 deletions apps/sim/providers/openai/core.response-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from '@sim/testing/mocks/providers-conversation-history.mock'
import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { classifyFailure } from '@/lib/core/errors/failure-log'
import { isNonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
import { executeResponsesProviderRequest } from '@/providers/openai/core'
import type { ProviderRequest, ProviderResponse } from '@/providers/types'

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

it('refuses a request with no input items before sending, as a nonretryable author failure', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(
{
error: {
message:
'One of input or previous_response_id or prompt or conversation must be provided.',
},
},
400
)
)
const error = await run(fetchMock, {
systemPrompt: '',
messages: [
{ role: 'system', content: '' },
{ role: 'user', content: '' },
],
}).catch((caught: unknown) => caught)

expect(error).toMatchObject({ name: 'EmptyProviderInputError' })
expect(isNonRetryableExecutionError(error)).toBe(true)
expect(classifyFailure(error)).toBe('user')
expect(fetchMock).not.toHaveBeenCalled()
})

it('still sends a request that carries only a system prompt', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(COMPLETED_RESPONSE))
await run(fetchMock, { systemPrompt: 'You are terse.', messages: [] })

expect(fetchMock).toHaveBeenCalledTimes(1)
const body = JSON.parse(fetchMock.mock.calls[0][1].body)
expect(body.input).toEqual([{ role: 'system', content: 'You are terse.' }])
})

it('lets an estimated oversized request reach the provider and propagates its actual context rejection', async () => {
mockConversationContext.mockReturnValue({ agentConversation: {} })
const fetchMock = vi.fn().mockResolvedValue({
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/providers/openai/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { createStreamingExecution } from '@/providers/streaming-execution'
import { isAbortError, parseToolArguments } from '@/providers/streaming-tool-loop-shared'
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { EmptyProviderInputError, ProviderError } from '@/providers/types'
import {
enforceStrictSchema,
prepareToolExecution,
Expand Down Expand Up @@ -188,6 +188,7 @@ export async function executeResponsesProviderRequest(
}

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

/**
* `stream` is always explicit: OpenAI defaults it to false, but Responses-compatible
Expand Down
17 changes: 17 additions & 0 deletions apps/sim/providers/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution'
import { UserFailure } from '@/lib/core/errors/user-failure'
import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config'
import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types'

Expand Down Expand Up @@ -294,4 +295,20 @@ export class ProviderError extends Error {
}
}

/**
* A request with no input items to send: every system and user message was absent or resolved
* to empty. The provider would reject it on every attempt, so it is the author's to fix and is
* never retried or handed to a fallback model.
*/
export class EmptyProviderInputError extends UserFailure {
readonly retryable = false

constructor() {
super(
'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.'
)
this.name = 'EmptyProviderInputError'
}
}

export const providers: Record<string, ProviderConfig> = {}
Loading