Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-bedrock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-bedrock': patch
---

Aborting a chat now cancels the Converse request to Bedrock, for streaming and for structured output. Before, the request kept running.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-byteplus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-byteplus': patch
---

Aborting an image, video, speech, or transcription call now cancels the BytePlus request. Before, the request kept running and was billed. The image and video requests still stop at the configured `timeout`.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-elevenlabs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-elevenlabs': patch
---

An abort now cancels the ElevenLabs request for speech, dialogue, music, sound effects, and transcription. Before, the adapters did not give the abort signal to the SDK, so the request ran to the end and was billed.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-fal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-fal': patch
---

An abort now asks fal to cancel the job for image, audio, speech, and transcription. fal stops a queued job. For a job that already runs, the model app decides whether it stops. The speech audio download also stops on abort.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-gemini.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-gemini': patch
---

Aborting a call now cancels the Gemini request for Lyria audio, speech, Veo and Omni video creation, the Veo input image download, and chat with agentic video. Before, the request kept running and was billed.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-grok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-grok': patch
---

Aborting `generateImage()`, `generateSpeech()`, `generateTranscription()` or `generateVideo()` now stops the xAI request. Before, the call rejected but the request kept running and was billed.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-groq.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-groq': patch
---

Aborting `generateSpeech()` or `generateTranscription()` now stops the Groq request. Before, the call rejected but the request kept running and was billed.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-mistral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-mistral': patch
---

Aborting a chat now cancels the request to Mistral, for streaming and for structured output. Structured output keeps the `timeoutMs` limit when you pass a signal.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-ollama.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-ollama': patch
---

Aborting a chat now stops the Ollama stream when you pass your own `Ollama` client. Structured output with your own client still runs to the end, because `ollama` has no per-call signal.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-openai.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-openai': patch
---

Aborting `generateImage()`, `generateSpeech()`, `generateTranscription()` or `generateVideo()` now stops the OpenAI request. Before, the call rejected but the request kept running and was billed. This also covers the image fetch for an `allowUrlFetch` image input.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-openrouter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-openrouter': patch
---

Aborting an image or video call now cancels the OpenRouter request. Before, the request kept running and was billed.
5 changes: 5 additions & 0 deletions .changeset/abort-signal-ai-vercel-gateway.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/ai-vercel-gateway': patch
---

Aborting `generateImage()` now stops the Vercel AI Gateway request. Before, the call rejected but the request kept running and was billed.
12 changes: 7 additions & 5 deletions packages/ai-bedrock/src/adapters/converse-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,13 @@ export class BedrockConverseTextAdapter<

protected async sendStream(
input: ConverseStreamCommandInput,
signal?: AbortSignal | null,
): Promise<AsyncIterable<ConverseStreamOutput>> {
const { ConverseStreamCommand } = await this.importBedrockRuntime()
const client = await this.getClient()
const command = new ConverseStreamCommand(input)
addRestoreDocuments(command, input)
const res = await client.send(command)
const res = await client.send(command, { abortSignal: signal ?? undefined })
if (!res.stream) {
throw new Error('Bedrock Converse: empty stream response')
}
Expand All @@ -255,12 +256,13 @@ export class BedrockConverseTextAdapter<

protected async send(
input: ConverseCommandInput,
signal?: AbortSignal | null,
): Promise<ConverseCommandOutput> {
const { ConverseCommand } = await this.importBedrockRuntime()
const client = await this.getClient()
const command = new ConverseCommand(input)
addRestoreDocuments(command, input)
return client.send(command)
return client.send(command, { abortSignal: signal ?? undefined })
}

// ---------------------------------------------------------------------------
Expand All @@ -281,7 +283,7 @@ export class BedrockConverseTextAdapter<
if (!input.toolConfig && input.messages) {
input.messages = toolBlocksToText(input.messages)
}
const stream = await this.sendStream(input)
const stream = await this.sendStream(input, options.request?.signal)
for await (const chunk of processConverseStream(
stream,
() => this.generateId(),
Expand Down Expand Up @@ -344,7 +346,7 @@ export class BedrockConverseTextAdapter<
...this.buildInput(requestOptions),
toolConfig: buildStructuredToolConfig(outputSchema),
}
const res = await this.send(input)
const res = await this.send(input, chatOptions.request?.signal)
// A forced tool call cut off at the output cap carries partial input,
// which would read like a schema failure (issue #1426).
if (res.stopReason === 'max_tokens') {
Expand Down Expand Up @@ -408,7 +410,7 @@ export class BedrockConverseTextAdapter<
...this.buildInput(requestOptions),
toolConfig: buildStructuredToolConfig(outputSchema),
}
const stream = await this.sendStream(input)
const stream = await this.sendStream(input, chatOptions.request?.signal)

// The forced tool streams its `input` as partial-JSON fragments inside
// `contentBlockDelta.delta.toolUse.input`. We surface them as
Expand Down
177 changes: 177 additions & 0 deletions packages/ai-bedrock/tests/abort-signal-wire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { describe, expect, it, vi } from 'vitest'
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
import { EventType } from '@tanstack/ai'
import { resolveDebugOption } from '@tanstack/ai/adapter-internals'
import { BedrockConverseTextAdapter } from '../src/adapters/converse-text'
import { createBedrockResponsesText } from '../src/adapters/responses-text'
import { createBedrockChat } from '../src/adapters/text'

const logger = resolveDebugOption(false)

/**
* A fake `fetch` that never answers. It rejects at once when it gets no abort
* signal. Otherwise it rejects only when its signal aborts.
*/
function hangingFetch() {
const signals: Array<AbortSignal> = []
const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const signal =
init?.signal ?? (input instanceof Request ? input.signal : undefined)
if (!signal) return Promise.reject(new Error('fetch got no abort signal'))
signals.push(signal)
return new Promise<Response>((_resolve, reject) => {
if (signal.aborted) reject(signal.reason)
signal.addEventListener('abort', () => reject(signal.reason))
})
})
return { fetch, signals }
}

