ruby_llm-ai_sdk 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: 83d6d8d1f54099cf62f2f8514780f8df54b185e7ad9e0fe7dbd5bb6e57be4a45
4
+ data.tar.gz: 49069ade3a6b4ff8fe19a417680651740331df524b1126ebe7fec854cd9882ed
5
+ SHA512:
6
+ metadata.gz: 53f1644c79f531dd9d89fc655e900b6a27b50c0d55a41b7dec1d8d81b12991a2cca82d9bec777bd1cc685071f321e904f73f7efa55c30abd7379ff025ab910df
7
+ data.tar.gz: 21219200c9740960c39bab1d1b8ae88c663009a0b6b2de0e8977e23cc45f6229470144e2ca336d27529388b70159a0ac7cc68c6b98010ac4cc199500ca6f79f3
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - `RubyLLM::AiSdk::Stream` writes a chat turn as AI SDK UI message stream events: text, reasoning, tool calls, tool results, and approval requests.
6
+ - `RubyLLM::AiSdk::Request` reads the last user message and approval decisions from the body `useChat` posts.
7
+ - `RubyLLM::AiSdk::Controller` streams a turn from a Rails action with `stream_chat`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Costanzo
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,103 @@
1
+ # ruby_llm-ai_sdk
2
+
3
+ Stream [RubyLLM](https://rubyllm.com) chats to the [AI SDK](https://ai-sdk.dev) `useChat` hook.
4
+
5
+ The AI SDK's React hooks speak the [UI message stream protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol): Server-Sent Events that carry text deltas, reasoning, tool calls, tool results and approval requests. This gem emits that protocol from a RubyLLM chat, so a React or Next.js front end talks to a Ruby backend the same way it talks to a Node one, tool calls and human approvals included.
6
+
7
+ ```ruby
8
+ RubyLLM::AiSdk::Stream.new(chat, response.stream).ask "Refund order 42"
9
+ ```
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ gem "ruby_llm-ai_sdk"
15
+ ```
16
+
17
+ Requires RubyLLM 1.16 or later. Approval requests need RubyLLM's `requires_approval`, unreleased at the time of writing, so point the `ruby_llm` gem at its `main` branch to use them.
18
+
19
+ ## Usage in Rails
20
+
21
+ Include the controller module and hand `stream_chat` a chat. It sets the headers the AI SDK expects, streams the turn over `ActionController::Live`, and closes the stream when the turn ends.
22
+
23
+ ```ruby
24
+ class MessagesController < ApplicationController
25
+ include RubyLLM::AiSdk::Controller
26
+
27
+ def create
28
+ stream_chat SupportAgent.find(params[:chat_id])
29
+ end
30
+ end
31
+ ```
32
+
33
+ On the front end, point `useChat` at that action. Nothing else changes.
34
+
35
+ ```tsx
36
+ const { messages, sendMessage, addToolApprovalResponse } = useChat({
37
+ transport: new DefaultChatTransport({ api: `/chats/${chatId}/messages` }),
38
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
39
+ });
40
+ ```
41
+
42
+ `stream_chat` reads the body `useChat` posts. When the last message is from the user, it asks it. When the last assistant message carries approval decisions, it records them with `chat.approve` or `chat.deny` and continues the parked turn.
43
+
44
+ ## Usage anywhere else
45
+
46
+ `Stream` writes to anything that responds to `write`, so it works with Rack hijacking, Sinatra streaming, or a file.
47
+
48
+ ```ruby
49
+ stream = RubyLLM::AiSdk::Stream.new(chat, io)
50
+ stream.ask "What is the weather in Paris?" # one turn, streamed
51
+ stream.serve request_body # a full useChat body, as JSON or a Hash
52
+ ```
53
+
54
+ `RubyLLM::AiSdk::HEADERS` holds the response headers to set.
55
+
56
+ ## What the stream contains
57
+
58
+ | RubyLLM event | AI SDK chunks |
59
+ | --- | --- |
60
+ | Turn begins | `start` |
61
+ | First chunk of a model response | `start-step` |
62
+ | Text chunks | `text-start`, `text-delta`, `text-end` |
63
+ | Reasoning chunks | `reasoning-start`, `reasoning-delta`, `reasoning-end` |
64
+ | Assistant message with tool calls | `tool-input-available` per call, then `finish-step` |
65
+ | Tool result message | `tool-output-available`, or `tool-output-denied` after a denial |
66
+ | Turn parked on `requires_approval` | `tool-approval-request` per pending call |
67
+ | Turn ends | `finish`, then `[DONE]` |
68
+ | Exception | `error`, then `[DONE]`, and the exception is re-raised |
69
+
70
+ Tool outputs that are valid JSON arrive parsed, so `part.output` on the client is the object your tool returned.
71
+
72
+ ## Human in the loop
73
+
74
+ Declare a tool with `requires_approval` and the turn stops before it runs. The client receives `tool-approval-request`, renders it with the tool's input, and the person decides:
75
+
76
+ ```tsx
77
+ {part.state === "approval-requested" && (
78
+ <button onClick={() => addToolApprovalResponse({ id: part.approval.id, approved: true })}>
79
+ Approve
80
+ </button>
81
+ )}
82
+ ```
83
+
84
+ `useChat` posts the decision back, the gem records it, and the same stream format carries the tool's output and the model's reply. With `acts_as_chat`, the decision persists on the tool call record, so the parked turn can be resumed from another process.
85
+
86
+ ## Design notes
87
+
88
+ - **A Stream is one turn.** It registers a callback on the chat it wraps and only writes while its own turn runs, so a chat can be streamed turn after turn, with one Stream per request.
89
+ - **Tool results come from the transcript, not the callback.** RubyLLM's `after_tool_result` does not carry the tool call id, so results are read from the tool messages appended to the chat. That also keeps concurrent tool execution correct.
90
+ - **Steps follow model responses.** Each assistant message closes a step; a text or reasoning chunk that arrives with no step open opens one.
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ bundle install
96
+ bundle exec rake # specs and RuboCop
97
+ ```
98
+
99
+ The specs stub the provider and drive real `RubyLLM::Chat` objects through every path, including approvals. No API keys are needed.
100
+
101
+ ## License
102
+
103
+ MIT.
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'action_controller'
5
+
6
+ module RubyLLM
7
+ module AiSdk
8
+ # Streams a chat turn from a Rails controller action in the format
9
+ # +useChat+ expects. Including it also includes ActionController::Live.
10
+ #
11
+ # class MessagesController < ApplicationController
12
+ # include RubyLLM::AiSdk::Controller
13
+ #
14
+ # def create
15
+ # stream_chat SupportAgent.find(params[:chat_id])
16
+ # end
17
+ # end
18
+ #
19
+ module Controller
20
+ extend ActiveSupport::Concern
21
+
22
+ included { include ActionController::Live }
23
+
24
+ # Streams one turn of +chat+ driven by +body+, the JSON +useChat+
25
+ # posted, and closes the response stream when the turn ends.
26
+ def stream_chat(chat, body = request.raw_post)
27
+ HEADERS.each { |name, value| response.headers[name] = value }
28
+ Stream.new(chat, response.stream).serve(body)
29
+ ensure
30
+ response.stream.close
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AiSdk
5
+ # The body +useChat+ posts on every turn: the chat id and the full list
6
+ # of UI messages. A Request reads the two things the server acts on,
7
+ # the last user message and the approval decisions on the last
8
+ # assistant message.
9
+ #
10
+ # request = RubyLLM::AiSdk::Request.parse(request.raw_post)
11
+ # request.text # => "Refund order 42"
12
+ # request.approvals # => []
13
+ #
14
+ class Request
15
+ # One decision on a tool call that required approval.
16
+ Approval = Struct.new(:tool_call_id, :approved, keyword_init: true) do
17
+ # Returns whether the person approved the tool call.
18
+ def approved?
19
+ approved == true
20
+ end
21
+ end
22
+
23
+ # The chat id +useChat+ was created with.
24
+ attr_reader :id
25
+
26
+ # The UI messages as posted, an array of Hashes.
27
+ attr_reader :messages
28
+
29
+ # Returns a Request parsed from a JSON +body+.
30
+ def self.parse(body)
31
+ new(JSON.parse(body))
32
+ end
33
+
34
+ def initialize(body)
35
+ @id = body['id']
36
+ @messages = body.fetch('messages', [])
37
+ end
38
+
39
+ # Returns the id of the last message, or +nil+ when there is none.
40
+ def message_id
41
+ last_message&.fetch('id', nil)
42
+ end
43
+
44
+ # Returns the text of the last message when it comes from the user,
45
+ # with its text parts joined by newlines, or +nil+ otherwise.
46
+ def text
47
+ return unless last_message&.fetch('role', nil) == 'user'
48
+
49
+ parts_of_type('text').map { |part| part['text'] }.join("\n")
50
+ end
51
+
52
+ # Returns the Approval decisions recorded on the last assistant
53
+ # message, an empty array when there are none.
54
+ def approvals
55
+ return [] unless last_message&.fetch('role', nil) == 'assistant'
56
+
57
+ parts_where('state' => 'approval-responded').map do |part|
58
+ Approval.new(tool_call_id: part['toolCallId'], approved: part.dig('approval', 'approved'))
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def last_message
65
+ messages.last
66
+ end
67
+
68
+ def parts_of_type(type)
69
+ parts_where('type' => type)
70
+ end
71
+
72
+ def parts_where(attributes)
73
+ last_message.fetch('parts', []).select { |part| attributes.all? { |key, value| part[key] == value } }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AiSdk
5
+ # Writes one assistant turn of a Chat to an IO as AI SDK UI message
6
+ # stream events. Text and reasoning stream as they arrive, tool calls
7
+ # and their results follow each step, and tool calls parked on an
8
+ # approval end the turn with an approval request.
9
+ #
10
+ # stream = RubyLLM::AiSdk::Stream.new(chat, response.stream)
11
+ # stream.ask "Refund order 42"
12
+ #
13
+ # A Stream only writes during its own turn, so several streams can be
14
+ # built on the same chat over its lifetime, one per request.
15
+ class Stream
16
+ # The Chat this stream writes.
17
+ attr_reader :chat
18
+
19
+ # The IO the events go to. Anything that responds to +write+.
20
+ attr_reader :io
21
+
22
+ def initialize(chat, io)
23
+ @chat = chat
24
+ @io = io
25
+ @denied = []
26
+ @step = false
27
+ @active = false
28
+ chat.after_message { |message| append(message) if @active }
29
+ end
30
+
31
+ # Handles one +useChat+ request end to end. Records the approval
32
+ # decisions when the request carries them and continues the parked
33
+ # turn, otherwise asks the last user message. Returns the final
34
+ # Message.
35
+ def serve(request)
36
+ request = Request.parse(request) if request.is_a?(String)
37
+ request = Request.new(request) if request.is_a?(Hash)
38
+ return ask(request.text) if request.approvals.empty?
39
+
40
+ request.approvals.each { |approval| decide(approval) }
41
+ complete(message_id: request.message_id)
42
+ end
43
+
44
+ # Asks +text+ and streams the response. Accepts the same keyword
45
+ # arguments as Chat#ask. Returns the final Message.
46
+ def ask(text, **options)
47
+ turn { chat.ask(text, **options, &method(:receive)) }
48
+ end
49
+
50
+ # Continues a turn the chat already staged, such as one parked on an
51
+ # approval, and streams the response. Pass the id of the assistant
52
+ # message the client is showing as +message_id+ so it continues that
53
+ # message instead of starting a new one. Returns the final Message.
54
+ def complete(message_id: nil)
55
+ turn(message_id) { chat.complete(&method(:receive)) }
56
+ end
57
+
58
+ private
59
+
60
+ def turn(message_id = nil)
61
+ @active = true
62
+ emit type: 'start', messageId: message_id || "msg_#{SecureRandom.hex(12)}"
63
+ response = yield
64
+ request_approvals
65
+ emit type: 'finish'
66
+ response
67
+ rescue StandardError => e
68
+ emit type: 'error', errorText: e.message
69
+ raise
70
+ ensure
71
+ io.write "data: [DONE]\n\n"
72
+ @active = false
73
+ end
74
+
75
+ def append(message)
76
+ message.tool_result? ? tool_output(message) : finish_step(message)
77
+ end
78
+
79
+ def receive(chunk)
80
+ start_step unless @step
81
+ reasoning(thinking_text(chunk)) if thinking_text(chunk)
82
+ text(chunk.content.to_s) unless chunk.content.to_s.empty?
83
+ end
84
+
85
+ def start_step
86
+ @step = true
87
+ emit type: 'start-step'
88
+ end
89
+
90
+ def finish_step(message)
91
+ start_step unless @step
92
+ close_text
93
+ close_reasoning
94
+ message.tool_calls&.each_value do |tool_call|
95
+ emit type: 'tool-input-available', toolCallId: tool_call.id, toolName: tool_call.name,
96
+ input: tool_call.arguments
97
+ end
98
+ emit type: 'finish-step'
99
+ @step = false
100
+ end
101
+
102
+ def text(delta)
103
+ close_reasoning
104
+ @text_id ||= open_part('text')
105
+ emit type: 'text-delta', id: @text_id, delta: delta
106
+ end
107
+
108
+ def reasoning(delta)
109
+ close_text
110
+ @reasoning_id ||= open_part('reasoning')
111
+ emit type: 'reasoning-delta', id: @reasoning_id, delta: delta
112
+ end
113
+
114
+ def open_part(kind)
115
+ id = "#{kind}_#{SecureRandom.hex(8)}"
116
+ emit type: "#{kind}-start", id: id
117
+ id
118
+ end
119
+
120
+ def close_text
121
+ emit type: 'text-end', id: @text_id if @text_id
122
+ @text_id = nil
123
+ end
124
+
125
+ def close_reasoning
126
+ emit type: 'reasoning-end', id: @reasoning_id if @reasoning_id
127
+ @reasoning_id = nil
128
+ end
129
+
130
+ def tool_output(message)
131
+ if @denied.include?(message.tool_call_id)
132
+ emit type: 'tool-output-denied', toolCallId: message.tool_call_id
133
+ else
134
+ emit type: 'tool-output-available', toolCallId: message.tool_call_id, output: output_of(message)
135
+ end
136
+ end
137
+
138
+ def output_of(message)
139
+ JSON.parse(message.content.to_s)
140
+ rescue JSON::ParserError
141
+ message.content.to_s
142
+ end
143
+
144
+ def request_approvals
145
+ return unless chat.respond_to?(:pending_approvals)
146
+
147
+ chat.pending_approvals.each do |tool_call|
148
+ id = tool_call_id(tool_call)
149
+ emit type: 'tool-approval-request', toolCallId: id, approvalId: id
150
+ end
151
+ end
152
+
153
+ def decide(approval)
154
+ if approval.approved?
155
+ chat.approve(approval.tool_call_id)
156
+ else
157
+ chat.deny(approval.tool_call_id)
158
+ @denied << approval.tool_call_id
159
+ end
160
+ end
161
+
162
+ def tool_call_id(tool_call)
163
+ tool_call.respond_to?(:tool_call_id) ? tool_call.tool_call_id : tool_call.id
164
+ end
165
+
166
+ def thinking_text(chunk)
167
+ thinking = chunk.thinking
168
+ text = thinking.respond_to?(:text) ? thinking.text : thinking
169
+ text.to_s.empty? ? nil : text.to_s
170
+ end
171
+
172
+ def emit(event)
173
+ io.write "data: #{JSON.generate(event)}\n\n"
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module AiSdk
5
+ # The version of the ruby_llm-ai_sdk gem, as a string.
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'ruby_llm'
6
+ require_relative 'ai_sdk/version'
7
+ require_relative 'ai_sdk/request'
8
+ require_relative 'ai_sdk/stream'
9
+
10
+ module RubyLLM
11
+ # Streams RubyLLM chats in the AI SDK UI message stream protocol, so a
12
+ # front end built on the AI SDK's +useChat+ hook can talk to a Ruby
13
+ # backend: text, reasoning, tool calls, tool results, and approvals.
14
+ #
15
+ # RubyLLM::AiSdk::Stream.new(chat, io).ask "What is the weather in Paris?"
16
+ #
17
+ module AiSdk
18
+ autoload :Controller, 'ruby_llm/ai_sdk/controller'
19
+
20
+ # The response headers the AI SDK expects on a UI message stream.
21
+ HEADERS = {
22
+ 'content-type' => 'text/event-stream',
23
+ 'cache-control' => 'no-cache',
24
+ 'connection' => 'keep-alive',
25
+ 'x-vercel-ai-ui-message-stream' => 'v1',
26
+ 'x-accel-buffering' => 'no'
27
+ }.freeze
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-ai_sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bruno Costanzo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.16'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.16'
26
+ description: 'Emits the AI SDK UI message stream protocol from RubyLLM chats, so React
27
+ front ends built with useChat talk to a Ruby backend: text, reasoning, tool calls
28
+ and approvals.'
29
+ email:
30
+ - dev.bcostanzo@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - lib/ruby_llm/ai_sdk.rb
39
+ - lib/ruby_llm/ai_sdk/controller.rb
40
+ - lib/ruby_llm/ai_sdk/request.rb
41
+ - lib/ruby_llm/ai_sdk/stream.rb
42
+ - lib/ruby_llm/ai_sdk/version.rb
43
+ homepage: https://github.com/bruno-costanzo/ruby_llm-ai_sdk
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://github.com/bruno-costanzo/ruby_llm-ai_sdk
48
+ source_code_uri: https://github.com/bruno-costanzo/ruby_llm-ai_sdk
49
+ changelog_uri: https://github.com/bruno-costanzo/ruby_llm-ai_sdk/blob/main/CHANGELOG.md
50
+ rubygems_mfa_required: 'true'
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 3.1.3
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 4.0.16
66
+ specification_version: 4
67
+ summary: Stream RubyLLM chats to the AI SDK useChat hook.
68
+ test_files: []