Summary
The chat completion convenience runners lose the reason supplied to a caller AbortSignal on the non-streaming path.
A direct SDK request preserves signal.reason as APIUserAbortError.cause, and the streaming runner path preserves it as well. runTools(..., { signal }) with stream: false instead bridges the caller signal through EventStream._listenForAbort(), which aborts the runner's internal controller without forwarding the reason.
Reproduction
On current main at 1662d1e, extend the existing flow with abort regression in tests/lib/ChatCompletionRunFunctions.test.ts:
const controller = new AbortController();
const reason = new Error('stop after assistant message');
const runner = openai.chat.completions.runTools(params, {
signal: controller.signal,
});
runner.on('message', (message) => {
if (message.role === 'assistant') controller.abort(reason);
});
const error = await runner.done().catch((error) => error);
expect(error).toMatchObject({ cause: reason });
Current result: the non-streaming case fails because the final APIUserAbortError has no caller reason. The corresponding streaming flow with abort case passes the same assertion.
Root cause
EventStream._listenForAbort() currently does:
if (signal.aborted) {
this.controller.abort();
return;
}
const listener = () => this.controller.abort();
The internal controller therefore receives a default abort reason rather than signal.reason. The request layer can only preserve the reason it receives from that internal signal.
Expected behavior
Forward the caller's abort reason when bridging into the runner-owned controller, so convenience runners retain the same APIUserAbortError.cause semantics as direct requests. This should apply both when the signal is already aborted and when it aborts later.
Summary
The chat completion convenience runners lose the reason supplied to a caller
AbortSignalon the non-streaming path.A direct SDK request preserves
signal.reasonasAPIUserAbortError.cause, and the streaming runner path preserves it as well.runTools(..., { signal })withstream: falseinstead bridges the caller signal throughEventStream._listenForAbort(), which aborts the runner's internal controller without forwarding the reason.Reproduction
On current
mainat1662d1e, extend the existingflow with abortregression intests/lib/ChatCompletionRunFunctions.test.ts:Current result: the non-streaming case fails because the final
APIUserAbortErrorhas no caller reason. The corresponding streamingflow with abortcase passes the same assertion.Root cause
EventStream._listenForAbort()currently does:The internal controller therefore receives a default abort reason rather than
signal.reason. The request layer can only preserve the reason it receives from that internal signal.Expected behavior
Forward the caller's abort reason when bridging into the runner-owned controller, so convenience runners retain the same
APIUserAbortError.causesemantics as direct requests. This should apply both when the signal is already aborted and when it aborts later.