Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/activity-abort-timeout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai': minor
'@tanstack/ai-fal': minor
---

feat(ai): add `timeout` and `abortSignal` to media generation activities

Media activities (`generateImage`, `generateAudio`, `generateVideo`, `generateSpeech`, `generateTranscription`, and `summarize`) now accept optional `timeout` and `abortSignal`. Core composes them into a request-specific effective signal, races the adapter call so hung providers reject, clears timeout resources on settle, and routes aborts to middleware `onAbort` (not `onError`).

`@tanstack/ai-fal` forwards the signal to `fal.subscribe()` / `fal.queue.submit()` per request — never via global `fal.config()` — so concurrent generations stay isolated.
6 changes: 5 additions & 1 deletion packages/ai-fal/src/adapters/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export class FalAudioAdapter<TModel extends FalModel> extends BaseAudioAdapter<
})
try {
const input = this.buildInput(options)
const result = await fal.subscribe(this.model, { input })
// Request-specific abortSignal only — not fal.config() (global).
const result = await fal.subscribe(this.model, {
input,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
})
return this.transformResponse(result)
} catch (error) {
logger.errors('fal.generateAudio fatal', {
Expand Down
7 changes: 6 additions & 1 deletion packages/ai-fal/src/adapters/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ export class FalImageAdapter<TModel extends FalModel> extends BaseImageAdapter<

try {
const input = this.buildInput(options, resolved)
const result = await fal.subscribe(this.model, { input })
// Pass request-specific abortSignal only — never via fal.config(), which
// is global and would cancel concurrent generations from other calls.
const result = await fal.subscribe(this.model, {
input,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
})
return this.transformResponse(result)
} catch (error) {
logger.errors('fal.generateImage fatal', {
Expand Down
6 changes: 5 additions & 1 deletion packages/ai-fal/src/adapters/speech.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ export class FalSpeechAdapter<TModel extends FalModel> extends BaseTTSAdapter<
})
try {
const input = this.buildInput(options)
const result = await fal.subscribe(this.model, { input })
// Request-specific abortSignal only — not fal.config() (global).
const result = await fal.subscribe(this.model, {
input,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
})
return await this.transformResponse(result)
} catch (error) {
logger.errors('fal.generateSpeech fatal', {
Expand Down
6 changes: 5 additions & 1 deletion packages/ai-fal/src/adapters/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ export class FalTranscriptionAdapter<
)
try {
const input = this.buildInput(options)
const result = await fal.subscribe(this.model, { input })
// Request-specific abortSignal only — not fal.config() (global).
const result = await fal.subscribe(this.model, {
input,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
})
return this.transformResponse(result)
} catch (error) {
logger.errors('fal.generateTranscription fatal', {
Expand Down
4 changes: 3 additions & 1 deletion packages/ai-fal/src/adapters/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,11 @@ export class FalVideoAdapter<TModel extends FalModel> extends BaseVideoAdapter<
...(duration ? { duration } : {}),
} as FalModelInput<TModel>

// Submit to queue and get request ID
// Submit to queue and get request ID. Request-specific abortSignal only —
// never via fal.config() (global; would cancel concurrent jobs).
const { request_id } = await fal.queue.submit(this.model, {
input,
...(options.abortSignal ? { abortSignal: options.abortSignal } : {}),
})

return {
Expand Down
44 changes: 44 additions & 0 deletions packages/ai-fal/tests/image-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,50 @@ describe('Fal Image Adapter', () => {
})
})

it('forwards request-specific abortSignal to fal.subscribe()', async () => {
const mockResponse = createMockImageResponse([
{ url: 'https://fal.media/files/image.png' },
])
mockSubscribe.mockResolvedValueOnce(mockResponse)

const adapter = createAdapter()
const controller = new AbortController()

await generateImage({
adapter,
prompt: 'A landscape',
abortSignal: controller.signal,
})

expect(mockSubscribe).toHaveBeenCalledTimes(1)
const [, options] = mockSubscribe.mock.calls[0]!
expect(options.abortSignal).toBeInstanceOf(AbortSignal)
// Must be request-scoped options, not a side effect of fal.config().
expect(mockConfig).toHaveBeenCalled()
for (const call of mockConfig.mock.calls) {
expect(call[0]).not.toHaveProperty('abortSignal')
}
})
Comment on lines +167 to +175

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Verify caller cancellation propagation.

The assertion only verifies the signal type. A new unrelated signal would pass this test.

Abort controller and verify that the signal captured from fal.subscribe() becomes aborted with the caller reason.

Proposed test change
     const [, options] = mockSubscribe.mock.calls[0]!
     expect(options.abortSignal).toBeInstanceOf(AbortSignal)
+    controller.abort('caller cancelled')
+    expect(options.abortSignal.aborted).toBe(true)
+    expect(options.abortSignal.reason).toBe('caller cancelled')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(mockSubscribe).toHaveBeenCalledTimes(1)
const [, options] = mockSubscribe.mock.calls[0]!
expect(options.abortSignal).toBeInstanceOf(AbortSignal)
// Must be request-scoped options, not a side effect of fal.config().
expect(mockConfig).toHaveBeenCalled()
for (const call of mockConfig.mock.calls) {
expect(call[0]).not.toHaveProperty('abortSignal')
}
})
expect(mockSubscribe).toHaveBeenCalledTimes(1)
const [, options] = mockSubscribe.mock.calls[0]!
expect(options.abortSignal).toBeInstanceOf(AbortSignal)
controller.abort('caller cancelled')
expect(options.abortSignal.aborted).toBe(true)
expect(options.abortSignal.reason).toBe('caller cancelled')
// Must be request-scoped options, not a side effect of fal.config().
expect(mockConfig).toHaveBeenCalled()
for (const call of mockConfig.mock.calls) {
expect(call[0]).not.toHaveProperty('abortSignal')
}
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-fal/tests/image-adapter.test.ts` around lines 167 - 175,
Strengthen the cancellation test around mockSubscribe by capture the abort
signal passed in options, abort the existing caller controller with a specific
reason, and assert that the captured signal is aborted with that reason. Keep
the existing request-scoped options and fal.config() assertions unchanged.


it('forwards activity timeout as abortSignal to fal.subscribe()', async () => {
const mockResponse = createMockImageResponse([
{ url: 'https://fal.media/files/image.png' },
])
mockSubscribe.mockResolvedValueOnce(mockResponse)

const adapter = createAdapter()

await generateImage({
adapter,
prompt: 'A landscape',
timeout: 60_000,
})

const [, options] = mockSubscribe.mock.calls[0]!
expect(options.abortSignal).toBeInstanceOf(AbortSignal)
expect(options.abortSignal.aborted).toBe(false)
})

it('passes custom image_size through model options', async () => {
const mockResponse = createMockImageResponse([
{ url: 'https://fal.media/files/image.png' },
Expand Down
52 changes: 47 additions & 5 deletions packages/ai/src/activities/generateAudio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ import { resolveDebugOption } from '../../logger/resolve'
import {
applyGenerationResultTransforms,
createGenerationContext,
runGenerationAbort,
runGenerationError,
runGenerationFinish,
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
import {
abortReasonMessage,
createActivityAbortControls,
isActivityAbortError,
raceWithAbort,
} from '../../utilities/activity-abort'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type { GenerationMiddleware } from '../middleware/types'
Expand Down Expand Up @@ -89,6 +96,18 @@ export interface AudioActivityOptions<
threadId?: string
/** Stable run id for correlating this run when persisted. */
runId?: string
/**
* Maximum duration of this activity invocation in milliseconds.
* No SDK-wide default — choose a value suitable for the provider and job.
* Composed with {@link abortSignal}; the first abort wins.
*/
timeout?: number
/**
* Caller cancellation signal (request disconnects, job/runtime cancellation).
* Composed with {@link timeout} into an effective signal forwarded to the
* adapter. Request-specific — not stored on global provider client config.
*/
abortSignal?: AbortSignal
}

// ===========================
Expand Down Expand Up @@ -167,12 +186,18 @@ async function runGenerateAudio<
middleware,
threadId,
runId,
timeout,
abortSignal: callerAbortSignal,
...rest
} = options
const model = adapter.model
const requestId = createId('audio')
const startTime = Date.now()
const logger: InternalLogger = resolveDebugOption(options.debug)
const abortControls = createActivityAbortControls({
timeout,
abortSignal: callerAbortSignal,
})
const providerName =
(adapter as { name?: string; provider?: string }).provider ??
(adapter as { name?: string }).name ??
Expand Down Expand Up @@ -208,7 +233,16 @@ async function runGenerateAudio<
})

try {
const rawResult = await adapter.generateAudio({ ...rest, model, logger })
const rawResult = await raceWithAbort(
adapter.generateAudio({
...rest,
model,
logger,
...(abortControls.signal ? { abortSignal: abortControls.signal } : {}),
}),
abortControls.signal,
)
abortControls.clear()
Comment on lines +236 to +247

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check that media adapter option interfaces declare abortSignal.
set -euo pipefail

fd -e ts . packages/ai/src/activities --exec rg -n -C4 'abortSignal' {} \
  | rg -n 'GenerationOptions|Options|abortSignal'

echo '--- option interface declarations ---'
for name in AudioGenerationOptions TTSOptions TTSGenerationOptions TranscriptionOptions VideoJobCreateOptions ImageGenerationOptions SummarizeOptions; do
  echo "== $name =="
  ast-grep run --pattern "interface $name { \$\$\$ }" --lang typescript packages/ai/src || true
done

Repository: TanStack/ai

Length of output: 2663


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- candidate interface names and declarations ---'
rg -n "interface .*Options|abortSignal\\??:\\s*AbortSignal" packages/ai/src/activities -g '*.ts' | rg "interface|abortSignal" | head -200

echo '--- files mentioning generateAudio / generateSpeech / generateSpeechToText / createVideoJob / generateImage ---'
rg -n "generateAudio|generateSpeech|generateSpeechToText|createVideoJob|generateImage" packages/ai/src/activities -g '*.ts' | head -200

echo '--- audio activity relevant section ---'
cat -n packages/ai/src/activities/generateAudio/index.ts | sed -n '1,130p;220,260p'

echo '--- related activity option exports/imports ---'
for f in packages/ai/src/activities/generateAudio/index.ts packages/ai/src/activities/generateSpeech/index.ts packages/ai/src/activities/generateSpeechToText/index.ts packages/ai/src/activities/createVideoJob/index.ts packages/ai/src/activities/generateImage/index.ts; do
  if [ -f "$f" ]; then
    echo "==$f=="
    cat -n "$f" | sed -n '1,80p;220,260p'
  fi
done

Repository: TanStack/ai

Length of output: 27941


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- package files and scripts ---'
git ls-files 'package.json' 'packages/*/package.json' | sed -n '1,80p'
for f in package.json packages/ai/package.json; do
  if [ -f "$f" ]; then
    echo "== $f =="
    sed -n '1,180p' "$f"
  fi
done

echo '--- local TypeScript/tsc availability ---'
if command -v tsc >/dev/null 2>&1; then tsc --version; else echo 'tsc not on path'; fi
if [ -d packages/ai/node_modules/typescript ]; then tsc --version; else echo 'no packages/ai/node_modules/typescript'; fi

echo '--- exact generated option interfaces ---'
for file in \
  packages/ai/src/activities/generateAudio/index.ts \
  packages/ai/src/activities/generateSpeech/index.ts \
  packages/ai/src/activities/generateTranscription/index.ts \
  packages/ai/src/activities/generateVideo/index.ts \
  packages/ai/src/activities/generateImage/index.ts \
  packages/ai/src/activities/summarize/index.ts; do
  if [ -f "$file" ]; then
    echo "== $file =="
    sed -n '50,120p;120,145p;150,180p' "$file"
  fi
done

echo '--- adapter method signatures ---'
for file in \
  packages/ai/src/activities/generateAudio/adapter.ts \
  packages/ai/src/activities/generateSpeech/adapter.ts \
  packages/ai/src/activities/generateTranscription/adapter.ts \
  packages/ai/src/activities/generateVideo/adapter.ts \
  packages/ai/src/activities/generateImage/adapter.ts; do
  if [ -f "$file" ]; then
    echo "== $file =="
    sed -n '65,95p;170,195p' "$file"
  fi
done

echo '--- TypeScript behavior probe for omitting unused value ---'
node - 2>&1 <<'JS'
const code = `
interface ProvidedOptions {
  prompt: string;
  duration?: number;
  modelOptions?: object;
  model: string;
  logger: unknown;
}
async function generateAudio(options: ProvidedOptions) { return 1; }
const rest = { prompt: 'x', duration: 1, model: 'm', logger: {}, abortSignal: new AbortController().signal };
await generateAudio({
  ...rest,
  ...(rest.abortSignal ? { abortSignal: rest.abortSignal } : {})
});
`
console.log(code)
JS

echo '--- search adapter option declarations with abortSignal ---'
rg -n "abortSignal\\??:\\s*AbortSignal" packages/ai/src/activities/generate{Audio,Speech,Transcription,Video,Image}/adapter.ts || true

Repository: TanStack/ai

Length of output: 41635


Add abortSignal to the adapter option interfaces.

runGenerateAudio now spreads abortSignal into adapter.generateAudio(...), but generateAudio is typed with AudioGenerationOptions<TProviderOptions> while AudioActivityOptions is the value type. If provider adapters define AudioGenerationOptions, make those option interfaces include abortSignal; otherwise the adapter call drops the timeout/caller signal or fails type checking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/generateAudio/index.ts` around lines 236 - 247,
Update the adapter option interfaces used by generateAudio, specifically
AudioGenerationOptions and any provider-specific extensions, to declare the
optional abortSignal passed by runGenerateAudio. Ensure the
adapter.generateAudio call preserves and accepts this signal without type
errors, while retaining existing provider option fields.

const result = await applyGenerationResultTransforms(mwCtx, rawResult)
const elapsedMs = Date.now() - startTime

Expand Down Expand Up @@ -245,6 +279,7 @@ async function runGenerateAudio<

return result
} catch (error) {
abortControls.clear()
const elapsedMs = Date.now() - startTime
const err = error as Error
aiEventClient.emit('audio:request:error', {
Expand All @@ -256,10 +291,17 @@ async function runGenerateAudio<
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
timestamp: Date.now(),
})
await runGenerationError(middleware, mwCtx, {
error,
duration: elapsedMs,
})
if (isActivityAbortError(error, abortControls.signal)) {
await runGenerationAbort(middleware, mwCtx, {
reason: abortReasonMessage(error, abortControls.signal),
duration: elapsedMs,
})
} else {
await runGenerationError(middleware, mwCtx, {
error,
duration: elapsedMs,
})
}
logger.errors('generateAudio activity failed', {
error,
source: 'generateAudio',
Expand Down
53 changes: 48 additions & 5 deletions packages/ai/src/activities/generateImage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ import { resolveDebugOption } from '../../logger/resolve'
import {
applyGenerationResultTransforms,
createGenerationContext,
runGenerationAbort,
runGenerationError,
runGenerationFinish,
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
import {
abortReasonMessage,
createActivityAbortControls,
isActivityAbortError,
raceWithAbort,
} from '../../utilities/activity-abort'
import { resolveMediaPrompt } from '../../utilities/media-prompt'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
Expand Down Expand Up @@ -142,6 +149,18 @@ export type ImageActivityOptions<
threadId?: string
/** Stable run id for correlating this run when persisted. */
runId?: string
/**
* Maximum duration of this activity invocation in milliseconds.
* No SDK-wide default — choose a value suitable for the provider and job.
* Composed with {@link abortSignal}; the first abort wins.
*/
timeout?: number
/**
* Caller cancellation signal (request disconnects, job/runtime cancellation).
* Composed with {@link timeout} into an effective signal forwarded to the
* adapter. Request-specific — not stored on global provider client config.
*/
abortSignal?: AbortSignal
} & ({} extends ImageProviderOptionsForModel<TAdapter, TAdapter['model']>
? {
/** Provider-specific options for image generation */ modelOptions?: ImageProviderOptionsForModel<
Expand Down Expand Up @@ -260,12 +279,18 @@ async function runGenerateImage<
middleware,
threadId,
runId,
timeout,
abortSignal: callerAbortSignal,
...rest
} = options
const model = adapter.model
const requestId = createId('image')
const startTime = Date.now()
const logger: InternalLogger = resolveDebugOption(options.debug)
const abortControls = createActivityAbortControls({
timeout,
abortSignal: callerAbortSignal,
})
Comment on lines +290 to +293

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Clear abort controls when pre-request middleware fails.

If runGenerationStart() rejects at Line 307, execution does not enter the later try/catch. The timeout timer remains active until expiry.

Put the middleware-start phase inside cleanup coverage, or clear abortControls before rethrowing its error. This violates the stated timer-cleanup objective.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/activities/generateImage/index.ts` around lines 290 - 293,
Ensure the middleware-start phase invoking runGenerationStart is covered by
cleanup for abortControls, so a rejection clears the timeout before propagating
the error. Update the surrounding flow in the image-generation activity without
changing normal successful execution or later error handling.


const mwCtx = createGenerationContext({
requestId,
Expand Down Expand Up @@ -311,7 +336,16 @@ async function runGenerateImage<
})

try {
const rawResult = await adapter.generateImages({ ...rest, model, logger })
const rawResult = await raceWithAbort(
adapter.generateImages({
...rest,
model,
logger,
...(abortControls.signal ? { abortSignal: abortControls.signal } : {}),
}),
abortControls.signal,
)
abortControls.clear()
const result = await applyGenerationResultTransforms(mwCtx, rawResult)
const duration = Date.now() - startTime

Expand Down Expand Up @@ -355,10 +389,19 @@ async function runGenerateImage<

return result
} catch (error) {
await runGenerationError(middleware, mwCtx, {
error,
duration: Date.now() - startTime,
})
abortControls.clear()
const duration = Date.now() - startTime
if (isActivityAbortError(error, abortControls.signal)) {
await runGenerationAbort(middleware, mwCtx, {
reason: abortReasonMessage(error, abortControls.signal),
duration,
})
} else {
await runGenerationError(middleware, mwCtx, {
error,
duration,
})
}
logger.errors('generateImage activity failed', {
error,
source: 'generateImage',
Expand Down
Loading
Loading