Skip to content

Commit 73bacd5

Browse files
committed
feat(language): add singular stream-part builders
Expose the primitives the streamText/streamReasoning/streamToolInput blocks compose: streamTextStart/Delta/End, streamReasoningStart/Delta/End, and streamToolInputStart/Delta/End. The block builders now build from them. Delta builders take the delta string first, then an optional { id } (id defaults to '1'), mirroring the block builders; start/end take options only and streamToolInputStart takes the tool name first. Each returns a single LanguageModelV4StreamPart.
1 parent df47e5c commit 73bacd5

4 files changed

Lines changed: 214 additions & 17 deletions

File tree

README.md

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,19 +684,84 @@ Language.custom(args: { kind: `${string}.${string}`; providerMetadata?: SharedV4
684684
685685
##### Stream parts
686686
687+
#### `.streamTextStart(options?)`
688+
689+
```ts
690+
Language.streamTextStart(options?: { id?: string }): LanguageModelV4StreamPart
691+
// Language.streamTextStart(): { type: 'text-start', id: '1' } — id defaults to '1'
692+
```
693+
694+
#### `.streamTextDelta(text, options?)`
695+
696+
```ts
697+
Language.streamTextDelta(text: string, options?: { id?: string }): LanguageModelV4StreamPart
698+
// Language.streamTextDelta('Hi'): { type: 'text-delta', id: '1', delta: 'Hi' } — the delta string comes first, mirroring streamText
699+
```
700+
701+
#### `.streamTextEnd(options?)`
702+
703+
```ts
704+
Language.streamTextEnd(options?: { id?: string }): LanguageModelV4StreamPart
705+
// Language.streamTextEnd(): { type: 'text-end', id: '1' }
706+
```
707+
687708
#### `.streamText(text, options?)`
688709
689710
```ts
690711
Language.streamText(text: string | string[], options?: StreamPartOptions): LanguageModelV4StreamPart[]
691712
// Language.streamText('Hi'): [{ type: 'text-start', id: '1' }, { type: 'text-delta', id: '1', delta: 'Hi' }, { type: 'text-end', id: '1' }]
692713
// Language.streamText(['He', 'llo']): an explicit two-delta split (string[] used as the deltas verbatim; length/separator ignored)
714+
// Composed from streamTextStart / streamTextDelta / streamTextEnd.
715+
```
716+
717+
#### `.streamReasoningStart(options?)`
718+
719+
```ts
720+
Language.streamReasoningStart(options?: { id?: string }): LanguageModelV4StreamPart
721+
// Language.streamReasoningStart(): { type: 'reasoning-start', id: '1' }
722+
```
723+
724+
#### `.streamReasoningDelta(text, options?)`
725+
726+
```ts
727+
Language.streamReasoningDelta(text: string, options?: { id?: string }): LanguageModelV4StreamPart
728+
// Language.streamReasoningDelta('Hmm'): { type: 'reasoning-delta', id: '1', delta: 'Hmm' }
729+
```
730+
731+
#### `.streamReasoningEnd(options?)`
732+
733+
```ts
734+
Language.streamReasoningEnd(options?: { id?: string }): LanguageModelV4StreamPart
735+
// Language.streamReasoningEnd(): { type: 'reasoning-end', id: '1' }
693736
```
694737
695738
#### `.streamReasoning(text, options?)`
696739
697740
```ts
698741
Language.streamReasoning(text: string | string[], options?: StreamPartOptions): LanguageModelV4StreamPart[]
699742
// Language.streamReasoning('Hmm'): [{ type: 'reasoning-start', id: '1' }, { type: 'reasoning-delta', id: '1', delta: 'Hmm' }, { type: 'reasoning-end', id: '1' }]
743+
// Composed from streamReasoningStart / streamReasoningDelta / streamReasoningEnd.
744+
```
745+
746+
#### `.streamToolInputStart(toolName, options?)`
747+
748+
```ts
749+
Language.streamToolInputStart(toolName: string, options?: { id?: string }): LanguageModelV4StreamPart
750+
// Language.streamToolInputStart('weather', { id: 't1' }): { type: 'tool-input-start', id: 't1', toolName: 'weather' }
751+
```
752+
753+
#### `.streamToolInputDelta(text, options?)`
754+
755+
```ts
756+
Language.streamToolInputDelta(text: string, options?: { id?: string }): LanguageModelV4StreamPart
757+
// Language.streamToolInputDelta('{"city":"Tokyo"}', { id: 't1' }): { type: 'tool-input-delta', id: 't1', delta: '{"city":"Tokyo"}' }
758+
```
759+
760+
#### `.streamToolInputEnd(options?)`
761+
762+
```ts
763+
Language.streamToolInputEnd(options?: { id?: string }): LanguageModelV4StreamPart
764+
// Language.streamToolInputEnd({ id: 't1' }): { type: 'tool-input-end', id: 't1' }
700765
```
701766
702767
#### `.streamToolInput(args)`
@@ -705,6 +770,7 @@ Language.streamReasoning(text: string | string[], options?: StreamPartOptions):
705770
Language.streamToolInput<TOOLS extends ToolSet = never>(args: { id: string; toolName: string; input: unknown; length?: number }): LanguageModelV4StreamPart[]
706771
// Language.streamToolInput({ id: 't1', toolName: 'weather', input: { city: 'Tokyo' } }): [{ type: 'tool-input-start', id: 't1', toolName: 'weather' }, { type: 'tool-input-delta', id: 't1', delta: '{"city":"Tokyo"}' }, { type: 'tool-input-end', id: 't1' }]
707772
// Pass <typeof tools> to constrain toolName to a tool key and input to that tool's input type; omit it to stay loose.
773+
// Composed from streamToolInputStart / streamToolInputDelta / streamToolInputEnd.
708774
```
709775
710776
#### `.streamStart(warnings?)`
@@ -1331,7 +1397,7 @@ import type { MockLanguageModelOptions } from 'ai-test-kit/language';
13311397
13321398
#### `StreamPartOptions`
13331399
1334-
Options for the streamed-text part builders (`Language.streamText` / `Language.streamReasoning`).
1400+
Options for the streamed-text block builders (`Language.streamText` / `Language.streamReasoning`).
13351401
13361402
```ts
13371403
import type { StreamPartOptions } from 'ai-test-kit/language';

src/language/language.test-d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,25 @@ describe('Language exhaustiveness', () => {
122122
expectTypeOf<BuiltStreamTag>().toEqualTypeOf<LanguageModelV4StreamPart['type']>();
123123
});
124124
});
125+
126+
describe('Language singular stream builders', () => {
127+
test('each singular builder should return the wide stream-part type', () => {
128+
expectTypeOf(Language.streamTextStart()).toEqualTypeOf<LanguageModelV4StreamPart>();
129+
expectTypeOf(Language.streamTextDelta('a')).toEqualTypeOf<LanguageModelV4StreamPart>();
130+
expectTypeOf(Language.streamTextEnd()).toEqualTypeOf<LanguageModelV4StreamPart>();
131+
expectTypeOf(Language.streamReasoningStart()).toEqualTypeOf<LanguageModelV4StreamPart>();
132+
expectTypeOf(Language.streamReasoningDelta('a')).toEqualTypeOf<LanguageModelV4StreamPart>();
133+
expectTypeOf(Language.streamReasoningEnd()).toEqualTypeOf<LanguageModelV4StreamPart>();
134+
expectTypeOf(Language.streamToolInputStart('w')).toEqualTypeOf<LanguageModelV4StreamPart>();
135+
expectTypeOf(Language.streamToolInputDelta('a')).toEqualTypeOf<LanguageModelV4StreamPart>();
136+
expectTypeOf(Language.streamToolInputEnd()).toEqualTypeOf<LanguageModelV4StreamPart>();
137+
});
138+
139+
test('the delta builders should take the delta string first, then options', () => {
140+
expectTypeOf(Language.streamTextDelta).toBeCallableWith('hi');
141+
expectTypeOf(Language.streamTextDelta).toBeCallableWith('hi', { id: '2' });
142+
expectTypeOf(Language.streamReasoningDelta).toBeCallableWith('hmm', { id: 'r' });
143+
expectTypeOf(Language.streamToolInputStart).toBeCallableWith('weather', { id: 't1' });
144+
expectTypeOf(Language.streamToolInputDelta).toBeCallableWith('{', { id: 't1' });
145+
});
146+
});

