Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/vercel-gateway-stream-reasoning.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/adapters/vercel-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
17 changes: 17 additions & 0 deletions packages/ai-vercel-gateway/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
85 changes: 85 additions & 0 deletions packages/ai-vercel-gateway/tests/text-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AdapterYieldChunk> = []

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 () => {
Expand Down
4 changes: 4 additions & 0 deletions testing/e2e/src/lib/feature-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export const matrix: Record<Feature, Set<Provider>> = {
// 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',
Expand All @@ -77,6 +80,7 @@ export const matrix: Record<Feature, Set<Provider>> = {
'llmgateway',
'cloudflare',
'openai-compatible-legacy',
'vercel-gateway',
'neon',
'neon-anthropic',
]),
Expand Down
106 changes: 63 additions & 43 deletions testing/e2e/src/lib/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array, Uint8Array>({
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<Uint8Array, Uint8Array>({
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<Provider, string> = {
openai: 'gpt-4o',
Expand Down Expand Up @@ -334,6 +353,7 @@ export function createTextAdapter(
api: 'chat',
baseURL: openaiUrl,
defaultHeaders: testHeaders,
fetch: gatewayReasoningFetch,
}),
}),
'vercel-gateway-responses': () =>
Expand Down