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 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
+ [![CI](https://github.com/tachyurgy/ai_stream/actions/workflows/ci.yml/badge.svg)](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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AiStream
4
+ VERSION = "0.1.0"
5
+ 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: []