Skip to content

Commit 48d1a61

Browse files
committed
refactor(openai): centralize Responses error handling via _responsesCreateWithRetries; dedupe checks for previous_response_id, verbosity, and Azure input_text invalid in streaming and non-streaming paths
1 parent bf49d77 commit 48d1a61

File tree

1 file changed

+99
-177
lines changed

1 file changed

+99
-177
lines changed

src/api/providers/openai.ts

Lines changed: 99 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -286,175 +286,41 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
286286

287287
// Non-streaming path
288288
if (nonStreaming) {
289-
try {
290-
const response = await (
291-
this.client as unknown as {
292-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
293-
}
294-
).responses.create(basePayload)
295-
yield* this._yieldResponsesResult(response as unknown, modelInfo)
296-
} catch (err: unknown) {
297-
// Retry without previous_response_id if server rejects it (400 "Previous response ... not found")
298-
if (previousId && this._isPreviousResponseNotFoundError(err)) {
299-
const { previous_response_id: _omitPrev, ...withoutPrev } = basePayload as {
300-
previous_response_id?: unknown
301-
[key: string]: unknown
302-
}
303-
// Clear stored continuity to avoid reusing a bad id
304-
this.lastResponseId = undefined
305-
const response = await (
306-
this.client as unknown as {
307-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
308-
}
309-
).responses.create(withoutPrev)
310-
yield* this._yieldResponsesResult(response as unknown, modelInfo)
311-
}
312-
// Graceful downgrade if verbosity is rejected by server (400 unknown/unsupported parameter)
313-
else if ("text" in basePayload && this._isVerbosityUnsupportedError(err)) {
314-
// Remove text.verbosity and retry once
315-
const { text: _omit, ...withoutVerbosity } = basePayload as { text?: unknown } & Record<
316-
string,
317-
unknown
318-
>
319-
const response = await (
320-
this.client as unknown as {
321-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
322-
}
323-
).responses.create(withoutVerbosity)
324-
yield* this._yieldResponsesResult(response as unknown, modelInfo)
325-
} else if (usedArrayInput && this._isInputTextInvalidError(err)) {
326-
// Azure-specific fallback: retry with a minimal single-message string when array input is rejected
327-
const retryPayload: Record<string, unknown> = {
328-
...basePayload,
329-
input:
330-
previousId && lastUserMessage
331-
? this._formatResponsesSingleMessage(lastUserMessage, true)
332-
: this._formatResponsesInput(systemPrompt, messages),
333-
}
334-
const response = await (
335-
this.client as unknown as {
336-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
337-
}
338-
).responses.create(retryPayload)
339-
yield* this._yieldResponsesResult(response as unknown, modelInfo)
340-
} else {
341-
throw err
342-
}
343-
}
289+
const response = await this._responsesCreateWithRetries(basePayload, {
290+
usedArrayInput,
291+
lastUserMessage,
292+
previousId,
293+
systemPrompt,
294+
messages,
295+
})
296+
yield* this._yieldResponsesResult(response as unknown, modelInfo)
344297
return
345298
}
346299