src/language/language.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,49 @@ describe('Language', () => {
100100
]);
101101
});
102102

103+
test('the singular text builders should build their parts and default the id to "1"', () => {
104+
// Assert
105+
expect(Language.streamTextStart()).toEqual({ type: 'text-start', id: '1' });
106+
expect(Language.streamTextDelta('hi')).toEqual({ type: 'text-delta', id: '1', delta: 'hi' });
107+
expect(Language.streamTextEnd()).toEqual({ type: 'text-end', id: '1' });
108+
});
109+
110+
test('the singular reasoning builders should build their parts with a custom id', () => {
111+
// Assert
112+
expect(Language.streamReasoningStart({ id: 'r' })).toEqual({ type: 'reasoning-start', id: 'r' });
113+
expect(Language.streamReasoningDelta('hm', { id: 'r' })).toEqual({
114+
type: 'reasoning-delta',
115+
id: 'r',
116+
delta: 'hm',
117+
});
118+
expect(Language.streamReasoningEnd({ id: 'r' })).toEqual({ type: 'reasoning-end', id: 'r' });
119+
});
120+
121+
test('the singular tool-input builders should build their parts', () => {
122+
// Assert
123+
expect(Language.streamToolInputStart('weather', { id: 't1' })).toEqual({
124+
type: 'tool-input-start',
125+
id: 't1',
126+
toolName: 'weather',
127+
});
128+
expect(Language.streamToolInputDelta('{', { id: 't1' })).toEqual({
129+
type: 'tool-input-delta',
130+
id: 't1',
131+
delta: '{',
132+
});
133+
expect(Language.streamToolInputEnd({ id: 't1' })).toEqual({ type: 'tool-input-end', id: 't1' });
134+
});
135+
136+
test('streamText() should compose the singular text builders', () => {
137+
// Assert
138+
expect(Language.streamText('ab', { length: 1 })).toEqual([
139+
Language.streamTextStart(),
140+
Language.streamTextDelta('a'),
141+
Language.streamTextDelta('b'),
142+
Language.streamTextEnd(),
143+
]);
144+
});
145+
103146
test('streamStart() should default warnings to an empty array', () => {
104147
// Assert
105148
expect(Language.streamStart()).toEqual({ type: 'stream-start', warnings: [] });

src/language/language.ts

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ import { toJSONString } from '../internal/json.js';
2121
import { tokenize } from '../internal/tokenize.js';
2222
import { simulateStream, type StreamDelayOptions } from '../streams.js';
2323

24-
/** Options for the streamed-text builders: a stable part `id` plus a tokenization strategy. */
25-
export type StreamPartOptions = {
26-
/** Stable id shared by the start/delta/end parts. */
24+
/** Options for a singular stream-part builder: the stable part `id`, defaulting to `'1'`. */
25+
type StreamPartIdOptions = {
26+
/** Stable id shared by the start/delta/end parts of one block. */
2727
id?: string;
28+
};
29+
30+
/** Options for the streamed-text block builders: a stable part `id` plus a tokenization strategy. */
31+
export type StreamPartOptions = StreamPartIdOptions & {
2832
/** Split the text into fixed-size slices of at most this many characters. */
2933
length?: number;
3034
/** Split the text on this delimiter, re-appending it to each token. */
@@ -179,6 +183,22 @@ const reasoningFile = (args: { mediaType: string; data: string | Uint8Array }):
179183
const toDeltas = (text: string | Array<string>, length?: number, separator?: string): Array<string> =>
180184
Array.isArray(text) ? text : tokenize(text, { length, separator });
181185

186+
/** A `text-start` part opening a streamed text block. */
187+
const streamTextStart = ({ id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
188+
type: 'text-start',
189+
id,
190+
});
191+
192+
/** A `text-delta` part carrying a slice of streamed text. */
193+
const streamTextDelta = (text: string, { id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
194+
type: 'text-delta',
195+
id,
196+
delta: text,
197+
});
198+
199+
/** A `text-end` part closing a streamed text block. */
200+
const streamTextEnd = ({ id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({ type: 'text-end', id });
201+
182202
/**
183203
* A streamed text block: `text-start` → `text-delta`* → `text-end`. A `string` is split per
184204
* `length`/`separator`; an `Array<string>` is used as the deltas verbatim.
@@ -187,35 +207,72 @@ const streamText = (
187207
text: string | Array<string>,
188208
{ id = '1', length, separator }: StreamPartOptions = {},
189209
): Array<LanguageModelV4StreamPart> => [
190-
{ type: 'text-start', id },
191-
...toDeltas(text, length, separator).map((delta) => ({ type: 'text-delta' as const, id, delta })),
192-
{ type: 'text-end', id },
210+
streamTextStart({ id }),
211+
...toDeltas(text, length, separator).map((delta) => streamTextDelta(delta, { id })),
212+
streamTextEnd({ id }),
193213
];
194214

215+
/** A `reasoning-start` part opening a streamed reasoning block. */
216+
const streamReasoningStart = ({ id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
217+
type: 'reasoning-start',
218+
id,
219+
});
220+
221+
/** A `reasoning-delta` part carrying a slice of streamed reasoning. */
222+
const streamReasoningDelta = (text: string, { id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
223+
type: 'reasoning-delta',
224+
id,
225+
delta: text,
226+
});
227+
228+
/** A `reasoning-end` part closing a streamed reasoning block. */
229+
const streamReasoningEnd = ({ id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
230+
type: 'reasoning-end',
231+
id,
232+
});
233+
195234
/** A streamed reasoning block: `reasoning-start` → `reasoning-delta`* → `reasoning-end`. */
196235
const streamReasoning = (
197236
text: string | Array<string>,
198237
{ id = '1', length, separator }: StreamPartOptions = {},
199238
): Array<LanguageModelV4StreamPart> => [
200-
{ type: 'reasoning-start', id },
201-
...toDeltas(text, length, separator).map((delta) => ({ type: 'reasoning-delta' as const, id, delta })),
202-
{ type: 'reasoning-end', id },
239+
streamReasoningStart({ id }),
240+
...toDeltas(text, length, separator).map((delta) => streamReasoningDelta(delta, { id })),
241+
streamReasoningEnd({ id }),
203242
];
204243

244+
/** A `tool-input-start` part opening a streamed tool input. */
245+
const streamToolInputStart = (toolName: string, { id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
246+
type: 'tool-input-start',
247+
id,
248+
toolName,
249+
});
250+
251+
/** A `tool-input-delta` part carrying a slice of the streamed tool input. */
252+
const streamToolInputDelta = (text: string, { id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
253+
type: 'tool-input-delta',
254+
id,
255+
delta: text,
256+
});
257+
258+
/** A `tool-input-end` part closing a streamed tool input. */
259+
const streamToolInputEnd = ({ id = '1' }: StreamPartIdOptions = {}): LanguageModelV4StreamPart => ({
260+
type: 'tool-input-end',
261+
id,
262+
});
263+
205264
/**
206265
* A streamed tool input: `tool-input-start` → `tool-input-delta`* → `tool-input-end`.
207266
* Pass a tool-set as `TOOLS` to constrain `toolName` and `input`.
208267
*/
209268
const streamToolInput = <TOOLS extends ToolSet = never>(
210269
args: StreamToolInputArgs<TOOLS>,
211270
): Array<LanguageModelV4StreamPart> => [
212-
{ type: 'tool-input-start', id: args.id, toolName: args.toolName },
213-
...tokenize(toJSONString(args.input), { length: args.length }).map((delta) => ({
214-
type: 'tool-input-delta' as const,
215-
id: args.id,
216-
delta,
217-
})),
218-
{ type: 'tool-input-end', id: args.id },
271+
streamToolInputStart(args.toolName, { id: args.id }),
272+
...tokenize(toJSONString(args.input), { length: args.length }).map((delta) =>
273+
streamToolInputDelta(delta, { id: args.id }),
274+
),
275+
streamToolInputEnd({ id: args.id }),
219276
];
220277

221278
/** The opening `stream-start` part carrying call warnings. */
@@ -321,8 +378,17 @@ export const Language = {
321378
source,
322379
custom,
323380
reasoningFile,
381+
streamTextStart,
382+
streamTextDelta,
383+
streamTextEnd,
324384
streamText,
385+
streamReasoningStart,
386+
streamReasoningDelta,
387+
streamReasoningEnd,
325388
streamReasoning,
389+
streamToolInputStart,
390+
streamToolInputDelta,
391+
streamToolInputEnd,
326392
streamToolInput,
327393
streamStart,
328394
streamFinish,

0 commit comments

Comments
 (0)