From ad15173aa718457d0e8ef0631fd3467b0d5716ff Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:25:24 -0500 Subject: [PATCH] fix(ai): ignore Vertex keepalives sent as SSE data (#50954) --- packages/ai/src/protocols/shared.ts | 9 ++++-- .../ai/test/provider/google-vertex.test.ts | 28 +++++++++++++++++++ packages/ai/test/schema.test.ts | 14 ++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/protocols/shared.ts b/packages/ai/src/protocols/shared.ts index fba7717b313..d69c1483bb7 100644 --- a/packages/ai/src/protocols/shared.ts +++ b/packages/ai/src/protocols/shared.ts @@ -253,9 +253,9 @@ export const errorText = (error: unknown) => { /** * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel - * decoder, optionally filters named events, and drops empty and bare `null` - * events. `[DONE]` is dropped by default or retained for protocols that use it - * as their stream boundary. Retry control events are ignored without + * decoder, optionally filters named events, and drops empty events and known + * keepalives that proxies send as data. `[DONE]` is dropped by default or + * retained for protocols that use it as their stream boundary. Retry control events are ignored without * interrupting the stream. Decoder failures become provider output errors so * the public error channel stays `AIError`. */ @@ -291,6 +291,9 @@ export const sseFraming = ( // `data: null`, between events or after `[DONE]`. No protocol has a // null event, so it carries nothing and must not abort the stream. event.data !== "null" && + // Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE + // keepalive comment as `data: : keepalive` while reasoning. + event.data !== ": keepalive" && (event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")), ), Stream.map((event) => event.data), diff --git a/packages/ai/test/provider/google-vertex.test.ts b/packages/ai/test/provider/google-vertex.test.ts index f7998365b8d..0bc198d9d55 100644 --- a/packages/ai/test/provider/google-vertex.test.ts +++ b/packages/ai/test/provider/google-vertex.test.ts @@ -343,6 +343,34 @@ describe("Google Vertex providers", () => { }), ) + // Captured from xai/grok-4.6 on Vertex: one keepalive every 15s until the first token. + it.effect("ignores keepalives sent as data while a partner model reasons", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + model: GoogleVertexChat.configure({ + accessToken: "vertex-token", + location: "global", + project: "vertex-project", + }).model("xai/grok-4.6"), + prompt: "Say hello.", + }), + ).pipe( + Effect.provide( + fixedResponse( + `data: : keepalive\n\ndata: : keepalive\n\n${sseEvents( + deltaChunk({ role: "assistant", content: "Hello." }), + finishChunk("stop"), + )}`, + ), + ), + ) + + expect(response.text).toBe("Hello.") + expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" }) + }), + ) + it.effect("sends Grok requests through Vertex Responses", () => Effect.gen(function* () { const response = yield* LLMClient.generate( diff --git a/packages/ai/test/schema.test.ts b/packages/ai/test/schema.test.ts index 4a025f6c535..8c3032dc401 100644 --- a/packages/ai/test/schema.test.ts +++ b/packages/ai/test/schema.test.ts @@ -192,6 +192,20 @@ describe("AI.Usage", () => { }), ) + it.effect("sseFraming drops keepalive comments sent as data and keeps other payloads", () => + Effect.gen(function* () { + const frames = yield* ProviderShared.sseFraming( + Stream.make( + new TextEncoder().encode( + 'data: {"first":true}\n\ndata: : keepalive\n\n: keepalive\n\ndata: : ping\n\ndata: {"second":true}\n\n', + ), + ), + ).pipe(Stream.runCollect) + + expect(Array.from(frames)).toEqual(['{"first":true}', ": ping", '{"second":true}']) + }), + ) + test("visibleOutputTokens clamps reasoning > output to zero", () => { expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6) expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)