llm.rb 4.20.0 → 4.20.2

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: d57e4d2af8568cdd6328c0a956fddb40aecbd2943a268b595dbd87ee811553a4
4
- data.tar.gz: 8cf576171c3bfd7328b8316d42aecbb364a7e4d0a6bbff707cdc65cc9ddfbd01
3
+ metadata.gz: a182d595ad65c1cb2f1a796b83e48cba4f1038031ec140709e902734051a8b46
4
+ data.tar.gz: b8cdb2e051bc620f111a97236bd64fe7940ff9f3d5b44c9f07b115641d74abcd
5
5
  SHA512:
6
- metadata.gz: 4d9087909b30c47e5ddb9c9407b53efdbbd2a3732629579dfd53415d60e1457a56b738b9942b578434888b97ee597d78955f21a1af5235847e6daa944810e8d7
7
- data.tar.gz: a890e08d0129ccfa18188efb503e8ac32e4d5424a79851bf2bfa15424b713cae4431afeb78fe7645437d8b3d0c11cc2d975d7415279a2880ca5cd557de57cf5f
6
+ metadata.gz: a6fd61aaa9479ec34af93a1e732acf553a055e36a4f5e822a2c643ef2bf537923a7d0a968b40c6a8cfa9a09af8186ba31467fe627462da49389f1c6594d7ee41
7
+ data.tar.gz: df56a4624eca8f7007ea2054d79812df553df69d867297230c9b38368c87e67c06187dbf03195b5fcaae1b1701b82a79cd7be10ed86364a49802573367910d10
data/CHANGELOG.md CHANGED
@@ -2,8 +2,55 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ Changes since `v4.20.2`.
6
+
7
+ ## v4.20.2
8
+
9
+ Changes since `v4.20.1`.
10
+
11
+ This patch release improves runtime behavior around interruption and mixed
12
+ concurrency waits. It also rounds out response API uniformity for Google
13
+ completion responses.
14
+
15
+ ### Fix
16
+
17
+ * **Expose Google completion response IDs through `.id`** <br>
18
+ Add `LLM::Response#id` support to Google completion responses so tracer
19
+ and caller code can rely on the same API used by other providers.
20
+
21
+ * **Track interrupt ownership on the active request** <br>
22
+ Bind `LLM::Context` interruption to the fiber running `talk` or `respond`
23
+ so `interrupt!` works correctly when requests are started outside the
24
+ context's initialization fiber.
25
+
26
+ ### Change
27
+
28
+ * **Allow mixed concurrency strategies in `wait(...)`** <br>
29
+ Let `LLM::Context#wait`, `LLM::Stream#wait`, and `LLM::Agent.concurrency`
30
+ accept arrays such as `[:thread, :ractor]` so mixed tool sets can wait on
31
+ more than one concurrency strategy.
32
+
33
+ ## v4.20.1
34
+
5
35
  Changes since `v4.20.0`.
6
36
 
37
+ This patch release fixes ORM option resolution in the Sequel and
38
+ ActiveRecord wrappers. Symbol-based `provider:` and `context:` hooks now
39
+ resolve correctly, and internal default option constants are referenced
40
+ explicitly instead of relying on nested constant lookup.
41
+
42
+ ### Fix
43
+
44
+ * **Fix symbol-based ORM option hooks for provider and context hashes** <br>
45
+ Make `provider:` and `context:` resolve symbol hooks through the model in
46
+ the Sequel plugin and ActiveRecord wrappers instead of falling back to an
47
+ empty hash.
48
+
49
+ * **Fix ORM wrapper constant lookup for option defaults** <br>
50
+ Qualify internal `EMPTY_HASH` / `DEFAULTS` references in the Sequel plugin
51
+ and ActiveRecord wrappers so option resolution does not depend on nested
52
+ constant lookup quirks.
53
+
7
54
  ## v4.20.0
8
55
 
9
56
  Changes since `v4.19.0`.
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <p align="center">
5
5
  <a href="https://0x1eef.github.io/x/llm.rb?rebuild=1"><img src="https://img.shields.io/badge/docs-0x1eef.github.io-blue.svg" alt="RubyDoc"></a>