347300
// Streaming path (auto-fallback to non-streaming result if provider ignores stream flag)
348301
const streamingPayload: Record<string, unknown> = { ...basePayload, stream: true }
349-
try {
350-
const maybeStream = await (
351-
this.client as unknown as {
352-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
353-
}
354-
).responses.create(streamingPayload)
355-
356-
const isAsyncIterable = (obj: unknown): obj is AsyncIterable<unknown> =>
357-
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function"
358-
359-
if (isAsyncIterable(maybeStream)) {
360-
for await (const chunk of handleResponsesStream(maybeStream, {
361-
onResponseId: (id) => {
362-
this.lastResponseId = id
363-
},
364-
})) {
365-
yield chunk
366-
}
367-
} else {
368-
// Some providers may ignore the stream flag and return a complete response
369-
yield* this._yieldResponsesResult(maybeStream as unknown, modelInfo)
370-
}
371-
} catch (err: unknown) {
372-
// Retry without previous_response_id if server rejects it (400 "Previous response ... not found")
373-
if (previousId && this._isPreviousResponseNotFoundError(err)) {
374-
const { previous_response_id: _omitPrev, ...withoutPrev } = streamingPayload as {
375-
previous_response_id?: unknown
376-
[key: string]: unknown
377-
}
378-
this.lastResponseId = undefined
379-
const maybeStreamRetry = await (
380-
this.client as unknown as {
381-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
382-
}
383-
).responses.create(withoutPrev)
384-
385-
const isAsyncIterable = (obj: unknown): obj is AsyncIterable<unknown> =>
386-
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function"
387-
388-
if (isAsyncIterable(maybeStreamRetry)) {
389-
for await (const chunk of handleResponsesStream(maybeStreamRetry, {
390-
onResponseId: (id) => {
391-
this.lastResponseId = id
392-
},
393-
})) {
394-
yield chunk
395-
}
396-
} else {
397-
yield* this._yieldResponsesResult(maybeStreamRetry as unknown, modelInfo)
398-
}
399-
}
400-
// Graceful verbosity removal on 400
401-
else if ("text" in streamingPayload && this._isVerbosityUnsupportedError(err)) {
402-
const { text: _omit, ...withoutVerbosity } = streamingPayload as { text?: unknown } & Record<
403-
string,
404-
unknown
405-
>
406-
const maybeStreamRetry = await (
407-
this.client as unknown as {
408-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
409-
}
410-
).responses.create(withoutVerbosity)
411-
412-
const isAsyncIterable = (obj: unknown): obj is AsyncIterable<unknown> =>
413-
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function"
414-
415-
if (isAsyncIterable(maybeStreamRetry)) {
416-
for await (const chunk of handleResponsesStream(maybeStreamRetry, {
417-
onResponseId: (id) => {
418-
this.lastResponseId = id
419-
},
420-
})) {
421-
yield chunk
422-
}
423-
} else {
424-
yield* this._yieldResponsesResult(maybeStreamRetry as unknown, modelInfo)
425-
}
426-
} else if (usedArrayInput && this._isInputTextInvalidError(err)) {
427-
// Azure-specific fallback for streaming: retry with minimal single-message string while keeping stream: true
428-
const retryStreamingPayload: Record<string, unknown> = {
429-
...streamingPayload,
430-
input:
431-
previousId && lastUserMessage
432-
? this._formatResponsesSingleMessage(lastUserMessage, true)
433-
: this._formatResponsesInput(systemPrompt, messages),
434-
}
435-
const maybeStreamRetry = await (
436-
this.client as unknown as {
437-
responses: { create: (body: Record<string, unknown>) => Promise<unknown> }
438-
}
439-
).responses.create(retryStreamingPayload)
302+
const maybeStream = await this._responsesCreateWithRetries(streamingPayload, {
303+
usedArrayInput,
304+
lastUserMessage,
305+
previousId,
306+
systemPrompt,
307+
messages,
308+
})
440309

441-
const isAsyncIterable = (obj: unknown): obj is AsyncIterable<unknown> =>
442-
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function"
310+
const isAsyncIterable = (obj: unknown): obj is AsyncIterable<unknown> =>
311+
typeof (obj as AsyncIterable<unknown>)[Symbol.asyncIterator] === "function"
443312

444-
if (isAsyncIterable(maybeStreamRetry)) {
445-
for await (const chunk of handleResponsesStream(maybeStreamRetry, {
446-
onResponseId: (id) => {
447-
this.lastResponseId = id
448-
},
449-
})) {
450-
yield chunk
451-
}
452-
} else {
453-
yield* this._yieldResponsesResult(maybeStreamRetry as unknown, modelInfo)
454-
}
455-
} else {
456-
throw err
313+
if (isAsyncIterable(maybeStream)) {
314+
for await (const chunk of handleResponsesStream(maybeStream, {
315+
onResponseId: (id) => {
316+
this.lastResponseId = id
317+
},
318+
})) {
319+
yield chunk
457320
}
321+
} else {
322+
// Some providers may ignore the stream flag and return a complete response
323+
yield* this._yieldResponsesResult(maybeStream as unknown, modelInfo)
458324
}
459325
return
460326
}
@@ -686,25 +552,22 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
686552
payload.max_output_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
687553
}
688554