/** Starts the call, aborts the caller, and expects the request to abort. */
async function expectAbortReachesFetch(
wire: ReturnType<typeof hangingFetch>,
start: (signal: AbortSignal) => Promise<unknown>,
) {
const controller = new AbortController()
const run = start(controller.signal)
run.catch(() => {})
await vi.waitFor(() => expect(wire.fetch).toHaveBeenCalled())
controller.abort()
await vi.waitFor(
() => expect(wire.signals.at(-1)?.aborted, 'request aborted').toBe(true),
{ timeout: 500 },
)
await expect(run).rejects.toBeDefined()
}

/** Drains a stream. A RUN_ERROR chunk counts as a rejection. */
async function drain(stream: AsyncIterable<{ type: string }>) {
for await (const chunk of stream) {
if (chunk.type === EventType.RUN_ERROR) throw new Error('RUN_ERROR')
}
}

const messages = [{ role: 'user' as const, content: 'Hi' }]
const outputSchema = { type: 'object', properties: {} }
const converseModel = 'us.amazon.nova-pro-v1:0'
const mantleModel = 'openai.gpt-oss-120b-1:0'

/** A Converse adapter whose AWS SDK client sends through the fake fetch. */
function converseAdapter(wire: ReturnType<typeof hangingFetch>) {
const client = new BedrockRuntimeClient({
region: 'us-east-1',
maxAttempts: 1,
credentials: { accessKeyId: 'test', secretAccessKey: 'test' },
requestHandler: {
handle: (_request: unknown, options?: { abortSignal?: AbortSignal }) =>
wire.fetch('https://bedrock.test', {
...(options?.abortSignal && { signal: options.abortSignal }),
}),
},
})
class WireAdapter extends BedrockConverseTextAdapter<typeof converseModel> {
protected override async getClient(): Promise<BedrockRuntimeClient> {
return client
}
}
return new WireAdapter({ apiKey: 'test-key' }, converseModel)
}

describe('Bedrock Converse aborts the provider request', () => {
it('chatStream', async () => {
const wire = hangingFetch()
const adapter = converseAdapter(wire)
await expectAbortReachesFetch(wire, (signal) =>
drain(
adapter.chatStream({
threadId: 'thread-1',
logger,
model: converseModel,
messages,
request: { signal },
}),
),
)
})

it('structuredOutput', async () => {
const wire = hangingFetch()
const adapter = converseAdapter(wire)
await expectAbortReachesFetch(wire, (signal) =>
adapter.structuredOutput({
chatOptions: {
threadId: 'thread-1',
logger,
model: converseModel,
messages,
request: { signal },
},
outputSchema,
}),
)
})

it('structuredOutputStream', async () => {
const wire = hangingFetch()
const adapter = converseAdapter(wire)
await expectAbortReachesFetch(wire, (signal) =>
drain(
adapter.structuredOutputStream({
chatOptions: {
threadId: 'thread-1',
logger,
model: converseModel,
messages,
request: { signal },
},
outputSchema,
}),
),
)
})
})

