diff --git a/.changeset/gemini-forward-abort-signal.md b/.changeset/gemini-forward-abort-signal.md new file mode 100644 index 0000000000..f156b2b528 --- /dev/null +++ b/.changeset/gemini-forward-abort-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-gemini': patch +--- + +Forward the caller's abort signal to the Google SDK request (`config.abortSignal`) so aborting a Gemini chat actually cancels the in-flight HTTP request, matching the OpenAI-compatible adapters. diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts index 81171fd98e..4893441cb9 100644 --- a/packages/ai-gemini/src/adapters/text.ts +++ b/packages/ai-gemini/src/adapters/text.ts @@ -1109,6 +1109,11 @@ export class GeminiTextAdapter< ...(systemInstruction !== undefined && { systemInstruction }), tools: convertToolsToProviderFormat(options.tools), ...(combinedSchemaConfig ?? {}), + // Forward the caller's abort signal so cancellation reaches the SDK + // request, matching the OpenAI-compatible adapters (issue #1374). + ...(options.request?.signal != null && { + abortSignal: options.request.signal, + }), }, } diff --git a/packages/ai-gemini/tests/gemini-adapter.test.ts b/packages/ai-gemini/tests/gemini-adapter.test.ts index c3779ce969..888a35a1a9 100644 --- a/packages/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/ai-gemini/tests/gemini-adapter.test.ts @@ -168,6 +168,59 @@ describe('GeminiAdapter through AI', () => { expect(payload.config.maxOutputTokens).toBe(512) }) + it("forwards the caller's abort signal as config.abortSignal (#1374)", async () => { + const streamChunks = [ + { + candidates: [ + { content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }, + ], + usageMetadata: { totalTokenCount: 1 }, + }, + ] + + mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks)) + + const adapter = createTextAdapter() + const abortController = new AbortController() + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + abortController, + })) { + /* consume stream */ + } + + expect(mocks.generateContentStreamSpy).toHaveBeenCalledTimes(1) + const [payload] = mocks.generateContentStreamSpy.mock.calls[0]! + expect(payload.config.abortSignal).toBe(abortController.signal) + }) + + it('omits config.abortSignal when no abort controller is supplied', async () => { + const streamChunks = [ + { + candidates: [ + { content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }, + ], + usageMetadata: { totalTokenCount: 1 }, + }, + ] + + mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks)) + + const adapter = createTextAdapter() + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + })) { + /* consume stream */ + } + + const [payload] = mocks.generateContentStreamSpy.mock.calls[0]! + expect(payload.config.abortSignal).toBeUndefined() + }) + it('joins object-form systemPrompts into systemInstruction and drops foreign metadata', async () => { const streamChunks = [ {