Skip to content

Commit b129d36

Browse files
feat: add includeRawChunks support for streaming (#360)
* feat: add includeRawChunks support for streaming When includeRawChunks: true is passed to streaming calls, the provider now emits { type: 'raw', rawValue: <parsed chunk> } stream parts for each SSE event, giving consumers access to the raw provider chunks alongside the processed AI SDK stream parts. This feature is available for both chat and completion models. Closes #340 Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> * test: add test for raw chunk emission on failed parse Documents the intentional behavior that raw chunks are emitted before validation, which is useful for debugging malformed responses. This matches the Vercel AI SDK reference implementation pattern. Co-Authored-By: Robert Yeakel <robert.yeakel@openrouter.ai> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent f2d5034 commit b129d36

File tree

5 files changed

+266
-0
lines changed

5 files changed

+266
-0
lines changed

.changeset/include-raw-chunks.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@openrouter/ai-sdk-provider": minor
3+
---
4+
5+
Add includeRawChunks support for streaming
6+
7+
When `includeRawChunks: true` is passed to streaming calls, the provider now emits `{ type: 'raw', rawValue: <parsed chunk> }` stream parts for each SSE event, giving consumers access to the raw provider chunks alongside the processed AI SDK stream parts.
8+
9+
This feature is available for both chat and completion models.

src/chat/index.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2340,3 +2340,127 @@ describe('debug settings', () => {
23402340
expect(requestBody).not.toHaveProperty('debug');
23412341
});
23422342
});
2343+
2344+
describe('includeRawChunks', () => {
2345+
const server = createTestServer({
2346+
'https://openrouter.ai/api/v1/chat/completions': {
2347+
response: { type: 'json-value', body: {} },
2348+
},
2349+
});
2350+
2351+
beforeAll(() => server.server.start());
2352+
afterEach(() => server.server.reset());
2353+
afterAll(() => server.server.stop());
2354+
2355+
function prepareStreamResponse({ content }: { content: string[] }) {
2356+
server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = {
2357+
type: 'stream-chunks',
2358+
chunks: [
2359+
`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n`,
2360+
...content.map(
2361+
(text) =>
2362+
`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":"${text}"},"finish_reason":null}]}\n\n`,
2363+
),
2364+
`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n`,
2365+
`data: {"id":"chatcmpl-test","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\n`,
2366+
'data: [DONE]\n\n',
2367+
],
2368+
};
2369+
}
2370+
2371+
it('should emit raw chunks when includeRawChunks is true', async () => {
2372+
prepareStreamResponse({ content: ['Hello'] });
2373+
2374+
const { stream } = await model.doStream({
2375+
prompt: TEST_PROMPT,
2376+
includeRawChunks: true,
2377+
});
2378+
2379+
const elements = await convertReadableStreamToArray(stream);
2380+
const rawChunks = elements.filter(
2381+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
2382+
chunk.type === 'raw',
2383+
);
2384+
2385+
expect(rawChunks.length).toBeGreaterThan(0);
2386+
expect(rawChunks[0]).toHaveProperty('rawValue');
2387+
expect(rawChunks[0]!.rawValue).toHaveProperty('id', 'chatcmpl-test');
2388+
});
2389+
2390+
it('should not emit raw chunks when includeRawChunks is false', async () => {
2391+
prepareStreamResponse({ content: ['Hello'] });
2392+
2393+
const { stream } = await model.doStream({
2394+
prompt: TEST_PROMPT,
2395+
includeRawChunks: false,
2396+
});
2397+
2398+
const elements = await convertReadableStreamToArray(stream);
2399+
const rawChunks = elements.filter(
2400+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
2401+
chunk.type === 'raw',
2402+
);
2403+
2404+
expect(rawChunks.length).toBe(0);
2405+
});
2406+
2407+
it('should not emit raw chunks when includeRawChunks is not specified', async () => {
2408+
prepareStreamResponse({ content: ['Hello'] });
2409+
2410+
const { stream } = await model.doStream({
2411+
prompt: TEST_PROMPT,
2412+
});
2413+
2414+
const elements = await convertReadableStreamToArray(stream);
2415+
const rawChunks = elements.filter(
2416+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
2417+
chunk.type === 'raw',
2418+
);
2419+
2420+
expect(rawChunks.length).toBe(0);
2421+
});
2422+
2423+
it('should emit raw chunks for each SSE event including usage chunk', async () => {
2424+
prepareStreamResponse({ content: ['Hello', ' World'] });
2425+
2426+
const { stream } = await model.doStream({
2427+
prompt: TEST_PROMPT,
2428+
includeRawChunks: true,
2429+
});
2430+
2431+
const elements = await convertReadableStreamToArray(stream);
2432+
const rawChunks = elements.filter(
2433+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
2434+
chunk.type === 'raw',
2435+
);
2436+
2437+
// Should have raw chunks for: initial, Hello, World, finish_reason, usage
2438+
expect(rawChunks.length).toBe(5);
2439+
});
2440+
2441+
it('should emit raw chunk even when parsing fails (for debugging malformed responses)', async () => {
2442+
server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = {
2443+
type: 'stream-chunks',
2444+
chunks: ['data: {unparsable}\n\n', 'data: [DONE]\n\n'],
2445+
};
2446+
2447+
const { stream } = await model.doStream({
2448+
prompt: TEST_PROMPT,
2449+
includeRawChunks: true,
2450+
});
2451+
2452+
const elements = await convertReadableStreamToArray(stream);
2453+
const rawChunks = elements.filter(
2454+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
2455+
chunk.type === 'raw',
2456+
);
2457+
const errorChunks = elements.filter(
2458+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'error' }> =>
2459+
chunk.type === 'error',
2460+
);
2461+
2462+
// Raw chunk is emitted before error handling, useful for debugging
2463+
expect(rawChunks.length).toBe(1);
2464+
expect(errorChunks.length).toBe(1);
2465+
});
2466+
});

