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) + }) +})