-
-
Notifications
You must be signed in to change notification settings - Fork 295
feat(ai): add abort signals and timeouts to media generation activities #1047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
| // =========================== | ||
|
|
@@ -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 ?? | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
doneRepository: 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
doneRepository: 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 || trueRepository: TanStack/ai Length of output: 41635 Add
🤖 Prompt for AI Agents |
||
| const result = await applyGenerationResultTransforms(mwCtx, rawResult) | ||
| const elapsedMs = Date.now() - startTime | ||
|
|
||
|
|
@@ -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', { | ||
|
|
@@ -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', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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< | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Put the middleware-start phase inside cleanup coverage, or clear 🤖 Prompt for AI Agents |
||
|
|
||
| const mwCtx = createGenerationContext({ | ||
| requestId, | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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', | ||
|
|
||
There was a problem hiding this comment.
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
controllerand verify that the signal captured fromfal.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
🤖 Prompt for AI Agents