ai_stream 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/lib/ai_stream/stream.rb +79 -0
- data/lib/ai_stream/version.rb +5 -0
- data/lib/ai_stream/writer.rb +230 -0
- data/lib/ai_stream.rb +41 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a2d50ba3d9bb262b476d90beaafce8b11a324f6239003efe3ce7edf6cb1c196e
|
|
4
|
+
data.tar.gz: 813ea5c8a9768269b65e7e7c4f275ed3be9d0c1567ac9e55228ff64569cc4bd7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c31fda77fe3a3fb48b105847a1556a3aa6b8490941b15c8d82bb7dc3dd04923cb72dea3490da1c7d3a000a9eff764f0a6a888e094b578f869f88543165b5146b
|
|
7
|
+
data.tar.gz: d2c4d97681e3afb524a093f6c646f00928b6bbc9f393632b02a2f1c82dc806c8bea3187c020cbde6e7ec5ca88863bab6dda8e0d275f6ec30353831e7149f3e39
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-05-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `AiStream::Writer` — encoder for the Vercel AI SDK Data Stream / UI Message
|
|
13
|
+
Stream Protocol over Server-Sent Events. Covers the full documented part set:
|
|
14
|
+
- lifecycle: `start`, `start-step`, `finish-step`, `finish`, `abort`, `error`
|
|
15
|
+
- text: `text-start`, `text-delta`, `text-end` (+ `#text` convenience)
|
|
16
|
+
- reasoning: `reasoning-start`, `reasoning-delta`, `reasoning-end`
|
|
17
|
+
- tools: `tool-input-start`, `tool-input-delta`, `tool-input-available`,
|
|
18
|
+
`tool-output-available` (+ `#tool_call` convenience)
|
|
19
|
+
- sources/files: `source-url`, `source-document`, `file`
|
|
20
|
+
- custom: `data-*` parts
|
|
21
|
+
- low-level `#emit` for forward-compatibility with future part types
|
|
22
|
+
- `AiStream::Stream` — lazy, re-enumerable, Rack-compatible response body that
|
|
23
|
+
runs a block against a `Writer`, frames each part as SSE, and appends the
|
|
24
|
+
`[DONE]` sentinel automatically.
|
|
25
|
+
- `AiStream::HEADERS` — the required `x-vercel-ai-ui-message-stream: v1` header.
|
|
26
|
+
- Pure Ruby, zero runtime dependencies.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://github.com/tachyurgy/ai_stream/compare/v0.1.0...HEAD
|
|
29
|
+
[0.1.0]: https://github.com/tachyurgy/ai_stream/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Levelbrook Consulting
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# ai_stream
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tachyurgy/ai_stream/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**Speak the Vercel AI SDK's streaming protocol from Ruby.**
|
|
6
|
+
|
|
7
|
+
The [Vercel AI SDK](https://ai-sdk.dev) is the de-facto frontend toolkit for AI apps — its
|
|
8
|
+
`useChat`, `useCompletion`, and `useObject` hooks power a huge share of the AI UIs shipping
|
|
9
|
+
today. Those hooks consume a specific [Data Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol)
|
|
10
|
+
(a.k.a. the **UI Message Stream Protocol**): a Server-Sent-Events wire format that is
|
|
11
|
+
*language-agnostic by design* — Vercel documents Python/FastAPI backends speaking it.
|
|
12
|
+
|
|
13
|
+
Ruby had nothing. If you wanted the polished Vercel `useChat` frontend in front of a Rails
|
|
14
|
+
app, you had to hand-roll the SSE framing and the exact `text-start` / `text-delta` /
|
|
15
|
+
`tool-input-available` / `finish` part encoding by reading the TypeScript source.
|
|
16
|
+
|
|
17
|
+
`ai_stream` is a faithful, pure-Ruby, **zero-dependency** encoder for that protocol. It's
|
|
18
|
+
**provider-agnostic**: it sits *downstream* of whatever produced the tokens
|
|
19
|
+
([ruby_llm](https://github.com/crmne/ruby_llm), [ruby-openai](https://github.com/alexrudall/ruby-openai),
|
|
20
|
+
a raw HTTP stream, or canned text), so it composes with the existing Ruby AI stack instead
|
|
21
|
+
of competing with it.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# Gemfile
|
|
27
|
+
gem "ai_stream"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bundle install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
gem install ai_stream
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require "ai_stream"
|
|
44
|
+
|
|
45
|
+
body = AiStream::Stream.new do |w|
|
|
46
|
+
w.start # {"type":"start","messageId":"..."}
|
|
47
|
+
id = w.text_start # {"type":"text-start","id":"..."}
|
|
48
|
+
w.text_delta("Hello", id: id)
|
|
49
|
+
w.text_delta(" world", id: id)
|
|
50
|
+
w.text_end(id: id)
|
|
51
|
+
w.finish # {"type":"finish"}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
puts body.to_s
|
|
55
|
+
# data: {"type":"start","messageId":"..."}
|
|
56
|
+
#
|
|
57
|
+
# data: {"type":"text-start","id":"..."}
|
|
58
|
+
#
|
|
59
|
+
# data: {"type":"text-delta","id":"...","delta":"Hello"}
|
|
60
|
+
#
|
|
61
|
+
# data: {"type":"text-delta","id":"...","delta":" world"}
|
|
62
|
+
#
|
|
63
|
+
# data: {"type":"text-end","id":"..."}
|
|
64
|
+
#
|
|
65
|
+
# data: {"type":"finish"}
|
|
66
|
+
#
|
|
67
|
+
# data: [DONE]
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## In Rails (streaming to `useChat`)
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
class ChatController < ApplicationController
|
|
74
|
+
include ActionController::Live
|
|
75
|
+
|
|
76
|
+
def create
|
|
77
|
+
# Required header so the AI SDK treats this as a UI message stream:
|
|
78
|
+
AiStream::HEADERS.each { |k, v| response.headers[k] = v }
|
|
79
|
+
response.headers["Content-Type"] = "text/event-stream"
|
|
80
|
+
|
|
81
|
+
AiStream::Stream.new do |w|
|
|
82
|
+
w.start
|
|
83
|
+
id = w.text_start
|
|
84
|
+
# Pipe tokens from any source. Example with ruby_llm:
|
|
85
|
+
RubyLLM.chat.ask(params[:prompt]) do |chunk|
|
|
86
|
+
w.text_delta(chunk.content, id: id)
|
|
87
|
+
end
|
|
88
|
+
w.text_end(id: id)
|
|
89
|
+
w.finish
|
|
90
|
+
end.each { |frame| response.stream.write(frame) }
|
|
91
|
+
ensure
|
|
92
|
+
response.stream.close
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Frontend, unchanged from any Vercel AI SDK app:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
const { messages, sendMessage } = useChat({ api: "/chat" });
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## In plain Rack
|
|
104
|
+
|
|
105
|
+
`AiStream::Stream` is a valid Rack response body (it responds to `#each` and yields complete
|
|
106
|
+
SSE frames):
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
run lambda { |env|
|
|
110
|
+
body = AiStream::Stream.new do |w|
|
|
111
|
+
w.start
|
|
112
|
+
w.text("Hi from Rack")
|
|
113
|
+
w.finish
|
|
114
|
+
end
|
|
115
|
+
headers = AiStream::HEADERS.merge("content-type" => "text/event-stream")
|
|
116
|
+
[200, headers, body]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Tool calls
|
|
121
|
+
|
|
122
|
+
The protocol streams tool calls as a lifecycle. For incrementally-produced arguments:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
AiStream::Stream.new do |w|
|
|
126
|
+
w.start
|
|
127
|
+
w.start_step
|
|
128
|
+
w.tool_input_start(tool_call_id: "t1", tool_name: "get_weather")
|
|
129
|
+
w.tool_input_delta(tool_call_id: "t1", delta: '{"city":')
|
|
130
|
+
w.tool_input_delta(tool_call_id: "t1", delta: '"SF"}')
|
|
131
|
+
w.tool_input_available(tool_call_id: "t1", tool_name: "get_weather", input: { city: "SF" })
|
|
132
|
+
w.tool_output_available(tool_call_id: "t1", output: { temp: 64 })
|
|
133
|
+
w.finish_step
|
|
134
|
+
w.start_step
|
|
135
|
+
w.text("It's 64°F in San Francisco.")
|
|
136
|
+
w.finish_step
|
|
137
|
+
w.finish
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
When the input is already known, `#tool_call` collapses the two parts (and shares a
|
|
142
|
+
`toolCallId`):
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
w.tool_call(tool_name: "search", input: { q: "ruby" }, output: { hits: 3 })
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Supported parts
|
|
149
|
+
|
|
150
|
+
Every part type from the AI SDK UI Stream Protocol:
|
|
151
|
+
|
|
152
|
+
| Category | Writer methods |
|
|
153
|
+
|---|---|
|
|
154
|
+
| Lifecycle | `start`, `start_step`, `finish_step`, `finish`, `abort`, `error` |
|
|
155
|
+
| Text | `text_start`, `text_delta`, `text_end`, `text` |
|
|
156
|
+
| Reasoning | `reasoning_start`, `reasoning_delta`, `reasoning_end` |
|
|
157
|
+
| Tools | `tool_input_start`, `tool_input_delta`, `tool_input_available`, `tool_output_available`, `tool_call` |
|
|
158
|
+
| Sources / files | `source_url`, `source_document`, `file` |
|
|
159
|
+
| Custom data | `data(name, payload)` → `data-<name>` |
|
|
160
|
+
| Forward-compat | `emit(type:, ...)` for any part type added after this release |
|
|
161
|
+
|
|
162
|
+
The stream is terminated with the SSE `data: [DONE]` sentinel automatically by
|
|
163
|
+
`AiStream::Stream`; if you drive a `Writer` directly, call `#done` yourself.
|
|
164
|
+
|
|
165
|
+
## Why a `Writer` *and* a `Stream`?
|
|
166
|
+
|
|
167
|
+
- **`AiStream::Writer`** is the low-level encoder. Give it any sink that responds to `<<`
|
|
168
|
+
(a `String`, an `IO`, a Rack stream). It does no IO of its own, which makes it trivial to
|
|
169
|
+
unit-test — feed it a `String` and assert on the bytes. (That's exactly how this gem's own
|
|
170
|
+
tests work, with **no API key required**.)
|
|
171
|
+
- **`AiStream::Stream`** wraps a `Writer` in a lazy, re-enumerable, Rack-compatible body and
|
|
172
|
+
handles the `[DONE]` terminator for you.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
bin/setup # or: bundle install
|
|
178
|
+
bundle exec rake test
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
[MIT](LICENSE) © [Levelbrook Consulting](https://consulting.levelbrook.com)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AiStream
|
|
4
|
+
# Stream is a lazy, Rack-compatible response body that produces UI Message
|
|
5
|
+
# Stream Protocol frames on demand.
|
|
6
|
+
#
|
|
7
|
+
# You pass a block that receives a Writer; the block runs the first time the
|
|
8
|
+
# body is enumerated (i.e. when Rack/Rails pulls bytes to send to the client),
|
|
9
|
+
# so nothing is buffered eagerly and the very first token can flush
|
|
10
|
+
# immediately. The terminating `data: [DONE]` frame is appended automatically
|
|
11
|
+
# unless the block already wrote it.
|
|
12
|
+
#
|
|
13
|
+
# Rack:
|
|
14
|
+
#
|
|
15
|
+
# body = AiStream::Stream.new do |w|
|
|
16
|
+
# w.start
|
|
17
|
+
# w.text("Hello")
|
|
18
|
+
# w.finish
|
|
19
|
+
# end
|
|
20
|
+
# [200, AiStream::HEADERS.merge("content-type" => "text/event-stream"), body]
|
|
21
|
+
#
|
|
22
|
+
# Rails (controller):
|
|
23
|
+
#
|
|
24
|
+
# include ActionController::Live
|
|
25
|
+
# def chat
|
|
26
|
+
# AiStream::HEADERS.each { |k, v| response.headers[k] = v }
|
|
27
|
+
# response.headers["Content-Type"] = "text/event-stream"
|
|
28
|
+
# AiStream::Stream.new { |w| w.start; w.text("hi"); w.finish }.each { |chunk| response.stream.write(chunk) }
|
|
29
|
+
# ensure
|
|
30
|
+
# response.stream.close
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# Or, even simpler, collect to a String for tests / non-streaming responses:
|
|
34
|
+
#
|
|
35
|
+
# AiStream::Stream.new { |w| w.start; w.text("hi"); w.finish }.to_s
|
|
36
|
+
#
|
|
37
|
+
class Stream
|
|
38
|
+
include Enumerable
|
|
39
|
+
|
|
40
|
+
# @yieldparam writer [AiStream::Writer]
|
|
41
|
+
def initialize(&block)
|
|
42
|
+
raise ArgumentError, "AiStream::Stream requires a block" unless block
|
|
43
|
+
|
|
44
|
+
@block = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Rack body contract. Yields each SSE frame string. Re-enumerable: the block
|
|
48
|
+
# is run fresh on every #each so the same Stream can be rendered twice
|
|
49
|
+
# (handy in tests).
|
|
50
|
+
def each
|
|
51
|
+
return enum_for(:each) unless block_given?
|
|
52
|
+
|
|
53
|
+
sink = FrameSink.new { |frame| yield frame }
|
|
54
|
+
writer = Writer.new(sink)
|
|
55
|
+
@block.call(writer)
|
|
56
|
+
writer.done unless writer.closed?
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Materialize the whole stream into one String.
|
|
61
|
+
def to_s
|
|
62
|
+
buf = String.new
|
|
63
|
+
each { |frame| buf << frame }
|
|
64
|
+
buf
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A tiny sink adapter: turns Writer's `sink << frame` into a yielded chunk.
|
|
68
|
+
class FrameSink
|
|
69
|
+
def initialize(&on_frame)
|
|
70
|
+
@on_frame = on_frame
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def <<(frame)
|
|
74
|
+
@on_frame.call(frame)
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module AiStream
|
|
7
|
+
# Writer encodes the Vercel AI SDK "Data Stream Protocol" (a.k.a. UI Message
|
|
8
|
+
# Stream Protocol) onto an arbitrary sink.
|
|
9
|
+
#
|
|
10
|
+
# The protocol is a sequence of Server-Sent Events. Every event is a single
|
|
11
|
+
# JSON object framed as:
|
|
12
|
+
#
|
|
13
|
+
# data: {"type":"text-delta","id":"...","delta":"Hi"}\n\n
|
|
14
|
+
#
|
|
15
|
+
# and the stream is terminated with the sentinel:
|
|
16
|
+
#
|
|
17
|
+
# data: [DONE]\n\n
|
|
18
|
+
#
|
|
19
|
+
# A consuming frontend (Vercel AI SDK's useChat / useCompletion / useObject)
|
|
20
|
+
# expects the HTTP response header `x-vercel-ai-ui-message-stream: v1`
|
|
21
|
+
# (see AiStream::HEADERS).
|
|
22
|
+
#
|
|
23
|
+
# The sink is anything that responds to `<<` (a String, an IO, a Rack stream,
|
|
24
|
+
# an Array buffer, ...). The Writer never performs IO itself beyond `sink <<`,
|
|
25
|
+
# which keeps it trivially unit-testable: feed it a String and assert on bytes.
|
|
26
|
+
#
|
|
27
|
+
# Example:
|
|
28
|
+
#
|
|
29
|
+
# buf = +""
|
|
30
|
+
# w = AiStream::Writer.new(buf)
|
|
31
|
+
# w.start
|
|
32
|
+
# id = w.text_start
|
|
33
|
+
# w.text_delta("Hello", id: id)
|
|
34
|
+
# w.text_delta(" world", id: id)
|
|
35
|
+
# w.text_end(id: id)
|
|
36
|
+
# w.finish
|
|
37
|
+
# w.done
|
|
38
|
+
#
|
|
39
|
+
class Writer
|
|
40
|
+
# Returns a freshly generated id when callers don't supply one. Exposed so
|
|
41
|
+
# text/reasoning/tool parts can share an id across their start/delta/end
|
|
42
|
+
# lifecycle.
|
|
43
|
+
def self.generate_id
|
|
44
|
+
SecureRandom.uuid
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :sink
|
|
48
|
+
|
|
49
|
+
# @param sink [#<<] anything that accepts string chunks (IO, String, Rack body, ...)
|
|
50
|
+
def initialize(sink)
|
|
51
|
+
@sink = sink
|
|
52
|
+
@closed = false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Lifecycle -----------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
# Emit the message-start frame. messageId is optional; one is generated when
|
|
58
|
+
# omitted so the frontend always has a stable id to key the message on.
|
|
59
|
+
def start(message_id: nil)
|
|
60
|
+
mid = message_id || self.class.generate_id
|
|
61
|
+
emit(type: "start", messageId: mid)
|
|
62
|
+
mid
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Multi-step runs (tool call -> tool result -> more text) are bracketed by
|
|
66
|
+
# start-step / finish-step pairs.
|
|
67
|
+
def start_step
|
|
68
|
+
emit(type: "start-step")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def finish_step
|
|
72
|
+
emit(type: "finish-step")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Terminal message frame. Does NOT write the SSE [DONE] sentinel; call #done
|
|
76
|
+
# for that (kept separate so callers can finish a message but keep the HTTP
|
|
77
|
+
# connection open, which some multi-message flows want).
|
|
78
|
+
def finish
|
|
79
|
+
emit(type: "finish")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Cooperative cancellation frame.
|
|
83
|
+
def abort(reason: nil)
|
|
84
|
+
part = { type: "abort" }
|
|
85
|
+
part[:reason] = reason unless reason.nil?
|
|
86
|
+
emit(part)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Surface an error to the client. The frontend renders `errorText`.
|
|
90
|
+
def error(text)
|
|
91
|
+
emit(type: "error", errorText: text.to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --- Text ----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# Begin a text block. Returns the block id to thread through deltas/end.
|
|
97
|
+
def text_start(id: nil)
|
|
98
|
+
tid = id || self.class.generate_id
|
|
99
|
+
emit(type: "text-start", id: tid)
|
|
100
|
+
tid
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def text_delta(delta, id:)
|
|
104
|
+
emit(type: "text-delta", id: id, delta: delta.to_s)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def text_end(id:)
|
|
108
|
+
emit(type: "text-end", id: id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Convenience: emit a whole text block (start + single delta + end) at once.
|
|
112
|
+
# Returns the block id.
|
|
113
|
+
def text(content, id: nil)
|
|
114
|
+
tid = text_start(id: id)
|
|
115
|
+
text_delta(content, id: tid)
|
|
116
|
+
text_end(id: tid)
|
|
117
|
+
tid
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- Reasoning -----------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def reasoning_start(id: nil)
|
|
123
|
+
rid = id || self.class.generate_id
|
|
124
|
+
emit(type: "reasoning-start", id: rid)
|
|
125
|
+
rid
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def reasoning_delta(delta, id:)
|
|
129
|
+
emit(type: "reasoning-delta", id: id, delta: delta.to_s)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def reasoning_end(id:)
|
|
133
|
+
emit(type: "reasoning-end", id: id)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Sources & files -----------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def source_url(url, source_id: nil, title: nil)
|
|
139
|
+
part = { type: "source-url", sourceId: source_id || self.class.generate_id, url: url }
|
|
140
|
+
part[:title] = title unless title.nil?
|
|
141
|
+
emit(part)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def source_document(media_type:, title:, source_id: nil)
|
|
145
|
+
emit(
|
|
146
|
+
type: "source-document",
|
|
147
|
+
sourceId: source_id || self.class.generate_id,
|
|
148
|
+
mediaType: media_type,
|
|
149
|
+
title: title
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def file(url:, media_type:)
|
|
154
|
+
emit(type: "file", url: url, mediaType: media_type)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --- Custom data parts ---------------------------------------------------
|
|
158
|
+
|
|
159
|
+
# Emits a `data-<name>` part. The frontend matches on the full type string,
|
|
160
|
+
# so a name of "weather" produces {"type":"data-weather","data":{...}}.
|
|
161
|
+
def data(name, payload)
|
|
162
|
+
emit(type: "data-#{name}", data: payload)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- Tool calls ----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# Streaming tool-input lifecycle (when arguments are produced incrementally):
|
|
168
|
+
# tool_input_start -> tool_input_delta* -> tool_input_available
|
|
169
|
+
def tool_input_start(tool_call_id:, tool_name:)
|
|
170
|
+
emit(type: "tool-input-start", toolCallId: tool_call_id, toolName: tool_name)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def tool_input_delta(tool_call_id:, delta:)
|
|
174
|
+
emit(type: "tool-input-delta", toolCallId: tool_call_id, inputTextDelta: delta.to_s)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Final, parsed tool input. `input` is any JSON-serializable object.
|
|
178
|
+
def tool_input_available(tool_call_id:, tool_name:, input:)
|
|
179
|
+
emit(type: "tool-input-available", toolCallId: tool_call_id, toolName: tool_name, input: input)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The result of executing the tool. `output` is any JSON-serializable object.
|
|
183
|
+
def tool_output_available(tool_call_id:, output:)
|
|
184
|
+
emit(type: "tool-output-available", toolCallId: tool_call_id, output: output)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Convenience: emit a complete non-streamed tool call (input known up front
|
|
188
|
+
# plus its output) inside its own step.
|
|
189
|
+
def tool_call(tool_name:, input:, output:, tool_call_id: nil)
|
|
190
|
+
id = tool_call_id || self.class.generate_id
|
|
191
|
+
tool_input_available(tool_call_id: id, tool_name: tool_name, input: input)
|
|
192
|
+
tool_output_available(tool_call_id: id, output: output)
|
|
193
|
+
id
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- Low level -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# Emit a raw, pre-shaped part hash. Validates that :type is present, JSON
|
|
199
|
+
# encodes it, and writes one SSE event. Useful for protocol part types added
|
|
200
|
+
# after this gem's release.
|
|
201
|
+
def emit(part)
|
|
202
|
+
raise ClosedError, "stream already terminated with [DONE]" if @closed
|
|
203
|
+
|
|
204
|
+
hash = part.is_a?(Hash) ? part : part.to_h
|
|
205
|
+
raise ArgumentError, "part must include a :type" unless hash[:type] || hash["type"]
|
|
206
|
+
|
|
207
|
+
write_frame(JSON.generate(hash))
|
|
208
|
+
self
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Write the SSE terminator. After this, further emits raise ClosedError.
|
|
212
|
+
def done
|
|
213
|
+
return self if @closed
|
|
214
|
+
|
|
215
|
+
write_frame("[DONE]")
|
|
216
|
+
@closed = true
|
|
217
|
+
self
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def closed?
|
|
221
|
+
@closed
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
def write_frame(payload)
|
|
227
|
+
@sink << "data: #{payload}\n\n"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/ai_stream.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ai_stream/version"
|
|
4
|
+
require_relative "ai_stream/writer"
|
|
5
|
+
require_relative "ai_stream/stream"
|
|
6
|
+
|
|
7
|
+
# AiStream is a pure-Ruby implementation of the Vercel AI SDK
|
|
8
|
+
# "Data Stream Protocol" (UI Message Stream Protocol) — the Server-Sent-Events
|
|
9
|
+
# wire format that drives the AI SDK's `useChat` / `useCompletion` / `useObject`
|
|
10
|
+
# frontend hooks.
|
|
11
|
+
#
|
|
12
|
+
# The protocol is language-agnostic by design (Vercel documents non-JS backends
|
|
13
|
+
# such as Python/FastAPI speaking it), but Ruby had no implementation. This gem
|
|
14
|
+
# lets a Rails/Rack backend stream text, reasoning, tool calls, sources, files,
|
|
15
|
+
# and custom data parts to a Vercel-AI-SDK frontend with the exact frames it
|
|
16
|
+
# expects.
|
|
17
|
+
#
|
|
18
|
+
# AiStream is provider-agnostic: it sits downstream of whatever produced the
|
|
19
|
+
# tokens (ruby_llm, ruby-openai, a raw HTTP stream, or canned text), so it
|
|
20
|
+
# composes with the existing Ruby AI stack rather than competing with it.
|
|
21
|
+
#
|
|
22
|
+
# See AiStream::Writer for the encoder and AiStream::Stream for the lazy,
|
|
23
|
+
# Rack-compatible response body.
|
|
24
|
+
module AiStream
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
# Raised when emitting onto a stream that already wrote the [DONE] sentinel.
|
|
28
|
+
class ClosedError < Error; end
|
|
29
|
+
|
|
30
|
+
# The HTTP response header the Vercel AI SDK requires to treat a response as a
|
|
31
|
+
# UI message stream. Merge this into your Rack/Rails headers.
|
|
32
|
+
HEADERS = {
|
|
33
|
+
"x-vercel-ai-ui-message-stream" => "v1"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Protocol version implemented by this gem (the `v1` of the header above).
|
|
37
|
+
PROTOCOL_VERSION = "v1"
|
|
38
|
+
|
|
39
|
+
# The SSE terminator the frontend watches for to know the stream is complete.
|
|
40
|
+
DONE_SENTINEL = "[DONE]"
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ai_stream
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Levelbrook Team
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: |-
|
|
42
|
+
A pure-Ruby, zero-dependency implementation of the Vercel AI SDK "Data Stream Protocol"
|
|
43
|
+
(UI Message Stream Protocol) — the Server-Sent-Events wire format that drives the AI SDK's
|
|
44
|
+
useChat / useCompletion / useObject frontend hooks. The protocol is language-agnostic by
|
|
45
|
+
design, but Ruby had no implementation; ai_stream lets a Rails/Rack backend stream text,
|
|
46
|
+
reasoning, tool calls, sources, files, and custom data parts to a Vercel-AI-SDK frontend
|
|
47
|
+
with the exact frames it expects. Provider-agnostic: it composes with ruby_llm, ruby-openai,
|
|
48
|
+
or any token source instead of competing with them.
|
|
49
|
+
email:
|
|
50
|
+
- levelbrookteam@gmail.com
|
|
51
|
+
executables: []
|
|
52
|
+
extensions: []
|
|
53
|
+
extra_rdoc_files: []
|
|
54
|
+
files:
|
|
55
|
+
- CHANGELOG.md
|
|
56
|
+
- LICENSE
|
|
57
|
+
- README.md
|
|
58
|
+
- lib/ai_stream.rb
|
|
59
|
+
- lib/ai_stream/stream.rb
|
|
60
|
+
- lib/ai_stream/version.rb
|
|
61
|
+
- lib/ai_stream/writer.rb
|
|
62
|
+
homepage: https://consulting.levelbrook.com
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://consulting.levelbrook.com
|
|
67
|
+
source_code_uri: https://github.com/tachyurgy/ai_stream
|
|
68
|
+
changelog_uri: https://github.com/tachyurgy/ai_stream/blob/main/CHANGELOG.md
|
|
69
|
+
rubygems_mfa_required: 'true'
|
|
70
|
+
post_install_message:
|
|
71
|
+
rdoc_options: []
|
|
72
|
+
require_paths:
|
|
73
|
+
- lib
|
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - ">="
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: 3.0.0
|
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: '0'
|
|
84
|
+
requirements: []
|
|
85
|
+
rubygems_version: 3.5.22
|
|
86
|
+
signing_key:
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Ruby encoder for the Vercel AI SDK Data Stream (UI Message Stream) Protocol.
|
|
89
|
+
test_files: []
|