From dbc3deda34aeae93c654b9f75f54ec0cd609f58f Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Wed, 28 Oct 2026 07:59:50 -0400 Subject: [PATCH] fix(ai-openai, ai-groq, ai-grok): forward abortSignal to speech, transcription and image requests The adapters accepted the effective abort signal that generateSpeech, generateTranscription and generateImage compose, but never passed it to the OpenAI SDK or fetch, so cancelling or timing out did not stop the request. Forward it as the SDK request option or the fetch signal; calls without a signal are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .changeset/media-adapters-abort-signal.md | 7 ++ packages/ai-grok/src/adapters/image.ts | 9 +- .../ai-grok/src/adapters/transcription.ts | 1 + packages/ai-grok/src/adapters/tts.ts | 1 + packages/ai-grok/tests/abort-signal.test.ts | 115 +++++++++++++++++ .../ai-groq/src/adapters/transcription.ts | 1 + packages/ai-groq/src/adapters/tts.ts | 8 +- packages/ai-groq/tests/abort-signal.test.ts | 70 +++++++++++ packages/ai-openai/src/adapters/image.ts | 14 ++- .../ai-openai/src/adapters/transcription.ts | 20 ++- packages/ai-openai/src/adapters/tts.ts | 8 +- packages/ai-openai/tests/abort-signal.test.ts | 119 ++++++++++++++++++ 12 files changed, 361 insertions(+), 12 deletions(-) create mode 100644 .changeset/media-adapters-abort-signal.md create mode 100644 packages/ai-grok/tests/abort-signal.test.ts create mode 100644 packages/ai-groq/tests/abort-signal.test.ts create mode 100644 packages/ai-openai/tests/abort-signal.test.ts diff --git a/.changeset/media-adapters-abort-signal.md b/.changeset/media-adapters-abort-signal.md new file mode 100644 index 0000000000..7d6043314b --- /dev/null +++ b/.changeset/media-adapters-abort-signal.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-openai': patch +'@tanstack/ai-groq': patch +'@tanstack/ai-grok': patch +--- + +Cancelling `generateSpeech()`, `generateTranscription()` or `generateImage()` with `abortSignal` (or `timeout`) now cancels the in-flight provider request for the OpenAI, Groq and Grok adapters. They accepted the signal but never passed it to the SDK or `fetch`, so the request ran to completion and was billed. diff --git a/packages/ai-grok/src/adapters/image.ts b/packages/ai-grok/src/adapters/image.ts index 9a79787634..94ad0fca74 100644 --- a/packages/ai-grok/src/adapters/image.ts +++ b/packages/ai-grok/src/adapters/image.ts @@ -154,12 +154,18 @@ export class GrokImageAdapter< ...modelOptions, } as OpenAI_SDK.Images.ImageGenerateParamsNonStreaming + const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal + ? [{ signal: options.abortSignal }] + : [] try { options.logger.request( `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, { provider: this.name, model }, ) - const response = await this.client.images.generate(request) + const response = await this.client.images.generate( + request, + ...requestOptions, + ) const images: Array = (response.data ?? []).flatMap( (item): Array => { @@ -266,6 +272,7 @@ export class GrokImageAdapter< Authorization: `Bearer ${this.clientConfig.apiKey}`, }, body: JSON.stringify(request), + ...(options.abortSignal && { signal: options.abortSignal }), }, ) if (!response.ok) { diff --git a/packages/ai-grok/src/adapters/transcription.ts b/packages/ai-grok/src/adapters/transcription.ts index 8037b7eef8..e258e88688 100644 --- a/packages/ai-grok/src/adapters/transcription.ts +++ b/packages/ai-grok/src/adapters/transcription.ts @@ -106,6 +106,7 @@ export class GrokTranscriptionAdapter< Authorization: `Bearer ${this.apiKey}`, }, body: form, + ...(options.abortSignal && { signal: options.abortSignal }), }) if (!response.ok) { diff --git a/packages/ai-grok/src/adapters/tts.ts b/packages/ai-grok/src/adapters/tts.ts index 2688564320..f245ef6ed5 100644 --- a/packages/ai-grok/src/adapters/tts.ts +++ b/packages/ai-grok/src/adapters/tts.ts @@ -75,6 +75,7 @@ export class GrokSpeechAdapter< 'Content-Type': 'application/json', }, body: JSON.stringify(body), + ...(options.abortSignal && { signal: options.abortSignal }), }) if (!response.ok) { diff --git a/packages/ai-grok/tests/abort-signal.test.ts b/packages/ai-grok/tests/abort-signal.test.ts new file mode 100644 index 0000000000..90993404a0 --- /dev/null +++ b/packages/ai-grok/tests/abort-signal.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { GrokImageAdapter } from '../src/adapters/image' +import { GrokSpeechAdapter } from '../src/adapters/tts' +import { GrokTranscriptionAdapter } from '../src/adapters/transcription' + +const logger = resolveDebugOption(false) +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() +}) + +function mockFetch(body: unknown) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)), + text: () => Promise.resolve(''), + } as Partial as Response) + globalThis.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} + +class ImageAdapter extends GrokImageAdapter<'grok-imagine-image'> { + spyOnGenerate() { + return vi.spyOn(this.client.images, 'generate') + } +} + +describe('Grok media adapters forward the abort signal', () => { + it('passes it to fetch for speech', async () => { + const fetchMock = mockFetch({}) + const adapter = new GrokSpeechAdapter({ apiKey: 'xai-test' }, 'grok-tts') + const controller = new AbortController() + + await adapter.generateSpeech({ + model: 'grok-tts', + text: 'hello', + abortSignal: controller.signal, + logger, + }) + + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal) + }) + + it('passes it to fetch for transcription', async () => { + const fetchMock = mockFetch({ text: 'hello' }) + const adapter = new GrokTranscriptionAdapter( + { apiKey: 'xai-test' }, + 'grok-stt', + ) + const controller = new AbortController() + + await adapter.transcribe({ + model: 'grok-stt', + audio: new Blob([new Uint8Array([1])], { type: 'audio/mpeg' }), + abortSignal: controller.signal, + logger, + }) + + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal) + }) + + it('passes it to the SDK for image generation', async () => { + const adapter = new ImageAdapter( + { apiKey: 'xai-test' }, + 'grok-imagine-image', + ) + const generate = adapter.spyOnGenerate().mockResolvedValue({ + data: [{ url: 'https://example.test/a.png' }], + } as never) + const controller = new AbortController() + + await adapter.generateImages({ + model: 'grok-imagine-image', + prompt: 'a cat', + abortSignal: controller.signal, + logger, + }) + + const requestOptions = generate.mock.calls[0]?.[1] as + | { signal?: AbortSignal } + | undefined + expect(requestOptions?.signal).toBe(controller.signal) + }) + + it('passes it to fetch for image edits', async () => { + const fetchMock = mockFetch({ + data: [{ url: 'https://example.test/a.png' }], + }) + const adapter = new GrokImageAdapter( + { apiKey: 'xai-test' }, + 'grok-imagine-image', + ) + const controller = new AbortController() + + await adapter.generateImages({ + model: 'grok-imagine-image', + prompt: [ + { type: 'text', content: 'make it blue' }, + { + type: 'image', + source: { type: 'url', value: 'https://example.test/in.png' }, + }, + ], + abortSignal: controller.signal, + logger, + }) + + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal) + }) +}) diff --git a/packages/ai-groq/src/adapters/transcription.ts b/packages/ai-groq/src/adapters/transcription.ts index 2ac6999b2a..1dc6ef8868 100644 --- a/packages/ai-groq/src/adapters/transcription.ts +++ b/packages/ai-groq/src/adapters/transcription.ts @@ -157,6 +157,7 @@ export class GroqTranscriptionAdapter< Authorization: `Bearer ${this.apiKey}`, }, body: form, + ...(options.abortSignal && { signal: options.abortSignal }), }) if (!response.ok) { diff --git a/packages/ai-groq/src/adapters/tts.ts b/packages/ai-groq/src/adapters/tts.ts index 64d7a55d88..2a538278d2 100644 --- a/packages/ai-groq/src/adapters/tts.ts +++ b/packages/ai-groq/src/adapters/tts.ts @@ -65,12 +65,18 @@ export class GroqTTSAdapter extends BaseTTSAdapter< ...(modelOptions ?? {}), } + const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal + ? [{ signal: options.abortSignal }] + : [] try { options.logger.request( `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, { provider: this.name, model }, ) - const response = await this.client.audio.speech.create(request) + const response = await this.client.audio.speech.create( + request, + ...requestOptions, + ) const arrayBuffer = await response.arrayBuffer() const base64 = arrayBufferToBase64(arrayBuffer) diff --git a/packages/ai-groq/tests/abort-signal.test.ts b/packages/ai-groq/tests/abort-signal.test.ts new file mode 100644 index 0000000000..8ab4d416e4 --- /dev/null +++ b/packages/ai-groq/tests/abort-signal.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { GroqTTSAdapter } from '../src/adapters/tts' +import { GroqTranscriptionAdapter } from '../src/adapters/transcription' +import type { GroqTTSModel } from '../src/model-meta' + +const logger = resolveDebugOption(false) +const originalFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = originalFetch + vi.restoreAllMocks() +}) + +class SpeechAdapter< + TModel extends GroqTTSModel, +> extends GroqTTSAdapter { + spyOnCreate() { + return vi.spyOn(this.client.audio.speech, 'create') + } +} + +describe('Groq media adapters forward the abort signal', () => { + it('passes it to the SDK for speech', async () => { + const adapter = new SpeechAdapter( + { apiKey: 'test-key' }, + 'canopylabs/orpheus-v1-english', + ) + const create = adapter.spyOnCreate().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)), + } as never) + const controller = new AbortController() + + await adapter.generateSpeech({ + model: 'canopylabs/orpheus-v1-english', + text: 'hello', + abortSignal: controller.signal, + logger, + }) + + const requestOptions = create.mock.calls[0]?.[1] as + | { signal?: AbortSignal } + | undefined + expect(requestOptions?.signal).toBe(controller.signal) + }) + + it('passes it to fetch for transcription', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ text: 'hello' }), + text: () => Promise.resolve(''), + } as Partial as Response) + globalThis.fetch = fetchMock as unknown as typeof fetch + const adapter = new GroqTranscriptionAdapter( + { apiKey: 'test-key' }, + 'whisper-large-v3', + ) + const controller = new AbortController() + + await adapter.transcribe({ + model: 'whisper-large-v3', + audio: new Blob([new Uint8Array([1])], { type: 'audio/mpeg' }), + abortSignal: controller.signal, + logger, + }) + + expect(fetchMock.mock.calls[0]?.[1]?.signal).toBe(controller.signal) + }) +}) diff --git a/packages/ai-openai/src/adapters/image.ts b/packages/ai-openai/src/adapters/image.ts index fecaf83d4e..fff5d460ad 100644 --- a/packages/ai-openai/src/adapters/image.ts +++ b/packages/ai-openai/src/adapters/image.ts @@ -131,15 +131,21 @@ export class OpenAIImageAdapter< request.size = size } + const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal + ? [{ signal: options.abortSignal }] + : [] try { options.logger.request( `activity=image provider=${this.name} model=${model} n=${request.n ?? 1} size=${request.size ?? 'default'}`, { provider: this.name, model }, ) - const response = await this.client.images.generate({ - ...request, - stream: false, - }) + const response = await this.client.images.generate( + { + ...request, + stream: false, + }, + ...requestOptions, + ) const images: Array = (response.data ?? []).flatMap( (item): Array => { diff --git a/packages/ai-openai/src/adapters/transcription.ts b/packages/ai-openai/src/adapters/transcription.ts index 1d9ca3c176..927a76b912 100644 --- a/packages/ai-openai/src/adapters/transcription.ts +++ b/packages/ai-openai/src/adapters/transcription.ts @@ -163,6 +163,9 @@ export class OpenAITranscriptionAdapter< ): Promise { const { model, language } = options + const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal + ? [{ signal: options.abortSignal }] + : [] try { const { request, responseMode } = this.buildTranscriptionRequest(options) @@ -173,6 +176,7 @@ export class OpenAITranscriptionAdapter< if (responseMode === 'diarized') { const response = (await this.client.audio.transcriptions.create( request, + ...requestOptions, )) as OpenAI_SDK.Audio.TranscriptionDiarized // Guard the cast: a proxy/gateway or API change that returns a @@ -213,10 +217,13 @@ export class OpenAITranscriptionAdapter< } if (responseMode === 'verbose') { - const response = (await this.client.audio.transcriptions.create({ - ...request, - response_format: 'verbose_json', - })) as OpenAI_SDK.Audio.Transcriptions.TranscriptionVerbose + const response = (await this.client.audio.transcriptions.create( + { + ...request, + response_format: 'verbose_json', + }, + ...requestOptions, + )) as OpenAI_SDK.Audio.Transcriptions.TranscriptionVerbose // `TranscriptionResult` declares optional fields without `| undefined`, // so under exactOptionalPropertyTypes we must omit absent fields rather @@ -255,7 +262,10 @@ export class OpenAITranscriptionAdapter< } } - const response = await this.client.audio.transcriptions.create(request) + const response = await this.client.audio.transcriptions.create( + request, + ...requestOptions, + ) const usage = typeof response === 'string' diff --git a/packages/ai-openai/src/adapters/tts.ts b/packages/ai-openai/src/adapters/tts.ts index cc366abf39..9c36638fc9 100644 --- a/packages/ai-openai/src/adapters/tts.ts +++ b/packages/ai-openai/src/adapters/tts.ts @@ -71,12 +71,18 @@ export class OpenAITTSAdapter< ...(modelOptions ?? {}), } + const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal + ? [{ signal: options.abortSignal }] + : [] try { options.logger.request( `activity=tts provider=${this.name} model=${model} format=${request.response_format ?? 'default'} voice=${request.voice}`, { provider: this.name, model }, ) - const response = await this.client.audio.speech.create(request) + const response = await this.client.audio.speech.create( + request, + ...requestOptions, + ) // Convert response to base64. Buffer is Node-only; use atob fallback in // browser/edge runtimes where the SDK can run. diff --git a/packages/ai-openai/tests/abort-signal.test.ts b/packages/ai-openai/tests/abort-signal.test.ts new file mode 100644 index 0000000000..7adae9518b --- /dev/null +++ b/packages/ai-openai/tests/abort-signal.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { OpenAIImageAdapter } from '../src/adapters/image' +import { OpenAITranscriptionAdapter } from '../src/adapters/transcription' +import { OpenAITTSAdapter } from '../src/adapters/tts' +import type { + OpenAIImageModel, + OpenAITTSModel, + OpenAITranscriptionModel, +} from '../src/model-meta' + +const logger = resolveDebugOption(false) + +class SpeechAdapter< + TModel extends OpenAITTSModel, +> extends OpenAITTSAdapter { + spyOnCreate() { + return vi.spyOn(this.client.audio.speech, 'create') + } +} + +class TranscriptionAdapter< + TModel extends OpenAITranscriptionModel, +> extends OpenAITranscriptionAdapter { + spyOnCreate() { + return vi.spyOn(this.client.audio.transcriptions, 'create') + } +} + +class ImageAdapter< + TModel extends OpenAIImageModel, +> extends OpenAIImageAdapter { + spyOnGenerate() { + return vi.spyOn(this.client.images, 'generate') + } +} + +function expectForwarded( + call: ReadonlyArray | undefined, + controller: AbortController, +) { + const requestOptions = call?.[1] as { signal?: AbortSignal } | undefined + expect(requestOptions?.signal).toBe(controller.signal) +} + +describe('OpenAI media adapters forward the abort signal to the SDK', () => { + it('forwards it for speech', async () => { + const adapter = new SpeechAdapter({ apiKey: 'test-key' }, 'tts-1') + const create = adapter.spyOnCreate().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)), + } as never) + const controller = new AbortController() + + await adapter.generateSpeech({ + model: 'tts-1', + text: 'hello', + abortSignal: controller.signal, + logger, + }) + + expectForwarded(create.mock.calls[0], controller) + }) + + it('forwards it for image generation', async () => { + const adapter = new ImageAdapter({ apiKey: 'test-key' }, 'gpt-image-1') + const generate = adapter.spyOnGenerate().mockResolvedValue({ + data: [{ b64_json: 'aGk=' }], + } as never) + const controller = new AbortController() + + await adapter.generateImages({ + model: 'gpt-image-1', + prompt: 'a cat', + abortSignal: controller.signal, + logger, + }) + + expectForwarded(generate.mock.calls[0], controller) + }) + + it.each([ + ['plain', 'whisper-1', undefined, { text: 'hello' }], + ['verbose', 'whisper-1', 'verbose_json', { text: 'hello', segments: [] }], + [ + 'diarized', + 'gpt-4o-transcribe-diarize', + undefined, + { text: 'hello', segments: [] }, + ], + ] as const)( + 'forwards it for %s transcription', + async (_mode, model, responseFormat, reply) => { + const adapter = new TranscriptionAdapter({ apiKey: 'test-key' }, model) + const create = adapter.spyOnCreate().mockResolvedValue(reply as never) + const controller = new AbortController() + + await adapter.transcribe({ + model, + audio: new ArrayBuffer(8), + ...(responseFormat !== undefined && { responseFormat }), + abortSignal: controller.signal, + logger, + }) + + expectForwarded(create.mock.calls[0], controller) + }, + ) + + it('sends no extra argument when the caller passes no signal', async () => { + const adapter = new SpeechAdapter({ apiKey: 'test-key' }, 'tts-1') + const create = adapter.spyOnCreate().mockResolvedValue({ + arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)), + } as never) + + await adapter.generateSpeech({ model: 'tts-1', text: 'hello', logger }) + + expect(create.mock.calls[0]).toHaveLength(1) + }) +})