src/chat/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,11 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
628628
LanguageModelV3StreamPart
629629
>({
630630
transform(chunk, controller) {
631+
// Emit raw chunk if requested (before anything else)
632+
if (options.includeRawChunks) {
633+
controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });
634+
}
635+
631636
// handle failed chunk parsing / validation:
632637
if (!chunk.success) {
633638
finishReason = createFinishReason('error');

src/completion/index.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,126 @@ describe('doStream', () => {
610610
);
611611
});
612612
});
613+
614+
describe('includeRawChunks', () => {
615+
const server = createTestServer({
616+
'https://openrouter.ai/api/v1/completions': {
617+
response: { type: 'stream-chunks', chunks: [] },
618+
},
619+
});
620+
621+
beforeAll(() => server.server.start());
622+
afterEach(() => server.server.reset());
623+
afterAll(() => server.server.stop());
624+
625+
function prepareStreamResponse({ content }: { content: string[] }) {
626+
server.urls['https://openrouter.ai/api/v1/completions']!.response = {
627+
type: 'stream-chunks',
628+
chunks: [
629+
...content.map(
630+
(text) =>
631+
`data: {"id":"cmpl-test","object":"text_completion","created":1711363440,"choices":[{"text":"${text}","index":0,"logprobs":null,"finish_reason":null}],"model":"openai/gpt-3.5-turbo-instruct"}\n\n`,
632+
),
633+
`data: {"id":"cmpl-test","object":"text_completion","created":1711363310,"choices":[{"text":"","index":0,"logprobs":null,"finish_reason":"stop"}],"model":"openai/gpt-3.5-turbo-instruct"}\n\n`,
634+
`data: {"id":"cmpl-test","object":"text_completion","created":1711363310,"model":"openai/gpt-3.5-turbo-instruct","usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15},"choices":[]}\n\n`,
635+
'data: [DONE]\n\n',
636+
],
637+
};
638+
}
639+
640+
it('should emit raw chunks when includeRawChunks is true', async () => {
641+
prepareStreamResponse({ content: ['Hello'] });
642+
643+
const { stream } = await model.doStream({
644+
prompt: TEST_PROMPT,
645+
includeRawChunks: true,
646+
});
647+
648+
const elements = await convertReadableStreamToArray(stream);
649+
const rawChunks = elements.filter(
650+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
651+
chunk.type === 'raw',
652+
);
653+
654+
expect(rawChunks.length).toBeGreaterThan(0);
655+
expect(rawChunks[0]).toHaveProperty('rawValue');
656+
expect(rawChunks[0]!.rawValue).toHaveProperty('id', 'cmpl-test');
657+
});
658+
659+
it('should not emit raw chunks when includeRawChunks is false', async () => {
660+
prepareStreamResponse({ content: ['Hello'] });
661+
662+
const { stream } = await model.doStream({
663+
prompt: TEST_PROMPT,
664+
includeRawChunks: false,
665+
});
666+
667+
const elements = await convertReadableStreamToArray(stream);
668+
const rawChunks = elements.filter(
669+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
670+
chunk.type === 'raw',
671+
);
672+
673+
expect(rawChunks.length).toBe(0);
674+
});
675+
676+
it('should not emit raw chunks when includeRawChunks is not specified', async () => {
677+
prepareStreamResponse({ content: ['Hello'] });
678+
679+
const { stream } = await model.doStream({
680+
prompt: TEST_PROMPT,
681+
});
682+
683+
const elements = await convertReadableStreamToArray(stream);
684+
const rawChunks = elements.filter(
685+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
686+
chunk.type === 'raw',
687+
);
688+
689+
expect(rawChunks.length).toBe(0);
690+
});
691+
692+
it('should emit raw chunks for each SSE event including usage chunk', async () => {
693+
prepareStreamResponse({ content: ['Hello', ' World'] });
694+
695+
const { stream } = await model.doStream({
696+
prompt: TEST_PROMPT,
697+
includeRawChunks: true,
698+
});
699+
700+
const elements = await convertReadableStreamToArray(stream);
701+
const rawChunks = elements.filter(
702+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
703+
chunk.type === 'raw',
704+
);
705+
706+
// Should have raw chunks for: Hello, World, finish_reason, usage
707+
expect(rawChunks.length).toBe(4);
708+
});
709+
710+
it('should emit raw chunk even when parsing fails (for debugging malformed responses)', async () => {
711+
server.urls['https://openrouter.ai/api/v1/completions']!.response = {
712+
type: 'stream-chunks',
713+
chunks: ['data: {unparsable}\n\n', 'data: [DONE]\n\n'],
714+
};
715+
716+
const { stream } = await model.doStream({
717+
prompt: TEST_PROMPT,
718+
includeRawChunks: true,
719+
});
720+
721+
const elements = await convertReadableStreamToArray(stream);
722+
const rawChunks = elements.filter(
723+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'raw' }> =>
724+
chunk.type === 'raw',
725+
);
726+
const errorChunks = elements.filter(
727+
(chunk): chunk is Extract<LanguageModelV3StreamPart, { type: 'error' }> =>
728+
chunk.type === 'error',
729+
);
730+
731+
// Raw chunk is emitted before error handling, useful for debugging
732+
expect(rawChunks.length).toBe(1);
733+
expect(errorChunks.length).toBe(1);
734+
});
735+
});

src/completion/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,11 @@ export class OpenRouterCompletionLanguageModel implements LanguageModelV3 {
288288
LanguageModelV3StreamPart
289289
>({
290290
transform(chunk, controller) {
291+
// Emit raw chunk if requested (before anything else)
292+
if (options.includeRawChunks) {
293+
controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });
294+
}
295+
291296
// handle failed chunk parsing / validation:
292297
if (!chunk.success) {
293298
finishReason = createFinishReason('error');

0 commit comments

Comments
 (0)