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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8366062327382434c0f86c1cb30a0a2265b1d26e7e512b294884aeac93e09468
4
- data.tar.gz: 510d3d9d0051a4cdcb63ecdf7e0d1f4e536e27701a64504cd57fb555868aba1d
3
+ metadata.gz: 6f4513f49c758f5522912c97932d445ed1e8b5f8a24b2d071c00d1ae16f8358d
4
+ data.tar.gz: ad06df07d319d315bcc7d6348a011bdc6314ba83d2725fa53e807655422f877b
5
5
  SHA512:
6
- metadata.gz: 711b8791cc6a9aa49362aaeb1a1da4d03071ec0ab14709e2b3ab20ec418752ce48232cace46103337b58f3daab643efd03a4f08259f994c848bf8c41efb8f3a1
7
- data.tar.gz: 6c3adfcaa2032802fdfbb8f861dea1d89e56d177be3fbf2f761c47457af573c7a7581cd5727ce736b31738f33649e1ea8b247c18bbc8bf1b7364824966aeaeb8
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
- - [ ] Tool calling
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
- { role: msg[:role].to_s, content: msg[:content] }
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
- content = data.dig("content", 0, "text")
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: 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
- { role: msg[:role].to_s, content: msg[:content] }
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
- { role: msg[:role].to_s, content: msg[:content] }
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
- { role: msg[:role].to_s, content: msg[:content] }
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCanUseLLM
4
+ class ToolCall
5
+ attr_reader :id, :name, :arguments
6
+
7
+ def initialize(id:, name:, arguments:)
8
+ @id = id
9
+ @name = name
10
+ @arguments = arguments
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycanusellm
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  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.5.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-04 00:00:00.000000000 Z
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