describe.each([
['Mantle chat', createBedrockChat],
['Mantle responses', createBedrockResponsesText],
] as const)('Bedrock %s aborts the provider request', (_name, create) => {
function adapter(wire: ReturnType<typeof hangingFetch>) {
return create(mantleModel, 'test-key', {
fetch: wire.fetch,
maxRetries: 0,
})
}

it('chatStream', async () => {
const wire = hangingFetch()
const a = adapter(wire)
await expectAbortReachesFetch(wire, (signal) =>
drain(
a.chatStream({
threadId: 'thread-1',
logger,
model: mantleModel,
messages,
request: { signal },
}),
),
)
})

it('structuredOutput', async () => {
const wire = hangingFetch()
const a = adapter(wire)
await expectAbortReachesFetch(wire, (signal) =>
a.structuredOutput({
chatOptions: {
threadId: 'thread-1',
logger,
model: mantleModel,
messages,
request: { signal },
},
outputSchema,
}),
)
})
})
5 changes: 4 additions & 1 deletion packages/ai-byteplus/src/adapters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ export class BytePlusImageAdapter<
)

const fetchImpl = this.clientConfig.fetch ?? fetch
const signal = bytePlusTimeoutSignal(this.clientConfig.timeout)
const signal = bytePlusTimeoutSignal(
this.clientConfig.timeout,
options.abortSignal,
)
const response = await fetchImpl(
`${this.clientConfig.baseURL}/images/generations`,
{
Expand Down
1 change: 1 addition & 0 deletions packages/ai-byteplus/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export class BytePlusTranscriptionAdapter<
[BYTEPLUS_ASR_RESOURCE_HEADER]: BYTEPLUS_ASR_RESOURCE_ID,
}),
body: JSON.stringify(body),
...(options.abortSignal && { signal: options.abortSignal }),
},
)

Expand Down
1 change: 1 addition & 0 deletions packages/ai-byteplus/src/adapters/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export class BytePlusTTSAdapter<
'X-Api-Request-Id': newRequestId(),
}),
body: JSON.stringify(body),
...(options.abortSignal && { signal: options.abortSignal }),
},
)

Expand Down
12 changes: 7 additions & 5 deletions packages/ai-byteplus/src/adapters/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,10 @@ export class BytePlusVideoAdapter<
private async request(
path: string,
init?: Omit<RequestInit, 'headers'>,
abortSignal?: AbortSignal,
): Promise<{ response: Response; body: unknown }> {
const fetchImpl = this.clientConfig.fetch ?? fetch
const signal = bytePlusTimeoutSignal(this.clientConfig.timeout)
const signal = bytePlusTimeoutSignal(this.clientConfig.timeout, abortSignal)
const response = await fetchImpl(`${this.clientConfig.baseURL}${path}`, {
...init,
...(signal && { signal }),
Expand Down Expand Up @@ -462,10 +463,11 @@ export class BytePlusVideoAdapter<
{ provider: this.name, model },
)

const { response, body } = await this.request(TASKS_PATH, {
method: 'POST',
body: JSON.stringify(request),
})
const { response, body } = await this.request(
TASKS_PATH,
{ method: 'POST', body: JSON.stringify(request) },
options.abortSignal,
)
if (!response.ok) {
throw bytePlusArkError(response.status, body, 'video task creation')
}
Expand Down
14 changes: 11 additions & 3 deletions packages/ai-byteplus/src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,13 +232,21 @@ function applyReservedHeaders(
*
* `maxRetries` has no equivalent here and stays SDK-path-only; it is
* documented as such on {@link BytePlusArkConfig}.
*
* The caller's `abortSignal`, when given, is combined with the timeout, so an
* abort cancels the request too.
*/
export function bytePlusTimeoutSignal(
timeout: number | undefined,
abortSignal?: AbortSignal,
): AbortSignal | undefined {
return typeof timeout === 'number' && timeout > 0
? AbortSignal.timeout(timeout)
: undefined
const timeoutSignal =
typeof timeout === 'number' && timeout > 0
? AbortSignal.timeout(timeout)
: undefined
return timeoutSignal && abortSignal
? AbortSignal.any([timeoutSignal, abortSignal])
: (timeoutSignal ?? abortSignal)
}

/**
Expand Down
Loading
Loading