rubycanusellm 0.5.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +42 -1
- data/lib/rubycanusellm/providers/anthropic.rb +42 -3
- data/lib/rubycanusellm/providers/mistral.rb +38 -2
- data/lib/rubycanusellm/providers/ollama.rb +36 -2
- data/lib/rubycanusellm/providers/openai.rb +38 -2
- data/lib/rubycanusellm/response.rb +7 -2
- data/lib/rubycanusellm/tool_call.rb +13 -0
- data/lib/rubycanusellm/version.rb +1 -1
- data/lib/rubycanusellm.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f4513f49c758f5522912c97932d445ed1e8b5f8a24b2d071c00d1ae16f8358d
|
|
4
|
+
data.tar.gz: ad06df07d319d315bcc7d6348a011bdc6314ba83d2725fa53e807655422f877b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9722c932b922b4c45ca2c55039e3fd99bb2e0137093a57196f1be6af2b60a5a6a9fcbd800c18ae4fedb494385dcc23b099bc838aaf1d0f91b554ce5fd88e0638
|
|
7
|
+
data.tar.gz: b91410cb53eecb40f987ce2ec4d2df9f0605dba7c69a86fd88835bc5c8710ee7f69104dd77d2846bc006c0054355774a692d6293e4e73a1b798d7c4ab82893bc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-04-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Tool calling support for OpenAI, Anthropic, Mistral, and Ollama providers
|
|
8
|
+
- `RubyCanUseLLM::ToolCall` object with `id`, `name`, and `arguments` (parsed Hash)
|
|
9
|
+
- `Response#tool_calls` — array of `ToolCall` objects when the model requests a tool, `nil` otherwise
|
|
10
|
+
- `Response#tool_call?` — convenience predicate
|
|
11
|
+
- Unified tool definition format: `[{ name:, description:, parameters: }]` — providers handle format translation internally
|
|
12
|
+
- Anthropic's `tool_use`/`tool_result` format handled transparently
|
|
13
|
+
- Multi-turn tool use: send `role: :tool` messages with `tool_call_id:` and `content:` to continue the conversation
|
|
14
|
+
|
|
3
15
|
## [0.5.0] - 2026-04-03
|
|
4
16
|
|
|
5
17
|
### Added
|
data/README.md
CHANGED
|
@@ -197,6 +197,47 @@ rescue RubyCanUseLLM::ProviderError => e
|
|
|
197
197
|
end
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
+
### Tool Calling
|
|
201
|
+
|
|
202
|
+
Define tools once, use them with any provider:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
tools = [
|
|
206
|
+
{
|
|
207
|
+
name: "get_weather",
|
|
208
|
+
description: "Get current weather for a city",
|
|
209
|
+
parameters: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
location: { type: "string", description: "City name" }
|
|
213
|
+
},
|
|
214
|
+
required: ["location"]
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
messages = [{ role: :user, content: "What's the weather in Paris?" }]
|
|
220
|
+
response = RubyCanUseLLM.chat(messages, tools: tools)
|
|
221
|
+
|
|
222
|
+
if response.tool_call?
|
|
223
|
+
tc = response.tool_calls.first
|
|
224
|
+
# tc.id => "call_abc123"
|
|
225
|
+
# tc.name => "get_weather"
|
|
226
|
+
# tc.arguments => { "location" => "Paris" }
|
|
227
|
+
|
|
228
|
+
# Execute the tool and continue the conversation
|
|
229
|
+
weather = fetch_weather(tc.arguments["location"])
|
|
230
|
+
|
|
231
|
+
messages << { role: :assistant, content: response.content, tool_calls: response.tool_calls }
|
|
232
|
+
messages << { role: :tool, tool_call_id: tc.id, name: tc.name, content: weather }
|
|
233
|
+
|
|
234
|
+
final_response = RubyCanUseLLM.chat(messages, tools: tools)
|
|
235
|
+
puts final_response.content
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Works the same across providers** — OpenAI, Anthropic, Mistral, and Ollama. Format differences (Anthropic uses `input_schema` and `tool_result` messages) are handled internally.
|
|
240
|
+
|
|
200
241
|
### Prompt Templates
|
|
201
242
|
|
|
202
243
|
Keep prompts out of your Ruby code. Define them in YAML files with ERB for dynamic content:
|
|
@@ -261,7 +302,7 @@ ERB is supported in both cases — loops, conditionals, any Ruby expression.
|
|
|
261
302
|
- [x] Ollama provider (chat + embeddings, local)
|
|
262
303
|
- [x] `generate:embedding` command
|
|
263
304
|
- [x] Prompt templates (ERB + YAML file-based)
|
|
264
|
-
- [
|
|
305
|
+
- [x] Tool calling
|
|
265
306
|
|
|
266
307
|
## Development
|
|
267
308
|
```bash
|
|
@@ -45,12 +45,38 @@ module RubyCanUseLLM
|
|
|
45
45
|
max_tokens: options[:max_tokens] || 1024
|
|
46
46
|
}
|
|
47
47
|
body[:system] = system if system
|
|
48
|
+
body[:tools] = format_tools(options[:tools]) if options[:tools]
|
|
48
49
|
body
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def format_messages(messages)
|
|
52
53
|
messages.map do |msg|
|
|
53
|
-
|
|
54
|
+
case msg[:role].to_s
|
|
55
|
+
when "tool"
|
|
56
|
+
{
|
|
57
|
+
role: "user",
|
|
58
|
+
content: [{ type: "tool_result", tool_use_id: msg[:tool_call_id], content: msg[:content].to_s }]
|
|
59
|
+
}
|
|
60
|
+
when "assistant"
|
|
61
|
+
content = []
|
|
62
|
+
content << { type: "text", text: msg[:content] } if msg[:content]
|
|
63
|
+
if msg[:tool_calls]
|
|
64
|
+
msg[:tool_calls].each do |tc|
|
|
65
|
+
content << { type: "tool_use", id: tc.id, name: tc.name, input: tc.arguments }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
content.size == 1 && content.first[:type] == "text" ?
|
|
69
|
+
{ role: "assistant", content: content.first[:text] } :
|
|
70
|
+
{ role: "assistant", content: content }
|
|
71
|
+
else
|
|
72
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def format_tools(tools)
|
|
78
|
+
tools.map do |t|
|
|
79
|
+
{ name: t[:name], description: t[:description], input_schema: t[:parameters] }
|
|
54
80
|
end
|
|
55
81
|
end
|
|
56
82
|
|
|
@@ -130,14 +156,27 @@ module RubyCanUseLLM
|
|
|
130
156
|
end
|
|
131
157
|
|
|
132
158
|
def parse_response(data)
|
|
133
|
-
|
|
159
|
+
text_content = nil
|
|
160
|
+
tool_calls = nil
|
|
161
|
+
|
|
162
|
+
data["content"].each do |block|
|
|
163
|
+
case block["type"]
|
|
164
|
+
when "text"
|
|
165
|
+
text_content = block["text"]
|
|
166
|
+
when "tool_use"
|
|
167
|
+
tool_calls ||= []
|
|
168
|
+
tool_calls << ToolCall.new(id: block["id"], name: block["name"], arguments: block["input"])
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
134
172
|
usage = data["usage"]
|
|
135
173
|
|
|
136
174
|
Response.new(
|
|
137
|
-
content:
|
|
175
|
+
content: text_content,
|
|
138
176
|
model: data["model"],
|
|
139
177
|
input_tokens: usage["input_tokens"],
|
|
140
178
|
output_tokens: usage["output_tokens"],
|
|
179
|
+
tool_calls: tool_calls,
|
|
141
180
|
raw: data
|
|
142
181
|
)
|
|
143
182
|
end
|
|
@@ -33,16 +33,40 @@ module RubyCanUseLLM
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def build_body(messages, options)
|
|
36
|
-
{
|
|
36
|
+
body = {
|
|
37
37
|
model: options[:model] || config.model || "mistral-small-latest",
|
|
38
38
|
messages: format_messages(messages),
|
|
39
39
|
temperature: options[:temperature] || 0.7
|
|
40
40
|
}
|
|
41
|
+
if options[:tools]
|
|
42
|
+
body[:tools] = format_tools(options[:tools])
|
|
43
|
+
body[:tool_choice] = options[:tool_choice] || "auto"
|
|
44
|
+
end
|
|
45
|
+
body
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
def format_messages(messages)
|
|
44
49
|
messages.map do |msg|
|
|
45
|
-
|
|
50
|
+
case msg[:role].to_s
|
|
51
|
+
when "tool"
|
|
52
|
+
{ role: "tool", tool_call_id: msg[:tool_call_id], content: msg[:content].to_s }
|
|
53
|
+
when "assistant"
|
|
54
|
+
m = { role: "assistant", content: msg[:content] }
|
|
55
|
+
if msg[:tool_calls]
|
|
56
|
+
m[:tool_calls] = msg[:tool_calls].map do |tc|
|
|
57
|
+
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments.to_json } }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
m
|
|
61
|
+
else
|
|
62
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_tools(tools)
|
|
68
|
+
tools.map do |t|
|
|
69
|
+
{ type: "function", function: { name: t[:name], description: t[:description], parameters: t[:parameters] } }
|
|
46
70
|
end
|
|
47
71
|
end
|
|
48
72
|
|
|
@@ -120,11 +144,23 @@ module RubyCanUseLLM
|
|
|
120
144
|
choice = data.dig("choices", 0, "message")
|
|
121
145
|
usage = data["usage"]
|
|
122
146
|
|
|
147
|
+
tool_calls = nil
|
|
148
|
+
if choice["tool_calls"]
|
|
149
|
+
tool_calls = choice["tool_calls"].map do |tc|
|
|
150
|
+
ToolCall.new(
|
|
151
|
+
id: tc["id"],
|
|
152
|
+
name: tc.dig("function", "name"),
|
|
153
|
+
arguments: JSON.parse(tc.dig("function", "arguments"))
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
123
158
|
Response.new(
|
|
124
159
|
content: choice["content"],
|
|
125
160
|
model: data["model"],
|
|
126
161
|
input_tokens: usage["prompt_tokens"],
|
|
127
162
|
output_tokens: usage["completion_tokens"],
|
|
163
|
+
tool_calls: tool_calls,
|
|
128
164
|
raw: data
|
|
129
165
|
)
|
|
130
166
|
end
|
|
@@ -36,16 +36,37 @@ module RubyCanUseLLM
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def build_body(messages, options)
|
|
39
|
-
{
|
|
39
|
+
body = {
|
|
40
40
|
model: options[:model] || config.model || "llama3.2",
|
|
41
41
|
messages: format_messages(messages),
|
|
42
42
|
temperature: options[:temperature] || 0.7
|
|
43
43
|
}
|
|
44
|
+
body[:tools] = format_tools(options[:tools]) if options[:tools]
|
|
45
|
+
body
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def format_messages(messages)
|
|
47
49
|
messages.map do |msg|
|
|
48
|
-
|
|
50
|
+
case msg[:role].to_s
|
|
51
|
+
when "tool"
|
|
52
|
+
{ role: "tool", content: msg[:content].to_s }
|
|
53
|
+
when "assistant"
|
|
54
|
+
m = { role: "assistant", content: msg[:content] || "" }
|
|
55
|
+
if msg[:tool_calls]
|
|
56
|
+
m[:tool_calls] = msg[:tool_calls].map do |tc|
|
|
57
|
+
{ function: { name: tc.name, arguments: tc.arguments } }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
m
|
|
61
|
+
else
|
|
62
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def format_tools(tools)
|
|
68
|
+
tools.map do |t|
|
|
69
|
+
{ type: "function", function: { name: t[:name], description: t[:description], parameters: t[:parameters] } }
|
|
49
70
|
end
|
|
50
71
|
end
|
|
51
72
|
|
|
@@ -104,11 +125,24 @@ module RubyCanUseLLM
|
|
|
104
125
|
|
|
105
126
|
def parse_response(data)
|
|
106
127
|
message = data["message"]
|
|
128
|
+
|
|
129
|
+
tool_calls = nil
|
|
130
|
+
if message["tool_calls"]
|
|
131
|
+
tool_calls = message["tool_calls"].each_with_index.map do |tc, i|
|
|
132
|
+
ToolCall.new(
|
|
133
|
+
id: "call_#{i}",
|
|
134
|
+
name: tc.dig("function", "name"),
|
|
135
|
+
arguments: tc.dig("function", "arguments") || {}
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
107
140
|
Response.new(
|
|
108
141
|
content: message["content"],
|
|
109
142
|
model: data["model"],
|
|
110
143
|
input_tokens: data["prompt_eval_count"] || 0,
|
|
111
144
|
output_tokens: data["eval_count"] || 0,
|
|
145
|
+
tool_calls: tool_calls,
|
|
112
146
|
raw: data
|
|
113
147
|
)
|
|
114
148
|
end
|
|
@@ -32,16 +32,40 @@ module RubyCanUseLLM
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
34
|
def build_body(messages, options)
|
|
35
|
-
{
|
|
35
|
+
body = {
|
|
36
36
|
model: options[:model] || config.model || "gpt-4o-mini",
|
|
37
37
|
messages: format_messages(messages),
|
|
38
38
|
temperature: options[:temperature] || 0.7
|
|
39
39
|
}
|
|
40
|
+
if options[:tools]
|
|
41
|
+
body[:tools] = format_tools(options[:tools])
|
|
42
|
+
body[:tool_choice] = options[:tool_choice] || "auto"
|
|
43
|
+
end
|
|
44
|
+
body
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
def format_messages(messages)
|
|
43
48
|
messages.map do |msg|
|
|
44
|
-
|
|
49
|
+
case msg[:role].to_s
|
|
50
|
+
when "tool"
|
|
51
|
+
{ role: "tool", tool_call_id: msg[:tool_call_id], content: msg[:content].to_s }
|
|
52
|
+
when "assistant"
|
|
53
|
+
m = { role: "assistant", content: msg[:content] }
|
|
54
|
+
if msg[:tool_calls]
|
|
55
|
+
m[:tool_calls] = msg[:tool_calls].map do |tc|
|
|
56
|
+
{ id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments.to_json } }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
m
|
|
60
|
+
else
|
|
61
|
+
{ role: msg[:role].to_s, content: msg[:content] }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def format_tools(tools)
|
|
67
|
+
tools.map do |t|
|
|
68
|
+
{ type: "function", function: { name: t[:name], description: t[:description], parameters: t[:parameters] } }
|
|
45
69
|
end
|
|
46
70
|
end
|
|
47
71
|
|
|
@@ -119,11 +143,23 @@ module RubyCanUseLLM
|
|
|
119
143
|
choice = data.dig("choices", 0, "message")
|
|
120
144
|
usage = data["usage"]
|
|
121
145
|
|
|
146
|
+
tool_calls = nil
|
|
147
|
+
if choice["tool_calls"]
|
|
148
|
+
tool_calls = choice["tool_calls"].map do |tc|
|
|
149
|
+
ToolCall.new(
|
|
150
|
+
id: tc["id"],
|
|
151
|
+
name: tc.dig("function", "name"),
|
|
152
|
+
arguments: JSON.parse(tc.dig("function", "arguments"))
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
122
157
|
Response.new(
|
|
123
158
|
content: choice["content"],
|
|
124
159
|
model: data["model"],
|
|
125
160
|
input_tokens: usage["prompt_tokens"],
|
|
126
161
|
output_tokens: usage["completion_tokens"],
|
|
162
|
+
tool_calls: tool_calls,
|
|
127
163
|
raw: data
|
|
128
164
|
)
|
|
129
165
|
end
|
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module RubyCanUseLLM
|
|
4
4
|
class Response
|
|
5
|
-
attr_reader :content, :model, :input_tokens, :output_tokens, :raw
|
|
5
|
+
attr_reader :content, :model, :input_tokens, :output_tokens, :tool_calls, :raw
|
|
6
6
|
|
|
7
|
-
def initialize(content:, model:, input_tokens:, output_tokens:, raw:)
|
|
7
|
+
def initialize(content:, model:, input_tokens:, output_tokens:, raw:, tool_calls: nil)
|
|
8
8
|
@content = content
|
|
9
9
|
@model = model
|
|
10
10
|
@input_tokens = input_tokens
|
|
11
11
|
@output_tokens = output_tokens
|
|
12
|
+
@tool_calls = tool_calls
|
|
12
13
|
@raw = raw
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
def tool_call?
|
|
17
|
+
!tool_calls.nil? && !tool_calls.empty?
|
|
18
|
+
end
|
|
19
|
+
|
|
15
20
|
def total_tokens
|
|
16
21
|
input_tokens + output_tokens
|
|
17
22
|
end
|
data/lib/rubycanusellm.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "rubycanusellm/providers/voyage"
|
|
|
12
12
|
require_relative "rubycanusellm/providers/mistral"
|
|
13
13
|
require_relative "rubycanusellm/providers/ollama"
|
|
14
14
|
require_relative "rubycanusellm/embedding_response"
|
|
15
|
+
require_relative "rubycanusellm/tool_call"
|
|
15
16
|
require_relative "rubycanusellm/prompt"
|
|
16
17
|
|
|
17
18
|
module RubyCanUseLLM
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubycanusellm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Juan Manuel Guzman Nava
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: One interface, every LLM. Rubycanusellm provides a unified client for
|
|
14
14
|
OpenAI, Anthropic, and more, plus generators that scaffold the boilerplate so you
|
|
@@ -44,6 +44,7 @@ files:
|
|
|
44
44
|
- lib/rubycanusellm/templates/completion.rb.tt
|
|
45
45
|
- lib/rubycanusellm/templates/config.rb.tt
|
|
46
46
|
- lib/rubycanusellm/templates/embedding.rb.tt
|
|
47
|
+
- lib/rubycanusellm/tool_call.rb
|
|
47
48
|
- lib/rubycanusellm/version.rb
|
|
48
49
|
- sig/rubycanusellm.rbs
|
|
49
50
|
homepage: https://github.com/mgznv/rubycanusellm
|