Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/media-adapters-abort-signal.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion packages/ai-grok/src/adapters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeneratedImage> = (response.data ?? []).flatMap(
(item): Array<GeneratedImage> => {
Expand Down Expand Up @@ -266,6 +272,7 @@ export class GrokImageAdapter<
Authorization: `Bearer ${this.clientConfig.apiKey}`,
},
body: JSON.stringify(request),
...(options.abortSignal && { signal: options.abortSignal }),
},
)
if (!response.ok) {
Expand Down
1 change: 1 addition & 0 deletions packages/ai-grok/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export class GrokTranscriptionAdapter<
Authorization: `Bearer ${this.apiKey}`,
},
body: form,
...(options.abortSignal && { signal: options.abortSignal }),
})

if (!response.ok) {
Expand Down
1 change: 1 addition & 0 deletions packages/ai-grok/src/adapters/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export class GrokSpeechAdapter<
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
...(options.abortSignal && { signal: options.abortSignal }),
})

if (!response.ok) {
Expand Down
115 changes: 115 additions & 0 deletions packages/ai-grok/tests/abort-signal.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(body),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)),
text: () => Promise.resolve(''),
} as Partial<Response> 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)
})
})
1 change: 1 addition & 0 deletions packages/ai-groq/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class GroqTranscriptionAdapter<
Authorization: `Bearer ${this.apiKey}`,
},
body: form,
...(options.abortSignal && { signal: options.abortSignal }),
})

if (!response.ok) {
Expand Down
8 changes: 7 additions & 1 deletion packages/ai-groq/src/adapters/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,18 @@ export class GroqTTSAdapter<TModel extends GroqTTSModel> 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)
Expand Down
70 changes: 70 additions & 0 deletions packages/ai-groq/tests/abort-signal.test.ts
Original file line number Diff line number Diff line change
@@ -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<TModel> {
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<typeof fetch>().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ text: 'hello' }),
text: () => Promise.resolve(''),
} as Partial<Response> 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)
})
})
14 changes: 10 additions & 4 deletions packages/ai-openai/src/adapters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeneratedImage> = (response.data ?? []).flatMap(
(item): Array<GeneratedImage> => {
Expand Down
20 changes: 15 additions & 5 deletions packages/ai-openai/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export class OpenAITranscriptionAdapter<
): Promise<TranscriptionResult> {
const { model, language } = options

const requestOptions: [] | [{ signal: AbortSignal }] = options.abortSignal
? [{ signal: options.abortSignal }]
: []
try {
const { request, responseMode } = this.buildTranscriptionRequest(options)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
8 changes: 7 additions & 1 deletion packages/ai-openai/src/adapters/tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading