Skip to content

Commit d4a7609

Browse files
committed
Refine AI chat streaming API
1 parent 7aaa8af commit d4a7609

7 files changed

Lines changed: 148 additions & 30 deletions

File tree

docs/api/create-ai-gateway-client.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,11 @@ function createAIGatewayClient(config: {
3434

3535
```typescript
3636
interface AIGatewayClient {
37-
chatCompletionStream(request: AIGatewayChatRequest): AsyncGenerator<string, void, unknown>;
37+
chatCompletionStream(request: AIGatewayChatRequest): AsyncIterable<string>;
3838
}
3939
```
4040

41-
The generator yields text deltas. Concatenate them to build the assistant response.
41+
The iterable yields text deltas. Concatenate them to build the assistant response.
4242

4343
## Related Types
4444

@@ -51,6 +51,7 @@ interface AIGatewayChatMessage {
5151
interface AIGatewayChatRequest {
5252
model: string;
5353
messages: AIGatewayChatMessage[];
54+
stream?: boolean;
5455
signal?: AbortSignal;
5556
}
5657
```
@@ -83,8 +84,9 @@ console.log(deltas.join(""));
8384

8485
## Notes
8586

86-
- Non-Gemini models are consumed as streaming SSE responses
87-
- `gemini-*` models are consumed as JSON responses and yielded once
87+
- `stream` defaults to `true`
88+
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
89+
- Gemini-backed routes often need `stream: false`
8890
- The public API is intentionally text-only
8991

9092
## Related

docs/api/use-ai-chat.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ React hook for simple text-only chat on top of `createAIGatewayClient`.
1010
## Signature
1111

1212
```typescript
13-
const useAIChat: (config: { client: AIGatewayClient; model: string }) => {
13+
const useAIChat: (config: { client: AIGatewayClient; model: string; stream?: boolean }) => {
1414
messages: AIChatMessage[];
1515
status: "ready" | "submitted" | "streaming" | "error";
1616
error?: Error;
17-
sendMessage: (message: { text: string } | string) => Promise<void>;
17+
sendMessage: (message: string) => Promise<boolean>;
1818
stop: () => void;
1919
};
2020
```
@@ -43,8 +43,9 @@ interface AIChatMessage {
4343

4444
### `sendMessage()`
4545

46-
- **Type:** `(message: { text: string } | string) => Promise<void>`
46+
- **Type:** `(message: string) => Promise<boolean>`
4747
- **Description:** Appends a user message and streams the assistant response
48+
- **Returns:** `true` when the request completes successfully, `false` when the call is ignored, stopped, or fails
4849

4950
### `stop()`
5051

@@ -81,7 +82,7 @@ export function ChatScreen() {
8182
))}
8283

8384
<button
84-
onClick={() => sendMessage("Hello")}
85+
onClick={() => void sendMessage("Hello")}
8586
disabled={status === "submitted" || status === "streaming"}
8687
>
8788
Send
@@ -97,9 +98,11 @@ export function ChatScreen() {
9798

9899
## Notes
99100

101+
- `stream` defaults to `true`
102+
- Pass `stream: false` when the endpoint returns a single JSON response instead of SSE
100103
- The hook is intentionally text-only
101104
- System prompts and custom history shaping should use the low-level client directly
102-
- `stop()` keeps any already-streamed assistant text
105+
- `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request
103106

104107
## Related
105108

docs/concepts/authentication.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ function ChatScreen() {
185185
</div>
186186
))}
187187

188-
<button onClick={() => sendMessage("Hello")}>Send</button>
188+
<button onClick={() => void sendMessage("Hello")}>Send</button>
189189
<button onClick={stop} disabled={status !== "submitted" && status !== "streaming"}>
190190
Stop
191191
</button>

packages/core/src/ai/client.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ describe("createAIGatewayClient", () => {
8282
});
8383
});
8484

