From 4b722678e857f22307340c2da7661fd778ce6624 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sat, 14 Nov 2026 04:05:32 -0400 Subject: [PATCH 1/2] fix(ai-vercel-gateway): surface delta.reasoning as reasoning events on chat completions AI Gateway streams thinking text on delta.reasoning when chat({ reasoning }) leaves the summary on. The chat adapter never overrode extractReasoning, so the base dropped that text and no reasoning events were emitted. --- .changeset/vercel-gateway-stream-reasoning.md | 5 ++ docs/adapters/vercel-gateway.md | 2 +- .../ai-vercel-gateway/src/adapters/text.ts | 17 ++++ .../tests/text-adapter.test.ts | 85 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 .changeset/vercel-gateway-stream-reasoning.md diff --git a/.changeset/vercel-gateway-stream-reasoning.md b/.changeset/vercel-gateway-stream-reasoning.md new file mode 100644 index 0000000000..4dfb241aab --- /dev/null +++ b/.changeset/vercel-gateway-stream-reasoning.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-vercel-gateway': patch +--- + +Chat Completions streams now surface AI Gateway `delta.reasoning` as reasoning events. A `chat({ reasoning })` call with the summary on no longer drops the thinking text. diff --git a/docs/adapters/vercel-gateway.md b/docs/adapters/vercel-gateway.md index 2c89100ab9..c32349935f 100644 --- a/docs/adapters/vercel-gateway.md +++ b/docs/adapters/vercel-gateway.md @@ -322,7 +322,7 @@ const stream = chat({ What goes to the gateway: -- Chat Completions (`api: "chat"`): the gateway `reasoning` object. A level sends `{ effort }`. `budgetTokens` sends `{ enabled: true, max_tokens }`. `off` sends `{ enabled: false }`. `summary: false` adds `exclude: true`. +- Chat Completions (`api: "chat"`): the gateway `reasoning` object. A level sends `{ effort }`. `budgetTokens` sends `{ enabled: true, max_tokens }`. `off` sends `{ enabled: false }`. `summary: false` adds `exclude: true`. When the summary is on, the gateway streams the thinking text on `delta.reasoning`, and the adapter emits it as reasoning events. - Responses (the default): `reasoning.effort`. It takes no `budgetTokens`. See [Reasoning](../chat/reasoning). diff --git a/packages/ai-vercel-gateway/src/adapters/text.ts b/packages/ai-vercel-gateway/src/adapters/text.ts index 47daf107ee..622ce3d2fd 100644 --- a/packages/ai-vercel-gateway/src/adapters/text.ts +++ b/packages/ai-vercel-gateway/src/adapters/text.ts @@ -119,4 +119,21 @@ export class VercelGatewayTextAdapter< if (resolved) Object.assign(rest, { reasoning: gatewayReasoning(resolved) }) return rest } + + /** + * AI Gateway streams thinking on `delta.reasoning` when the request does + * not set `exclude` (summary on). The Chat Completions base has no + * reasoning field, so that text is dropped without this override. + * https://vercel.com/docs/ai-gateway/sdks-and-apis/openai-chat-completions/reasoning + */ + protected override extractReasoning( + chunk: OpenAI.Chat.Completions.ChatCompletionChunk, + ): { text: string } | undefined { + const delta = chunk.choices?.[0]?.delta as + | { reasoning?: unknown } + | undefined + const raw = delta?.reasoning + if (typeof raw === 'string' && raw.length > 0) return { text: raw } + return undefined + } } diff --git a/packages/ai-vercel-gateway/tests/text-adapter.test.ts b/packages/ai-vercel-gateway/tests/text-adapter.test.ts index 41c6333bc9..edb165e143 100644 --- a/packages/ai-vercel-gateway/tests/text-adapter.test.ts +++ b/packages/ai-vercel-gateway/tests/text-adapter.test.ts @@ -129,6 +129,91 @@ describe('Vercel Gateway text adapter', () => { chunk.delta === 'hi', ), ).toBe(true) + expect( + chunks.some( + (chunk) => chunk.type === EventType.REASONING_MESSAGE_CONTENT, + ), + ).toBe(false) + }) + + it('surfaces delta.reasoning as reasoning events', async () => { + setupMockSdkClient([ + { + id: 'chatcmpl-reasoning', + model: 'openai/gpt-5.5', + choices: [ + { + index: 0, + delta: { reasoning: 'Think first. ' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-reasoning', + model: 'openai/gpt-5.5', + choices: [ + { + index: 0, + delta: { reasoning: 'Then answer.' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-reasoning', + model: 'openai/gpt-5.5', + choices: [ + { + index: 0, + delta: { content: '4' }, + finish_reason: null, + }, + ], + }, + { + id: 'chatcmpl-reasoning', + model: 'openai/gpt-5.5', + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }, + ]) + const adapter = createVercelGatewayText('openai/gpt-5.5', 'k') + const chunks: Array = [] + + for await (const chunk of adapter.chatStream({ + threadId: 'thread-1', + model: 'openai/gpt-5.5', + messages: [{ role: 'user', content: 'What is 2+2?' }], + logger: testLogger, + })) { + chunks.push(chunk) + } + + const reasoning = chunks.filter( + (chunk) => chunk.type === EventType.REASONING_MESSAGE_CONTENT, + ) + expect( + reasoning.map((chunk) => + chunk.type === EventType.REASONING_MESSAGE_CONTENT ? chunk.delta : '', + ), + ).toEqual(['Think first. ', 'Then answer.']) + + const reasoningEndIndex = chunks.findIndex( + (chunk) => chunk.type === EventType.REASONING_MESSAGE_END, + ) + const textStartIndex = chunks.findIndex( + (chunk) => chunk.type === EventType.TEXT_MESSAGE_START, + ) + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeGreaterThan(reasoningEndIndex) + expect( + chunks.some( + (chunk) => + chunk.type === EventType.TEXT_MESSAGE_CONTENT && + 'delta' in chunk && + chunk.delta === '4', + ), + ).toBe(true) }) it('sends gateway options under providerOptions.gateway', async () => { From 0e92bf9b7beab79c4be350a7449b92a1862e7baf Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Sat, 14 Nov 2026 04:33:04 -0400 Subject: [PATCH 2/2] test(e2e): cover vercel-gateway chat reasoning over delta.reasoning Re-key aimock's delta.reasoning_content to delta.reasoning for the vercel-gateway chat provider and add it to the reasoning matrix, so the adapter's extractReasoning override runs end-to-end. --- testing/e2e/src/lib/feature-support.ts | 4 + testing/e2e/src/lib/providers.ts | 106 +++++++++++++++---------- 2 files changed, 67 insertions(+), 43 deletions(-) diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 8cecc8cc27..7384c117d0 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -67,6 +67,9 @@ export const matrix: Record> = { // pre-July-2025 `response.reasoning.delta` event name that OpenAI-compatible // endpoints frozen on the older spec still emit (see providers.ts), pinning // openai-base's legacy-event mapping end-to-end. Reasoning-only on purpose. + // `vercel-gateway` (Chat Completions) streams thinking on `delta.reasoning`, + // so its factory re-keys aimock's `delta.reasoning_content` to that field + // (see providers.ts) and the adapter's `extractReasoning` runs end-to-end. reasoning: new Set([ 'openai', 'anthropic', @@ -77,6 +80,7 @@ export const matrix: Record> = { 'llmgateway', 'cloudflare', 'openai-compatible-legacy', + 'vercel-gateway', 'neon', 'neon-anthropic', ]), diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index 456878d3b3..1fb6f77454 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -34,54 +34,73 @@ export const LLMOCK_DEFAULT_BASE = process.env.LLMOCK_URL || 'http://127.0.0.1:4010' const DUMMY_KEY = 'sk-e2e-test-dummy-key' +/** + * Builds a `fetch` that passes every SSE frame of a streamed response through + * `rewriteFrame`. Non-streaming responses are returned untouched. aimock + * authors everything else about the stream. + */ +const sseRewriteFetch = + (rewriteFrame: (frame: string) => string): typeof fetch => + async (input, init) => { + const response = await fetch(input, init) + if ( + !response.body || + !response.headers.get('content-type')?.includes('text/event-stream') + ) { + return response + } + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = '' + const rewritten = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }) + const frames = buffer.split(/\r?\n\r?\n/) + buffer = frames.pop() ?? '' + for (const frame of frames) { + controller.enqueue(encoder.encode(rewriteFrame(frame) + '\n\n')) + } + }, + flush(controller) { + if (buffer) controller.enqueue(encoder.encode(rewriteFrame(buffer))) + }, + }), + ) + return new Response(rewritten, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }) + } + /** * Re-keys aimock's reasoning frames to the pre-July-2025 * `response.reasoning.delta` event name that Mantle-served Bedrock models * still emit, so openai-base's legacy-event mapping is exercised over a real - * wire. aimock authors everything else about the stream. + * wire. */ -const legacyReasoningFetch: typeof fetch = async (input, init) => { - const response = await fetch(input, init) - if ( - !response.body || - !response.headers.get('content-type')?.includes('text/event-stream') - ) { - return response - } - const decoder = new TextDecoder() - const encoder = new TextEncoder() - const rewriteFrame = (frame: string) => - frame - .replace( - 'event: response.reasoning_summary_text.delta', - 'event: response.reasoning.delta', - ) - .replace( - '"type":"response.reasoning_summary_text.delta"', - '"type":"response.reasoning.delta"', - ) - let buffer = '' - const rewritten = response.body.pipeThrough( - new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }) - const frames = buffer.split(/\r?\n\r?\n/) - buffer = frames.pop() ?? '' - for (const frame of frames) { - controller.enqueue(encoder.encode(rewriteFrame(frame) + '\n\n')) - } - }, - flush(controller) { - if (buffer) controller.enqueue(encoder.encode(rewriteFrame(buffer))) - }, - }), - ) - return new Response(rewritten, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) -} +const legacyReasoningFetch = sseRewriteFetch((frame) => + frame + .replace( + 'event: response.reasoning_summary_text.delta', + 'event: response.reasoning.delta', + ) + .replace( + '"type":"response.reasoning_summary_text.delta"', + '"type":"response.reasoning.delta"', + ), +) + +/** + * Re-keys aimock's Chat Completions reasoning deltas from + * `delta.reasoning_content` to `delta.reasoning`, the field Vercel AI Gateway + * streams thinking text on, so the gateway chat adapter's `extractReasoning` + * is exercised over a real wire. + */ +const gatewayReasoningFetch = sseRewriteFetch((frame) => + frame.replaceAll('"reasoning_content":', '"reasoning":'), +) const defaultModels: Record = { openai: 'gpt-4o', @@ -334,6 +353,7 @@ export function createTextAdapter( api: 'chat', baseURL: openaiUrl, defaultHeaders: testHeaders, + fetch: gatewayReasoningFetch, }), }), 'vercel-gateway-responses': () =>