Skip to content

Commit 682665e

Browse files
fix(xai): bridge the abort signal only after request construction
Review follow-up: mapToolChoice throws for malformed allowed_tools entries, and the abort bridge cleanup only covers the request that follows. The bridge now registers after the whole request body (including the tool-choice mapping) is built, so a construction failure cannot leave a listener on metadata.abortSignal. The malformed-entry test now passes an abortSignal and asserts addEventListener was never called. xai.spec.ts 49 passed; tsc clean.
1 parent a203c7a commit 682665e

2 files changed

Lines changed: 15 additions & 9 deletions

File tree

‎src/api/providers/__tests__/xai.spec.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,8 +397,13 @@ describe("XAIHandler", () => {
397397
{ type: "function", function: { name: 123 } },
398398
]
399399
for (const entry of malformedEntries) {
400+
// The mapping throws during request construction; the abort bridge must not have
401+
// registered a listener yet, or a malformed tool choice would leak it.
402+
const abortController = new AbortController()
403+
const addListenerSpy = vi.spyOn(abortController.signal, "addEventListener")
400404
const stream = handler.createMessage("test prompt", [], {
401405
taskId: "test-task-id",
406+
abortSignal: abortController.signal,
402407
tools: testTools,
403408
tool_choice: {
404409
type: "allowed_tools",
@@ -411,6 +416,8 @@ describe("XAIHandler", () => {
411416
await expect(stream.next()).rejects.toThrow(
412417
'allowed_tools entry of type "function" is missing a valid function name',
413418
)
419+
expect(addListenerSpy).not.toHaveBeenCalled()
420+
addListenerSpy.mockRestore()
414421
}
415422
expect(mockResponsesCreate.mock.calls.length).toBe(callsBefore)
416423
})

‎src/api/providers/xai.ts‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,15 +159,6 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
159159
const toolChoice = metadata?.tool_choice
160160
const parallelToolCalls = metadata?.parallelToolCalls
161161

162-
// Bridge the external abort signal from request metadata into a per-request
163-
// controller so the SDK call is cancelled when the owning request is aborted
164-
// (or when the signal is already aborted). Without an external signal the
165-
// client-level timeout configured in the constructor remains the only
166-
// cancellation mechanism, preserving the existing behavior.
167-
const externalAbortSignal = metadata?.abortSignal
168-
const { signal: abortSignal, dispose: removeExternalAbortListener } =
169-
bridgeExternalAbortSignal(externalAbortSignal)
170-
171162
// Build request options
172163
const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = {
173164
model: model.id,
@@ -197,6 +188,14 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
197188
requestBody.reasoning = { effort: model.reasoning.reasoning_effort }
198189
}
199190

191+
// Bridge the external abort signal only after every throw-prone part of the request
192+
// construction (notably mapToolChoice, which rejects malformed allowed_tools entries)
193+
// has run: the bridge's cleanup covers the request below, not construction failures,
194+
// so registering earlier would leak a listener on metadata.abortSignal.
195+
const externalAbortSignal = metadata?.abortSignal
196+
const { signal: abortSignal, dispose: removeExternalAbortListener } =
197+
bridgeExternalAbortSignal(externalAbortSignal)
198+
200199
try {
201200
let stream: AsyncIterable<any>
202201
try {

0 commit comments

Comments
 (0)