6
6
  <a href="https://opensource.org/license/0bsd"><img src="https://img.shields.io/badge/License-0BSD-orange.svg?" alt="License"></a>
7
- <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.20.0-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.20.2-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
@@ -23,7 +23,8 @@ pieces only when needed, includes built-in ActiveRecord support through
23
23
  long-lived, tool-capable, stateful AI workflows instead of just
24
24
  request/response helpers.
25
25
 
26
- Want to see some code? Jump to [the examples](#examples) section.
26
+ Want to see some code? Jump to [the examples](#examples) section. <br>
27
+ Want a taste of what llm.rb can build? See [the screencast](#screencast).
27
28
 
28
29
  ## Architecture
29
30
 
@@ -186,7 +187,7 @@ gem install llm.rb
186
187
 
187
188
  ## Examples
188
189
 
189
- **REPL**
190
+ #### REPL
190
191
 
191
192
  This example uses [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) directly for an interactive REPL. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
192
193
 
@@ -203,7 +204,61 @@ loop do
203
204
  end
204
205
  ```
205
206
 
206
- **Sequel (ORM)**
207
+ #### Streaming
208
+
209
+ This example uses [`LLM::Stream`](https://0x1eef.github.io/x/llm.rb/LLM/Stream.html) directly so visible output and tool execution can happen together. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
210
+
211
+ ```ruby
212
+ require "llm"
213
+
214
+ class Stream < LLM::Stream
215
+ def on_content(content)
216
+ $stdout << content
217
+ end
218
+
219
+ def on_tool_call(tool, error)
220
+ return queue << error if error
221
+ $stdout << "\nRunning tool #{tool.name}...\n"
222
+ queue << tool.spawn(:thread)
223
+ end
224
+
225
+ def on_tool_return(tool, result)
226
+ if result.error?
227
+ $stdout << "Tool #{tool.name} failed\n"
228
+ else
229
+ $stdout << "Finished tool #{tool.name}\n"
230
+ end
231
+ end
232
+ end
233
+
234
+ llm = LLM.openai(key: ENV["KEY"])
235
+ ctx = LLM::Context.new(llm, stream: Stream.new, tools: [System])
236
+
237
+ ctx.talk("Run `date` and `uname -a`.")
238
+ ctx.talk(ctx.wait(:thread)) while ctx.functions.any?
239
+ ```
240
+
241
+ #### Request Cancellation
242
+
243
+ Need to cancel a stream? llm.rb has you covered through [`LLM::Context#interrupt!`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html#interrupt-21-instance_method). <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
244
+
245
+ ```ruby
246
+ require "llm"
247
+ require "io/console"
248
+
249
+ llm = LLM.openai(key: ENV["KEY"])
250
+ ctx = LLM::Context.new(llm, stream: $stdout)
251
+
252
+ worker = Thread.new do
253
+ ctx.talk("Write a very long essay about network protocols.")
254
+ end
255
+
256
+ STDIN.getch
257
+ ctx.interrupt!
258
+ worker.join
259
+ ```
260
+
261
+ #### Sequel (ORM)
207
262
 
208
263
  The `plugin :llm` integration wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) on a `Sequel::Model` and keeps tool execution explicit. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
209
264
 
@@ -222,7 +277,7 @@ ctx.talk("Remember that my favorite language is Ruby")
222
277
  puts ctx.talk("What is my favorite language?").content
223
278
  ```
224
279
 
225
- **ActiveRecord (ORM): acts_as_llm**
280
+ #### ActiveRecord (ORM): acts_as_llm
226
281
 
227
282
  The `acts_as_llm` method wraps [`LLM::Context`](https://0x1eef.github.io/x/llm.rb/LLM/Context.html) and
228
283
  provides full control over tool execution. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
@@ -242,7 +297,7 @@ ctx.talk("Remember that my favorite language is Ruby")
242
297
  puts ctx.talk("What is my favorite language?").content
243
298
  ```
244
299
 
245
- **ActiveRecord (ORM): acts_as_agent**
300
+ #### ActiveRecord (ORM): acts_as_agent
246
301
 
247
302
  The `acts_as_agent` method wraps [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) and
248
303
  manages tool execution for you. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
@@ -272,7 +327,7 @@ ticket = Ticket.create!(provider: "openai", model: "gpt-5.4-mini")
272
327
  puts ticket.talk("How do I rotate my API key?").content
273
328
  ```
274
329
 
275
- **Agent**
330
+ #### Agent
276
331
 
277
332
  This example uses [`LLM::Agent`](https://0x1eef.github.io/x/llm.rb/LLM/Agent.html) directly and lets the agent manage tool execution. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
278
333
 
@@ -291,6 +346,37 @@ agent = ShellAgent.new(llm)
291
346
  puts agent.talk("What time is it on this system?").content
292
347
  ```
293
348
 
349
+ #### MCP
350
+
351
+ This example uses [`LLM::MCP`](https://0x1eef.github.io/x/llm.rb/LLM/MCP.html) over HTTP so remote GitHub MCP tools run through the same `LLM::Context` tool path as local tools. <br> See the [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) for more examples.
352
+
353
+ ```ruby
354
+ require "llm"
355
+ require "net/http/persistent"
356
+
357
+ llm = LLM.openai(key: ENV["KEY"])
358
+ mcp = LLM::MCP.http(
359
+ url: "https://api.githubcopilot.com/mcp/",
360
+ headers: {"Authorization" => "Bearer #{ENV.fetch("GITHUB_PAT")}"}
361
+ ).persistent
362
+
363
+ begin
364
+ mcp.start
365
+ ctx = LLM::Context.new(llm, stream: $stdout, tools: mcp.tools)
366
+ ctx.talk("Pull information about my GitHub account.")
367
+ ctx.talk(ctx.call(:functions)) while ctx.functions.any?
368
+ ensure
369
+ mcp.stop
370
+ end
371
+ ```
372
+
373
+ ## Screencast
374
+
375
+ This screencast was built on an older version of llm.rb, but it still shows
376
+ how capable the runtime can be in a real application:
377
+
378
+ [![Watch the llm.rb screencast](https://img.youtube.com/vi/Jb7LNUYlCf4/maxresdefault.jpg)](https://www.youtube.com/watch?v=x1K4wMeO_QA)
379
+
294
380
  ## Resources
295
381
 
296
382
  - [deepdive](https://0x1eef.github.io/x/llm.rb/file.deepdive.html) is the
@@ -150,8 +150,8 @@ module LLM::ActiveRecord
150
150
  # @return [Hash]
151
151
  def resolve_options(option)
152
152
  case option
153
- when Proc, Hash then resolve_option(option)
154
- else EMPTY_HASH.dup
153
+ when Proc, Symbol, Hash then resolve_option(option)
154
+ else ActsAsAgent::EMPTY_HASH.dup
155
155
  end
156
156
  end
157
157
 
@@ -270,8 +270,8 @@ module LLM::ActiveRecord
270
270
  # @return [Hash]
271
271
  def resolve_options(option)
272
272
  case option
273
- when Proc, Hash then resolve_option(option)
274
- else EMPTY_HASH.dup
273
+ when Proc, Symbol, Hash then resolve_option(option)
274
+ else ActsAsLLM::EMPTY_HASH.dup
275
275
  end
276
276
  end
277
277
 
data/lib/llm/agent.rb CHANGED
@@ -17,7 +17,8 @@ module LLM
17
17
  # * Instructions are injected only on the first request.
18
18
  # * An agent automatically executes tool loops (unlike {LLM::Context LLM::Context}).
19
19
  # * Tool loop execution can be configured with `concurrency :call`,
20
- # `:thread`, `:task`, `:fiber`, or `:ractor`.
20
+ # `:thread`, `:task`, `:fiber`, `:ractor`, or a list of queued task
21
+ # types such as `[:thread, :ractor]`.
21
22
  #
22
23
  # @example
23
24
  # class SystemAdmin < LLM::Agent
@@ -83,7 +84,7 @@ module LLM
83
84
  ##
84
85
  # Set or get the tool execution concurrency.
85
86
  #
86
- # @param [Symbol, nil] concurrency
87
+ # @param [Symbol, Array<Symbol>, nil] concurrency
87
88
  # Controls how pending tool loops are executed:
88
89
  # - `:call`: sequential calls
89
90
  # - `:thread`: concurrent threads
@@ -91,7 +92,10 @@ module LLM
91
92
  # - `:fiber`: concurrent raw fibers
92
93
  # - `:ractor`: concurrent Ruby ractors for class-based tools; MCP tools are not supported,
93
94
  # and this mode is especially useful for CPU-bound tool work
94
- # @return [Symbol, nil]
95
+ # - `[:thread, :ractor]`: the possible concurrency strategies to wait on, in the
96
+ # given order. This is useful for mixed tool sets or when work may have been
97
+ # spawned with more than one concurrency strategy.
98
+ # @return [Symbol, Array<Symbol>, nil]
95
99
  def self.concurrency(concurrency = nil)
96
100
  return @concurrency if concurrency.nil?
97
101
  @concurrency = concurrency
@@ -107,7 +111,7 @@ module LLM
107
111
  # @option params [String] :model Defaults to the provider's default model
108
112
  # @option params [Array<LLM::Function>, nil] :tools Defaults to nil
109
113
  # @option params [#to_json, nil] :schema Defaults to nil
110
- # @option params [Symbol, nil] :concurrency Defaults to the agent class concurrency
114
+ # @option params [Symbol, Array<Symbol>, nil] :concurrency Defaults to the agent class concurrency
111
115
  def initialize(llm, params = {})
112
116
  defaults = {model: self.class.model, tools: self.class.tools, schema: self.class.schema}.compact
113
117
  @concurrency = params.delete(:concurrency) || self.class.concurrency
@@ -270,7 +274,7 @@ module LLM
270
274
 
271
275
  ##
272
276
  # Returns the configured tool execution concurrency.
273
- # @return [Symbol, nil]
277
+ # @return [Symbol, Array<Symbol>, nil]
274
278
  def concurrency
275
279
  @concurrency
276
280
  end
@@ -348,8 +352,8 @@ module LLM
348
352
  def call_functions
349
353
  case concurrency || :call
350
354
  when :call then call(:functions)
351
- when :thread, :task, :fiber, :ractor then wait(concurrency)
352
- else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, :fiber, or :ractor"
355
+ when :thread, :task, :fiber, :ractor, Array then wait(concurrency)
356
+ else raise ArgumentError, "Unknown concurrency: #{concurrency.inspect}. Expected :call, :thread, :task, :fiber, :ractor, or an array of queued task types"
353
357
  end
354
358
  end
355
359
  end
data/lib/llm/context.rb CHANGED
@@ -69,7 +69,6 @@ module LLM
69
69
  @mode = params.delete(:mode) || :completions
70
70
  @params = {model: llm.default_model, schema: nil}.compact.merge!(params)
71
71
  @messages = LLM::Buffer.new(llm)
72
- @owner = Fiber.current
73
72
  end
74
73
 
75
74
  ##
@@ -86,6 +85,7 @@ module LLM
86
85
  # puts res.messages[0].content
87
86
  def talk(prompt, params = {})
88
87
  return respond(prompt, params) if mode == :responses
88
+ @owner = Fiber.current
89
89
  params = params.merge(messages: @messages.to_a)
90
90
  params = @params.merge(params)
91
91
  bind!(params[:stream], params[:model])
@@ -112,6 +112,7 @@ module LLM
112
112
  # res = ctx.respond("What is the capital of France?")
113
113
  # puts res.output_text
114
114
  def respond(prompt, params = {})
115
+ @owner = Fiber.current
115
116
  params = @params.merge(params)
116
117
  bind!(params[:stream], params[:model])
117
118
  res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
@@ -182,8 +183,10 @@ module LLM
182
183
  # exposes a non-empty queue. Otherwise it falls back to waiting on
183
184
  # the context's pending functions directly.
184
185
  #
185
- # @param [Symbol] strategy
186
- # The concurrency strategy to use
186
+ # @param [Symbol, Array<Symbol>] strategy
187
+ # The concurrency strategy to use, or the possible concurrency strategies to
188
+ # wait on. For example, `[:thread, :ractor]` waits for any queued thread or
189
+ # ractor work, in that order.
187
190
  # @return [Array<LLM::Function::Return>]
188
191
  def wait(strategy)
189
192
  stream = @params[:stream]
@@ -9,6 +9,12 @@ module LLM::Google::ResponseAdapter
9
9
  end
10
10
  alias_method :choices, :messages
11
11
 
12
+ ##
13
+ # (see LLM::Contract::Completion#id)
14
+ def id
15
+ body["responseId"]
16
+ end
17
+
12
18
  ##
13
19
  # (see LLM::Contract::Completion#input_tokens)
14
20
  def input_tokens
@@ -79,7 +79,7 @@ module LLM::Sequel
79
79
  ##
80
80
  # @return [Hash]
81
81
  def llm_plugin_options
82
- @llm_plugin_options || DEFAULTS
82
+ @llm_plugin_options || Plugin::DEFAULTS
83
83
  end
84
84
  end
85
85
 
@@ -287,8 +287,8 @@ module LLM::Sequel
287
287
  # @return [Hash]
288
288
  def resolve_options(option)
289
289
  case option
290
- when Proc, Hash then resolve_option(option)
291
- else EMPTY_HASH.dup
290
+ when Proc, Symbol, Hash then resolve_option(option)
291
+ else Plugin::EMPTY_HASH.dup
292
292
  end
293
293
  end
294
294
 
@@ -33,27 +33,57 @@ class LLM::Stream
33
33
 
34
34
  ##
35
35
  # Waits for queued work to finish and returns function results.
36
- # @param [Symbol] strategy
37
- # Controls concurrency strategy:
36
+ # @param [Symbol, Array<Symbol>] strategy
37
+ # Controls concurrency strategy, or lists the possible concurrency strategies
38
+ # to wait on:
38
39
  # - `:thread`: Use threads
39
40
  # - `:task`: Use async tasks (requires async gem)
40
41
  # - `:fiber`: Use raw fibers
41
42
  # - `:ractor`: Use Ruby ractors (class-based tools only; MCP tools are not supported)
43
+ # - `[:thread, :ractor]`: Wait for any queued thread or ractor work, in the
44
+ # given order. This is useful when different tools were spawned with
45
+ # different concurrency strategies.
42
46
  # @return [Array<LLM::Function::Return>]
43
47
  def wait(strategy)
44
48
  returns, tasks = @items.shift(@items.length).partition { LLM::Function::Return === _1 }
45
- results = case strategy
49
+ results = wait_tasks(tasks, strategy)
50
+ returns.concat fire_hooks(tasks, results)
51
+ end
52
+ alias_method :value, :wait
53
+
54
+ private
55
+
56
+ def wait_tasks(tasks, strategy)
57
+ strategies = Array(strategy)
58
+ return wait_group(tasks, strategies.first) unless strategies.length > 1
59
+ grouped = strategies.to_h { [_1, []] }
60
+ tasks.each do |task|
61
+ grouped[task_strategy(task)] << task
62
+ end
63
+ strategies.flat_map do |name|
64
+ selected = grouped.fetch(name)
65
+ selected.empty? ? [] : wait_group(selected, name)
66
+ end
67
+ end
68
+
69
+ def wait_group(tasks, strategy)
70
+ case strategy
46
71
  when :thread then LLM::Function::ThreadGroup.new(tasks).wait
47
72
  when :task then LLM::Function::TaskGroup.new(tasks).wait
48
73
  when :fiber then LLM::Function::FiberGroup.new(tasks).wait
49
74
  when :ractor then LLM::Function::Ractor::Group.new(tasks).wait
50
75
  else raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, :fiber, or :ractor"
51
76
  end
52
- returns.concat fire_hooks(tasks, results)
53
77
  end
54
- alias_method :value, :wait
55
78
 
56
- private
79
+ def task_strategy(task)
80
+ case task.task
81
+ when Thread then :thread
82
+ when Fiber then :fiber
83
+ when LLM::Function::Ractor::Task then :ractor
84
+ else :task
85
+ end
86
+ end
57
87
 
58
88
  def fire_hooks(tasks, results)
59
89
  results.each_with_index do |result, idx|
data/lib/llm/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LLM
4
- VERSION = "4.20.0"
4
+ VERSION = "4.20.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm.rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.20.0
4
+ version: 4.20.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri