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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/lib/ruby_llm/ai_sdk/controller.rb +34 -0
- data/lib/ruby_llm/ai_sdk/request.rb +77 -0
- data/lib/ruby_llm/ai_sdk/stream.rb +177 -0
- data/lib/ruby_llm/ai_sdk/version.rb +8 -0
- data/lib/ruby_llm/ai_sdk.rb +29 -0
- metadata +68 -0
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,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: []
|