Skip to content

Commit e8d57d0

Browse files
committed
Handle provider errors gracefully in AI service streaming
1 parent 8c61848 commit e8d57d0

4 files changed

Lines changed: 82 additions & 24 deletions

File tree

packages/services/service-ai/src/adapters/vercel-adapter.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,17 @@ export class VercelLLMAdapter implements LLMAdapter {
116116
...buildVercelOptions(options),
117117
});
118118

119-
for await (const part of result.fullStream) {
120-
yield part as TextStreamPart<ToolSet>;
119+
try {
120+
for await (const part of result.fullStream) {
121+
yield part as TextStreamPart<ToolSet>;
122+
}
123+
} catch (err) {
124+
// Convert provider errors into a typed `error` part so the encoder can
125+
// surface them to the client instead of leaving the SSE stream open.
126+
yield {
127+
type: 'error',
128+
error: err instanceof Error ? err : new Error(String(err)),
129+
} as unknown as TextStreamPart<ToolSet>;
121130
}
122131
}
123132

packages/services/service-ai/src/plugin.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,24 @@ export class AIServicePlugin implements Plugin {
131131
if (process.env[envKey]) {
132132
try {
133133
const mod = await import(/* webpackIgnore: true */ pkg);
134-
const createModel = mod[factory] ?? mod.default;
135-
if (typeof createModel === 'function') {
134+
const provider = mod[factory] ?? mod.default;
135+
if (typeof provider === 'function') {
136136
const modelId = process.env.AI_MODEL ?? defaultModel;
137-
const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
138-
return { adapter, description: `${displayName} (model: ${modelId})` };
137+
// For OpenAI, prefer the Chat Completions API (`openai.chat(...)`)
138+
// over the new Responses API. The Responses endpoint
139+
// (`/v1/responses`) is not supported by common reverse proxies
140+
// such as the Vercel AI Gateway, Cloudflare AI Gateway, or
141+
// Azure-style OpenAI deployments — calling it returns 403
142+
// Forbidden and the chat completion silently fails. The Chat
143+
// Completions endpoint (`/v1/chat/completions`) is the
144+
// industry-standard contract every gateway supports.
145+
const useChatApi = factory === 'openai' && typeof (provider as any).chat === 'function';
146+
const model = useChatApi
147+
? (provider as any).chat(modelId)
148+
: provider(modelId);
149+
const adapter = new VercelLLMAdapter({ model });
150+
const apiSuffix = useChatApi ? ' [chat-completions]' : '';
151+
return { adapter, description: `${displayName} (model: ${modelId})${apiSuffix}` };
139152
}
140153
} catch (err) {
141154
ctx.logger.warn(

packages/services/service-ai/src/routes/assistant-routes.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,14 @@ export function buildAssistantRoutes(
258258
'[AI Route] /assistant/chat error',
259259
err instanceof Error ? err : undefined,
260260
);
261-
return { status: 500, body: { error: 'Internal AI service error' } };
261+
// Surface a brief upstream message so the client UI can render it
262+
// instead of an opaque "Internal AI service error". Stack traces
263+
// stay in the logger.
264+
const upstreamMsg = err instanceof Error ? err.message : String(err);
265+
return {
266+
status: 500,
267+
body: { error: 'Internal AI service error', detail: upstreamMsg },
268+
};
262269
}
263270
},
264271
},

packages/services/service-ai/src/stream/vercel-stream-encoder.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -118,35 +118,64 @@ export async function* encodeVercelDataStream(
118118

119119
let textOpen = true;
120120
let finishReason = 'stop';
121+
let errorMessage: string | undefined;
122+
123+
try {
124+
for await (const part of events) {
125+
// Surface error parts emitted by the underlying provider stream.
126+
if ((part as { type: string }).type === 'error') {
127+
const errPart = part as unknown as { error?: unknown };
128+
const raw = errPart.error;
129+
errorMessage =
130+
(raw && typeof raw === 'object' && 'message' in raw
131+
? String((raw as { message: unknown }).message)
132+
: typeof raw === 'string'
133+
? raw
134+
: 'Unknown provider error');
135+
finishReason = 'error';
136+
break;
137+
}
121138

122-
for await (const part of events) {
123-
// Capture finish reason
124-
if (part.type === 'finish') {
125-
finishReason = part.finishReason ?? 'stop';
126-
}
139+
// Capture finish reason
140+
if (part.type === 'finish') {
141+
finishReason = part.finishReason ?? 'stop';
142+
}
127143

128-
// Before finish-step/finish, close the text part first
129-
if (part.type === 'finish-step' || part.type === 'finish') {
130-
if (textOpen) {
131-
yield sse({ type: 'text-end', id: '0' });
132-
textOpen = false;
144+
// Before finish-step/finish, close the text part first
145+
if (part.type === 'finish-step' || part.type === 'finish') {
146+
if (textOpen) {
147+
yield sse({ type: 'text-end', id: '0' });
148+
textOpen = false;
149+
}
150+
// Don't emit these via encodeStreamPart — we handle them in postamble
151+
continue;
133152
}
134-
// Don't emit these via encodeStreamPart — we handle them in postamble
135-
continue;
136-
}
137153

138-
const frame = encodeStreamPart(part);
139-
if (frame) {
140-
yield frame;
154+
const frame = encodeStreamPart(part);
155+
if (frame) {
156+
yield frame;
157+
}
141158
}
159+
} catch (err) {
160+
// Upstream provider threw (auth failure, network error, etc.). Without
161+
// this catch the SSE response would hang half-open and the client would
162+
// never leave its "streaming" state.
163+
errorMessage = err instanceof Error ? err.message : String(err);
164+
finishReason = 'error';
142165
}
143166

144167
// Close text if still open (safety)
145168
if (textOpen) {
146169
yield sse({ type: 'text-end', id: '0' });
147170
}
148171

149-
// Postamble
172+
// If we recorded an error, emit it as a UI Message Stream `error` part so
173+
// the client can display it instead of spinning forever.
174+
if (errorMessage) {
175+
yield sse({ type: 'error', errorText: errorMessage });
176+
}
177+
178+
// Postamble — always emit so the client transitions out of "streaming".
150179
yield sse({ type: 'finish-step' });
151180
yield sse({ type: 'finish', finishReason });
152181
yield 'data: [DONE]\n\n';

0 commit comments

Comments
 (0)