555+
const response = await this._responsesCreateWithRetries(payload as unknown as Record<string, unknown>, {
556+
usedArrayInput: false,
557+
lastUserMessage: undefined,
558+
previousId: undefined,
559+
systemPrompt: "",
560+
messages: [],
561+
})
689562
try {
690-
const response = await this.client.responses.create(payload)
691-
try {
692-
const respId = (response as { id?: unknown } | undefined)?.id
693-
if (typeof respId === "string" && respId.length > 0) {
694-
this.lastResponseId = respId
695-
}
696-
} catch {
697-
// ignore
698-
}
699-
return this._extractResponsesText(response) ?? ""
700-
} catch (err: unknown) {
701-
if (payload.text && this._isVerbosityUnsupportedError(err)) {
702-
const { text: _omit, ...withoutVerbosity } = payload
703-
const response = await this.client.responses.create(withoutVerbosity)
704-
return this._extractResponsesText(response) ?? ""
563+
const respId = (response as { id?: unknown } | undefined)?.id
564+
if (typeof respId === "string" && respId.length > 0) {
565+
this.lastResponseId = respId
705566
}
706-
throw err
567+
} catch {
568+
// ignore
707569
}
570+
return this._extractResponsesText(response) ?? ""
708571
}
709572

710573
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
@@ -1095,6 +958,65 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
1095958
const msgRaw = (anyErr.message ?? anyErr.error?.message ?? "").toString().toLowerCase()
1096959
return status === 400 && msgRaw.includes("invalid value") && msgRaw.includes("input_text")
1097960
}
961+
962+
/**
963+
* Centralized Responses.create with one-shot retries for common provider errors:
964+
* - 400 "Previous response ... not found" -> drop previous_response_id and retry
965+
* - 400 unknown/unsupported "text.verbosity" -> remove text and retry
966+
* - 400 invalid value for input_text (Azure) -> rebuild single-message string input and retry
967+
* Returns either an AsyncIterable (streaming) or a full response object (non-streaming).
968+
*/
969+
private async _responsesCreateWithRetries(
970+
payload: Record<string, unknown>,
971+
opts: {
972+
usedArrayInput: boolean
973+
lastUserMessage?: Anthropic.Messages.MessageParam
974+
previousId?: string
975+
systemPrompt: string
976+
messages: Anthropic.Messages.MessageParam[]
977+
},
978+
): Promise<unknown> {
979+
const create = (body: Record<string, unknown>) =>
980+
(
981+
this.client as unknown as { responses: { create: (b: Record<string, unknown>) => Promise<unknown> } }
982+
).responses.create(body)
983+
984+
try {
985+
return await create(payload)
986+
} catch (err: unknown) {
987+
// Retry without previous_response_id if server rejects it
988+
if (opts.previousId && this._isPreviousResponseNotFoundError(err)) {
989+
const { previous_response_id: _omitPrev, ...withoutPrev } = payload as {
990+
previous_response_id?: unknown
991+
[key: string]: unknown
992+
}
993+
this.lastResponseId = undefined
994+
return await create(withoutPrev)
995+
}
996+
997+
// Graceful downgrade if verbosity is rejected by server
998+
if ("text" in payload && this._isVerbosityUnsupportedError(err)) {
999+
const { text: _omit, ...withoutVerbosity } = payload as { text?: unknown } & Record<string, unknown>
1000+
return await create(withoutVerbosity)
1001+
}
1002+
1003+
// Azure-specific fallback when array input is rejected
1004+
if (opts.usedArrayInput && this._isInputTextInvalidError(err)) {
1005+
const fallbackInput =
1006+
opts.previousId && opts.lastUserMessage
1007+
? this._formatResponsesSingleMessage(opts.lastUserMessage, true)
1008+
: this._formatResponsesInput(opts.systemPrompt, opts.messages)
1009+
1010+
const retryPayload: Record<string, unknown> = {
1011+
...payload,
1012+
input: fallbackInput,
1013+
}
1014+
return await create(retryPayload)
1015+
}
1016+
1017+
throw err
1018+
}
1019+
}
10981020
private async *_yieldResponsesResult(response: any, modelInfo: ModelInfo): ApiStream {
10991021
// Capture response id for continuity when present
11001022
try {

0 commit comments

Comments
 (0)