85-
it("yields a single text chunk for gemini models", async () => {
85+
it("yields a single text chunk for json responses", async () => {
8686
const authClient = createMockAuthClient(
8787
new Response(
8888
JSON.stringify({
@@ -105,7 +105,10 @@ describe("createAIGatewayClient", () => {
105105
authClient,
106106
});
107107

108-
const deltas = await collectStream(client, createRequest({ model: "gemini-2.5-flash" }));
108+
const deltas = await collectStream(
109+
client,
110+
createRequest({ model: "gemini-2.5-flash", stream: false }),
111+
);
109112

110113
expect(deltas).toEqual(["Grounded answer"]);
111114
expect(authClient.fetch).toHaveBeenCalledWith(
@@ -120,6 +123,7 @@ describe("createAIGatewayClient", () => {
120123
).toEqual({
121124
model: "gemini-2.5-flash",
122125
messages: [{ role: "user", content: "Hello" }],
126+
stream: false,
123127
});
124128
});
125129

@@ -149,7 +153,7 @@ describe("createAIGatewayClient", () => {
149153
);
150154
});
151155

152-
it("throws when gemini responses do not include assistant text", async () => {
156+
it("throws when json responses do not include assistant text", async () => {
153157
const authClient = createMockAuthClient(
154158
new Response(JSON.stringify({ choices: [{ message: { content: [] } }] }), { status: 200 }),
155159
);
@@ -159,7 +163,7 @@ describe("createAIGatewayClient", () => {
159163
});
160164

161165
await expect(
162-
collectStream(client, createRequest({ model: "gemini-2.5-flash" })),
163-
).rejects.toThrow("AI Gateway Gemini response did not include assistant text.");
166+
collectStream(client, createRequest({ model: "gemini-2.5-flash", stream: false })),
167+
).rejects.toThrow("AI Gateway JSON response did not include assistant text.");
164168
});
165169
});

packages/core/src/ai/client.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ export interface AIGatewayChatMessage {
88
export interface AIGatewayChatRequest {
99
model: string;
1010
messages: AIGatewayChatMessage[];
11+
stream?: boolean;
1112
signal?: AbortSignal;
1213
}
1314

1415
export interface AIGatewayClient {
15-
chatCompletionStream(request: AIGatewayChatRequest): AsyncGenerator<string, void, unknown>;
16+
/**
17+
* Honor request.signal when possible so callers can stop work early.
18+
*/
19+
chatCompletionStream(request: AIGatewayChatRequest): AsyncIterable<string>;
1620
}
1721

1822
interface OpenAIStreamChunk {
@@ -39,8 +43,8 @@ export function createAIGatewayClient(config: {
3943

4044
return {
4145
async *chatCompletionStream(request) {
42-
if (isGeminiModel(request.model)) {
43-
yield* streamGeminiResponse({
46+
if (request.stream === false) {
47+
yield* streamJSONResponse({
4448
endpoint,
4549
authClient: config.authClient,
4650
request,
@@ -96,7 +100,7 @@ async function* streamOpenAICompatibleResponse(input: {
96100
}
97101
}
98102

99-
async function* streamGeminiResponse(input: {
103+
async function* streamJSONResponse(input: {
100104
endpoint: string;
101105
authClient: AuthClient;
102106
request: AIGatewayChatRequest;
@@ -110,17 +114,18 @@ async function* streamGeminiResponse(input: {
110114
body: JSON.stringify({
111115
model: input.request.model,
112116
messages: input.request.messages,
117+
stream: false,
113118
}),
114119
signal: input.request.signal,
115120
});
116121

117-
await assertOK(response, "AI Gateway Gemini request");
122+
await assertOK(response, "AI Gateway JSON request");
118123

119124
const payload = parseJSON<OpenAIFinalResponse>(await response.text(), "AI Gateway JSON response");
120125
const text = extractText(payload.choices?.[0]?.message?.content);
121126

122127
if (!text) {
123-
throw new Error("AI Gateway Gemini response did not include assistant text.");
128+
throw new Error("AI Gateway JSON response did not include assistant text.");
124129
}
125130

126131
yield text;
@@ -229,10 +234,6 @@ async function* iterateSSEDataEvents(
229234
}
230235
}
231236

232-
function isGeminiModel(model: string): boolean {
233-
return model.startsWith("gemini-");
234-
}
235-
236237
function withTrailingSlash(value: string): string {
237238
return value.endsWith("/") ? value : `${value}/`;
238239
}

packages/core/src/ai/use-ai-chat.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe("useAIChat", () => {
6666
expect(client.chatCompletionStream).toHaveBeenCalledWith({
6767
model: "gpt-5-mini",
6868
messages: [{ role: "user", content: "Hi" }],
69+
stream: true,
6970
signal: expect.any(AbortSignal),
7071
});
7172

@@ -131,6 +132,78 @@ describe("useAIChat", () => {
131132
});
132133
});
133134

135+
it("ignores stale chunks after stop and allows the next send", async () => {
136+
let releaseStoppedRequest!: () => void;
137+
const stoppedRequestGate = new Promise<void>((resolve) => {
138+
releaseStoppedRequest = resolve;
139+
});
140+
141+
const client = {
142+
chatCompletionStream: vi.fn(async function* ({ messages }) {
143+
const text = messages.at(-1)?.content;
144+
145+
if (text === "First") {
146+
yield "Partial";
147+
await stoppedRequestGate;
148+
yield " stale";
149+
return;
150+
}
151+
152+
yield "Fresh";
153+
}),
154+
} satisfies AIGatewayClient;
155+
156+
const { result } = renderHook(() => useAIChat({ client, model: "gpt-5-mini" }));
157+
158+
let firstSendPromise: Promise<boolean> | undefined;
159+
await act(async () => {
160+
firstSendPromise = result.current.sendMessage("First");
161+
});
162+
163+
await waitFor(() => {
164+
expect(result.current.status).toBe("streaming");
165+
expect(result.current.messages).toEqual([
166+
{ id: "id-1", role: "user", content: "First" },
167+
{ id: "id-2", role: "assistant", content: "Partial" },
168+
]);
169+
});
170+
171+
act(() => {
172+
result.current.stop();
173+
});
174+
175+
await waitFor(() => {
176+
expect(result.current.status).toBe("ready");
177+
});
178+
179+
await act(async () => {
180+
await expect(result.current.sendMessage("Second")).resolves.toBe(true);
181+
});
182+
183+
await waitFor(() => {
184+
expect(result.current.messages).toEqual([
185+
{ id: "id-1", role: "user", content: "First" },
186+
{ id: "id-2", role: "assistant", content: "Partial" },
187+
{ id: "id-3", role: "user", content: "Second" },
188+
{ id: "id-4", role: "assistant", content: "Fresh" },
189+
]);
190+
});
191+
192+
releaseStoppedRequest();
193+
await act(async () => {
194+
await expect(firstSendPromise).resolves.toBe(false);
195+
});
196+
197+
await waitFor(() => {
198+
expect(result.current.messages).toEqual([
199+
{ id: "id-1", role: "user", content: "First" },
200+
{ id: "id-2", role: "assistant", content: "Partial" },
201+
{ id: "id-3", role: "user", content: "Second" },
202+
{ id: "id-4", role: "assistant", content: "Fresh" },
203+
]);
204+
});
205+
});
206+
134207
it("sets error state and recovers on the next successful send", async () => {
135208
let shouldFail = true;
136209
const client = {

0 commit comments

Comments
 (0)