Skip to content

Commit b82b3f3

Browse files
committed
fix(ai-router): handle stream error failover
1 parent 12e1d24 commit b82b3f3

2 files changed

Lines changed: 99 additions & 4 deletions

File tree

src/server/model/aiRouter.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,43 @@ describe('AI Router buffered attempt mapping', () => {
645645
});
646646
});
647647

648+
test('converts buffered stream error payload into an uncommitted retryable failure', () => {
649+
const result = buildBufferedAIGatewayAttemptResult({
650+
protocol: AI_ROUTER_PROTOCOLS.OPENAI_CHAT,
651+
failOnEmptyContent: true,
652+
gatewayId: 'gw-stream-error',
653+
logId: 'log-stream-error',
654+
response: {
655+
statusCode: 200,
656+
headers: {
657+
'content-type': 'text/event-stream',
658+
},
659+
chunks: [
660+
Buffer.from(AI_GATEWAY_STREAM_PING_COMMENT),
661+
Buffer.from(
662+
'data: {"error":{"message":"LLM returned empty response from stream","type":"server_error"}}\n\n'
663+
),
664+
Buffer.from('data: [DONE]\n\n'),
665+
],
666+
wroteBody: true,
667+
bodyStartedBeforeFailure: false,
668+
ended: true,
669+
},
670+
});
671+
672+
expect(result).toMatchObject({
673+
ok: false,
674+
committed: false,
675+
gatewayId: 'gw-stream-error',
676+
logId: 'log-stream-error',
677+
statusCode: 502,
678+
failure: {
679+
message: 'LLM returned empty response from stream',
680+
errorType: 'upstream',
681+
},
682+
});
683+
});
684+
648685
test('detects empty OpenAI chat content while allowing tool calls', () => {
649686
expect(
650687
inspectAIRouterBufferedResponseContent(

src/server/model/aiRouter.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,7 +1219,8 @@ function buildAIRouterRuntimeHandler(args: {
12191219
writeAIRouterStreamError(
12201220
res,
12211221
args.protocol,
1222-
getBufferedFailureMessage(result.response),
1222+
result.failure?.message ??
1223+
getBufferedFailureMessage(result.response),
12231224
'router_failed'
12241225
);
12251226
return;
@@ -1592,13 +1593,22 @@ export function buildBufferedAIGatewayAttemptResult(args: {
15921593
};
15931594
}
15941595

1596+
const streamFailureMessage = readSSEErrorMessage(args.response.chunks);
15951597
const statusCode =
15961598
args.response.statusCode >= 200 &&
15971599
args.response.statusCode < 300 &&
15981600
partialFailure
15991601
? 500
1600-
: args.response.statusCode;
1601-
const ok = statusCode >= 200 && statusCode < 300 && !partialFailure;
1602+
: args.response.statusCode >= 200 &&
1603+
args.response.statusCode < 300 &&
1604+
streamFailureMessage
1605+
? 502
1606+
: args.response.statusCode;
1607+
const ok =
1608+
statusCode >= 200 &&
1609+
statusCode < 300 &&
1610+
!partialFailure &&
1611+
!streamFailureMessage;
16021612
const emptyContentFailure =
16031613
ok &&
16041614
args.failOnEmptyContent === true &&
@@ -1631,7 +1641,8 @@ export function buildBufferedAIGatewayAttemptResult(args: {
16311641
: {
16321642
message: partialFailure
16331643
? 'AI Gateway attempt ended after partial output'
1634-
: getBufferedFailureMessage(args.response),
1644+
: streamFailureMessage ??
1645+
getBufferedFailureMessage(args.response),
16351646
errorType: 'upstream',
16361647
},
16371648
response: args.response,
@@ -1863,6 +1874,12 @@ function getBufferedFailureMessage(snapshot: BufferedResponseSnapshot): string {
18631874
return jsonMessage;
18641875
}
18651876

1877+
const sseMessage = readSSEErrorMessage(snapshot.chunks);
1878+
1879+
if (sseMessage) {
1880+
return sseMessage;
1881+
}
1882+
18661883
const text = Buffer.concat(snapshot.chunks).toString('utf8').trim();
18671884

18681885
return text || `AI Gateway attempt failed with status ${snapshot.statusCode}`;
@@ -1883,6 +1900,47 @@ function readJsonErrorMessage(value: unknown): string | null {
18831900
return typeof message === 'string' && message ? message : null;
18841901
}
18851902

1903+
function readSSEErrorMessage(chunks: Buffer[]): string | null {
1904+
for (const event of parseAIRouterSSEEvents(chunks)) {
1905+
const message = readSSEEventErrorMessage(event);
1906+
1907+
if (message) {
1908+
return message;
1909+
}
1910+
}
1911+
1912+
return null;
1913+
}
1914+
1915+
function readSSEEventErrorMessage(value: unknown): string | null {
1916+
if (!isAIRouterRecord(value)) {
1917+
return null;
1918+
}
1919+
1920+
const error = value.error;
1921+
1922+
if (typeof error === 'string' && error) {
1923+
return error;
1924+
}
1925+
1926+
if (isAIRouterRecord(error)) {
1927+
const nestedError = error.error;
1928+
const message =
1929+
error.message ??
1930+
(isAIRouterRecord(nestedError) ? nestedError.message : undefined);
1931+
1932+
if (typeof message === 'string' && message) {
1933+
return message;
1934+
}
1935+
}
1936+
1937+
if (value.type === 'error' && typeof value.message === 'string') {
1938+
return value.message;
1939+
}
1940+
1941+
return null;
1942+
}
1943+
18861944
export interface AIRouterBufferedContentInspection {
18871945
parsed: boolean;
18881946
empty: boolean;

0 commit comments

Comments
 (0)