llm.rb 4.11.1 → 4.12.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: f4c449483ce7a3b53411760d6376157fed3e23b4f013f23ae397255398bef368
4
- data.tar.gz: a9a9c82b107cde72edfe6fe5f68ea7b1ea5e493314883d101c453a94db81b601
3
+ metadata.gz: 79d4a45ec25408e46451475575e917ef9d8579bec32f1a6a78bfed235e5ae212
4
+ data.tar.gz: fdeb12175be3ef87e411021444305b9e785a9bf2d055dfdc7bf718f5740623d8
5
5
  SHA512:
6
- metadata.gz: 71a389b2fe654cfd053f45bd749c34b96c9d89ac60e984960f4a2720896588ba39056a3a92ab75a429572cd099961d9f3c02474f7dc43460b59866e41d8b5f28
7
- data.tar.gz: 4532ec55176751b32ed21b281f2f71395dcd32cdf318973a751decf171af0a9e5f3f75b75871542c578fd9a2a134f8fc5cbf6a54b1df3b2dbe0c47745122b900
6
+ metadata.gz: ea35b39b5476b75370485128dd8441e078bc7ac69236a7a50f4e32fb419f6fac5f7bb81faf3e029f28b788f4d69645e1b97e4126ea4f9fcc31f014921d2434a4
7
+ data.tar.gz: c73bbf806f5cef71bfadfc1368fbdbfe07bf37118df18ebec71f4914a27ae2a3858fa6a210ee4d7cdff8f672a14c59016604a72a0a90c611b37223c4652ee991
data/CHANGELOG.md CHANGED
@@ -1,11 +1,43 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## v4.12.0
4
4
 
5
5
  Changes since `v4.11.1`.
6
6
 
7
+ This release expands advanced streaming and MCP execution while reframing
8
+ llm.rb more clearly as a system integration layer for LLMs, tools, MCP
9
+ sources, and application APIs.
10
+
11
+ ### Add
12
+
13
+ - Add `persistent` as an alias for `persist!` on providers and MCP transports.
14
+ - Add `LLM::Stream#on_tool_return` for observing completed streamed tool work.
15
+ - Add `LLM::Function::Return#error?`.
16
+
17
+ ### Change
18
+
19
+ - Expect advanced streaming callbacks to use `LLM::Stream` subclasses
20
+ instead of duck-typing them onto arbitrary objects. Basic `#<<`
21
+ streaming remains supported.
22
+
23
+ ### Fix
24
+
25
+ - Fix Anthropic tools without params by always emitting `input_schema`.
26
+ - Fix Anthropic tool-only responses to still produce an assistant message.
27
+ - Fix Anthropic tool results to use the `user` role.
28
+ - Fix Anthropic tool input normalization.
29
+
7
30
  ## v4.11.1
8
31
 
32
+ Changes since `v4.11.0`.
33
+
34
+ ### Fix
35
+
36
+ * Cast OpenTelemetry tool-related values to strings. <br>
37
+ Otherwise they're rejected by opentelemetry-sdk as invalid attributes.
38
+
39
+ ## v4.11.0
40
+
9
41
  Changes since `v4.10.0`.
10
42
 
11
43
  ### Add
data/README.md CHANGED
@@ -4,15 +4,16 @@
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.11.1-green.svg?" alt="Version"></a>
7
+ <a href="https://github.com/llmrb/llm.rb/tags"><img src="https://img.shields.io/badge/version-4.12.0-green.svg?" alt="Version"></a>
8
8
  </p>
9
9
 
10
10
  ## About
11
11
 
12
- llm.rb is a Ruby-centric toolkit for building real LLM-powered systems — where
13
- LLMs are part of your architecture, not just API calls. It gives you explicit
14
- control over contexts, tools, concurrency, and providers, so you can compose
15
- reliable, production-ready workflows without hidden abstractions.
12
+ llm.rb is a Ruby-centric system integration layer for building real
13
+ LLM-powered systems. It connects LLMs to real systems by turning APIs into
14
+ tools and unifying MCP, providers, and application logic into a single
15
+ execution model. It is used in production systems integrating external and
16
+ internal tools, including agents, MCP services, and OpenAPI-based APIs.
16
17
 
17
18
  Built for engineers who want to understand and control their LLM systems. No
18
19
  frameworks, no hidden magic — just composable primitives for building real
@@ -26,8 +27,10 @@ and capabilities of llm.rb.
26
27
  ## What Makes It Different
27
28
 
28
29
  Most LLM libraries stop at requests and responses. <br>
29
- llm.rb is built around the state and execution model around them:
30
+ llm.rb is built around the state and execution model behind them:
30
31
 
32
+ - **A system layer, not just an API wrapper** <br>
33
+ llm.rb unifies LLMs, tools, MCP servers, and application APIs into a single execution model.
31
34
  - **Contexts are central** <br>
32
35
  They hold history, tools, schema, usage, cost, persistence, and execution state.
33
36
  - **Contexts can be serialized** <br>
@@ -39,7 +42,7 @@ llm.rb is built around the state and execution model around them:
39
42
  Start tool work while a response is still streaming instead of waiting for the turn to finish. <br>
40
43
  This overlaps tool latency with model output and exposes streamed tool-call events for introspection, making it one of llm.rb's strongest execution features.
41
44
  - **HTTP MCP can reuse connections** <br>
42
- Opt into persistent HTTP pooling for repeated remote MCP tool calls with `persist!`.
45
+ Opt into persistent HTTP pooling for repeated remote MCP tool calls with `persistent`.
43
46
  - **One API across providers and capabilities** <br>
44
47
  The same model covers chat, files, images, audio, embeddings, vector stores, and more.
45
48
  - **Thread-safe where it matters** <br>
@@ -49,22 +52,48 @@ llm.rb is built around the state and execution model around them:
49
52
  - **Stdlib-only by default** <br>
50
53
  llm.rb runs on the Ruby standard library by default, with providers, optional features, and the model registry loaded only when you use them.
51
54
 
55
+ ## What llm.rb Enables
56
+
57
+ llm.rb acts as the integration layer between LLMs, tools, and real systems.
58
+
59
+ - Turn REST / OpenAPI APIs into LLM tools
60
+ - Connect multiple MCP sources (Notion, internal services, etc.)
61
+ - Build agents that operate across system boundaries
62
+ - Orchestrate tools from multiple providers and protocols
63
+ - Stream responses while executing tools concurrently
64
+ - Treat LLMs as part of your architecture, not isolated calls
65
+
66
+ Without llm.rb, providers, tool formats, and orchestration paths tend to stay
67
+ fragmented. With llm.rb, they share a unified execution model with composable
68
+ tools and a more consistent system architecture.
69
+
70
+ ## Real-World Usage
71
+
72
+ llm.rb is used to integrate external MCP services such as Notion, internal APIs
73
+ exposed via OpenAPI or `swagger.json`, and multiple tool sources into a unified
74
+ execution model. Common usage patterns include combining multiple MCP sources,
75
+ turning internal APIs into tools, and running those tools through the same
76
+ context and provider flow.
77
+
78
+ It supports multiple MCP sources, external SaaS integrations, internal APIs via
79
+ OpenAPI, and multiple LLM providers simultaneously.
80
+
52
81
  ## Architecture & Execution Model
53
82
 
54
- llm.rb is built in layers, each providing explicit control:
83
+ llm.rb sits at the center of the execution path, connecting tools, MCP
84
+ sources, APIs, providers, and your application through explicit contexts:
55
85
 
56
86
  ```
57
- ┌─────────────────────────────────────────┐
58
- Your Application
59
- ├─────────────────────────────────────────┤
60
- Contexts & Agents │ ← Stateful workflows
61
- ├─────────────────────────────────────────┤
62
- Tools & Functions │ ← Concurrent execution
63
- ├─────────────────────────────────────────┤
64
- │ Unified Provider API (OpenAI, etc.) │ ← Provider abstraction
65
- ├─────────────────────────────────────────┤
66
- │ HTTP, JSON, Thread Safety │ ← Infrastructure
67
- └─────────────────────────────────────────┘
87
+ External MCP Internal MCP OpenAPI / REST
88
+
89
+ └────────── Tools / MCP Layer ──────────┘
90
+
91
+ llm.rb Contexts
92
+
93
+ LLM Providers
94
+ (OpenAI, Anthropic, etc.)
95
+
96
+ Your Application
68
97
  ```
69
98
 
70
99
  ### Key Design Decisions
@@ -103,6 +132,10 @@ llm.rb provides a complete set of primitives for building LLM-powered systems:
103
132
 
104
133
  ## Quick Start
105
134
 
135
+ These examples show individual features, but llm.rb is designed to combine
136
+ them into full systems where LLMs, tools, and external services operate
137
+ together.
138
+
106
139
  #### Simple Streaming
107
140
 
108
141
  At the simplest level, any object that implements `#<<` can receive visible
@@ -111,7 +144,8 @@ and other Ruby IO-style objects.
111
144
 
112
145
  For more control, llm.rb also supports advanced streaming patterns through
113
146
  [`LLM::Stream`](lib/llm/stream.rb). See [Advanced Streaming](#advanced-streaming)
114
- for a structured callback-based example:
147
+ for a structured callback-based example. Basic `#<<` streams only receive
148
+ visible output chunks:
115
149
 
116
150
  ```ruby
117
151
  #!/usr/bin/env ruby
@@ -215,28 +249,33 @@ ctx.talk(ctx.wait(:thread)) while ctx.functions.any?
215
249
 
216
250
  #### Advanced Streaming
217
251
 
218
- llm.rb also supports the [`LLM::Stream`](lib/llm/stream.rb) interface for
219
- structured streaming events:
252
+ Use [`LLM::Stream`](lib/llm/stream.rb) when you want more than plain `#<<`
253
+ output. It adds structured streaming callbacks for:
220
254
 
221
255
  - `on_content` for visible assistant output
222
256
  - `on_reasoning_content` for separate reasoning output
223
257
  - `on_tool_call` for streamed tool-call notifications
258
+ - `on_tool_return` for completed tool execution
259
+
260
+ Subclass [`LLM::Stream`](lib/llm/stream.rb) when you want callbacks like
261
+ `on_reasoning_content`, `on_tool_call`, and `on_tool_return`, or helpers like
262
+ `queue` and `wait`.
224
263
 
225
- Subclass [`LLM::Stream`](lib/llm/stream.rb) when you want features like
226
- `queue` and `wait`, or implement the same methods on your own object. Keep these
227
- callbacks fast: they run inline with the parser.
264
+ Keep `on_content`, `on_reasoning_content`, and `on_tool_call` fast: they run
265
+ inline with the streaming parser. `on_tool_return` is different: it runs later,
266
+ when `wait` resolves queued streamed tool work.
228
267
 
229
268
  `on_tool_call` lets tools start before the model finishes its turn, for
230
269
  example with `tool.spawn(:thread)`, `tool.spawn(:fiber)`, or
231
- `tool.spawn(:task)`. That can overlap tool latency with streaming output and
232
- gives you a first-class place to observe and instrument tool-call execution as
233
- it unfolds.
270
+ `tool.spawn(:task)`. That can overlap tool latency with streaming output.
271
+ `on_tool_return` is the place to react when that queued work completes, for
272
+ example by updating progress UIs, logging tool latency, or changing visible
273
+ state from "Running tool ..." to "Finished tool ...".
234
274
 
235
- If a stream cannot resolve a tool, `error` is an `LLM::Function::Return` that
236
- communicates the failure back to the LLM. That lets the tool-call path recover
237
- and keeps the session alive. It also leaves control in the callback: it can
238
- send `error`, spawn the tool when `error == nil`, or handle the situation
239
- however it sees fit.
275
+ If a stream cannot resolve a tool, `on_tool_call` receives `error` as an
276
+ `LLM::Function::Return`. That keeps the session alive and leaves control in
277
+ the callback: it can send `error`, spawn the tool when `error == nil`, or
278
+ handle the situation however it sees fit.
240
279
 
241
280
  In normal use this should be rare, since `on_tool_call` is usually called with
242
281
  a resolved tool and `error == nil`. To resolve a tool call, the tool must be
@@ -250,25 +289,22 @@ require "llm"
250
289
  # Assume `System < LLM::Tool` is already defined.
251
290
 
252
291
  class Stream < LLM::Stream
253
- attr_reader :content, :reasoning_content
254
-
255
- def initialize
256
- @content = +""
257
- @reasoning_content = +""
258
- end
259
-
260
292
  def on_content(content)
261
- @content << content
262
- print content
293
+ $stdout << content
263
294
  end
264
295
 
265
296
  def on_reasoning_content(content)
266
- @reasoning_content << content
297
+ $stderr << content
267
298
  end
268
299
 
269
300
  def on_tool_call(tool, error)
301
+ $stdout << "Running tool #{tool.name}\n"
270
302
  queue << (error || tool.spawn(:thread))
271
303
  end
304
+
305
+ def on_tool_return(tool, ret)
306
+ $stdout << (ret.error? ? "Tool #{tool.name} failed\n" : "Finished tool #{tool.name}\n")
307
+ end
272
308
  end
273
309
 
274
310
  llm = LLM.openai(key: ENV["KEY"])
@@ -282,6 +318,16 @@ end
282
318
 
283
319
  #### MCP
284
320
 
321
+ MCP is a first-class integration mechanism in llm.rb.
322
+
323
+ MCP allows llm.rb to treat external services, internal APIs, and system
324
+ capabilities as tools in a unified interface. This makes it possible to
325
+ connect multiple MCP sources simultaneously and expose your own APIs as tools.
326
+
327
+ In practice, this supports workflows such as external SaaS integrations,
328
+ multiple MCP sources in the same context, and OpenAPI -> MCP -> tools
329
+ pipelines for internal services.
330
+
285
331
  llm.rb integrates with the Model Context Protocol (MCP) to dynamically discover
286
332
  and use tools from external servers. This example starts a filesystem MCP
287
333
  server over stdio and makes its tools available to a context, enabling the LLM
@@ -309,7 +355,7 @@ end
309
355
 
310
356
  You can also connect to an MCP server over HTTP. This is useful when the
311
357
  server already runs remotely and exposes MCP through a URL instead of a local
312
- process. If you expect repeated tool calls, use `persist!` to reuse a
358
+ process. If you expect repeated tool calls, use `persistent` to reuse a
313
359
  process-wide HTTP connection pool. This requires the optional
314
360
  `net-http-persistent` gem:
315
361
 
@@ -321,7 +367,7 @@ llm = LLM.openai(key: ENV["KEY"])
321
367
  mcp = LLM::MCP.http(
322
368
  url: "https://api.githubcopilot.com/mcp/",
323
369
  headers: {"Authorization" => "Bearer #{ENV.fetch("GITHUB_PAT")}"}
324
- ).persist!
370
+ ).persistent
325
371
 
326
372
  begin
327
373
  mcp.start
@@ -460,7 +506,7 @@ require "llm"
460
506
  LLM.json = :oj # Use Oj for faster JSON parsing
461
507
 
462
508
  # Enable HTTP connection pooling for high-throughput applications
463
- llm = LLM.openai(key: ENV["KEY"]).persist! # Uses net-http-persistent when available
509
+ llm = LLM.openai(key: ENV["KEY"]).persistent # Uses net-http-persistent when available
464
510
  ```
465
511
 
466
512
  #### Model Registry
@@ -9,11 +9,17 @@ class LLM::Function
9
9
  # @return [Object]
10
10
  attr_reader :task
11
11
 
12
+ ##
13
+ # @return [LLM::Function, nil]
14
+ attr_reader :function
15
+
12
16
  ##
13
17
  # @param [Thread, Fiber, Async::Task] task
18
+ # @param [LLM::Function, nil] function
14
19
  # @return [LLM::Function::Task]
15
- def initialize(task)
20
+ def initialize(task, function = nil)
16
21
  @task = task
22
+ @function = function
17
23
  end
18
24
 
19
25
  ##
data/lib/llm/function.rb CHANGED
@@ -41,6 +41,13 @@ class LLM::Function
41
41
  prepend LLM::Function::Tracing
42
42
 
43
43
  Return = Struct.new(:id, :name, :value) do
44
+ ##
45
+ # Returns true when the return value represents an error.
46
+ # @return [Boolean]
47
+ def error?
48
+ Hash === value && value[:error] == true
49
+ end
50
+
44
51
  ##
45
52
  # Returns a Hash representation of {LLM::Function::Return}
46
53
  # @return [Hash]
@@ -186,7 +193,7 @@ class LLM::Function
186
193
  else
187
194
  raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, or :fiber"
188
195
  end
189
- Task.new(task)
196
+ Task.new(task, self)
190
197
  ensure
191
198
  @called = true
192
199
  end
@@ -233,7 +240,11 @@ class LLM::Function
233
240
  when "LLM::Google"
234
241
  {name: @name, description: @description, parameters: @params}.compact
235
242
  when "LLM::Anthropic"
236
- {name: @name, description: @description, input_schema: @params}.compact
243
+ {
244
+ name: @name,
245
+ description: @description,
246
+ input_schema: @params || {type: "object", properties: {}}
247
+ }.compact
237
248
  else
238
249
  format_openai(provider)
239
250
  end
@@ -104,7 +104,7 @@ module LLM::MCP::Transport
104
104
  # Configures the transport to use a persistent HTTP connection pool
105
105
  # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
106
106
  # @example
107
- # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persist!
107
+ # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persistent
108
108
  # # do something with 'mcp'
109
109
  # @return [LLM::MCP::Transport::HTTP]
110
110
  def persist!
@@ -119,6 +119,7 @@ module LLM::MCP::Transport
119
119
  end
120
120
  self
121
121
  end
122
+ alias_method :persistent, :persist!
122
123
 
123
124
  private
124
125
 
@@ -84,6 +84,7 @@ module LLM::MCP::Transport
84
84
  def persist!
85
85
  self
86
86
  end
87
+ alias_method :persistent, :persist!
87
88
 
88
89
  private
89
90
 
data/lib/llm/mcp.rb CHANGED
@@ -104,13 +104,14 @@ class LLM::MCP
104
104
  # Configures an HTTP MCP transport to use a persistent connection pool
105
105
  # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
106
106
  # @example
107
- # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persist!
107
+ # mcp = LLM.mcp(http: {url: "https://example.com/mcp"}).persistent
108
108
  # # do something with 'mcp'
109
109
  # @return [LLM::MCP]
110
110
  def persist!
111
111
  transport.persist!
112
112
  self
113
113
  end
114
+ alias_method :persistent, :persist!
114
115
 
115
116
  ##
116
117
  # Returns the tools provided by the MCP process.
data/lib/llm/provider.rb CHANGED
@@ -308,7 +308,7 @@ class LLM::Provider
308
308
  # This method configures a provider to use a persistent connection pool
309
309
  # via the optional dependency [Net::HTTP::Persistent](https://github.com/drbrain/net-http-persistent)
310
310
  # @example
311
- # llm = LLM.openai(key: ENV["KEY"]).persist!
311
+ # llm = LLM.openai(key: ENV["KEY"]).persistent
312
312
  # # do something with 'llm'
313
313
  # @return [LLM::Provider]
314
314
  def persist!
@@ -317,14 +317,13 @@ class LLM::Provider
317
317
  tap { @client = client }
318
318
  end
319
319
  end
320
+ alias_method :persistent, :persist!
320
321
 
321
322
  ##
322
323
  # @param [Object] stream
323
324
  # @return [Boolean]
324
325
  def streamable?(stream)
325
- stream.respond_to?(:on_content) ||
326
- stream.respond_to?(:on_reasoning_content) ||
327
- stream.respond_to?(:<<)
326
+ LLM::Stream === stream || stream.respond_to?(:<<)
328
327
  end
329
328
 
330
329
  private
@@ -28,12 +28,19 @@ module LLM::Anthropic::RequestAdapter
28
28
 
29
29
  def adapt_message
30
30
  if message.tool_call?
31
- {role: message.role, content: message.extra[:original_tool_calls]}
31
+ {role: message.role, content: adapt_tool_calls}
32
32
  else
33
33
  {role: message.role, content: adapt_content(content)}
34
34
  end
35
35
  end
36
36
 
37
+ def adapt_tool_calls
38
+ message.extra[:tool_calls].filter_map do |tool|
39
+ next unless tool[:id] && tool[:name]
40
+ {type: "tool_use", id: tool[:id], name: tool[:name], input: LLM::Anthropic.parse_tool_input(tool[:arguments])}
41
+ end
42
+ end
43
+
37
44
  ##
38
45
  # @param [String, URI] content
39
46
  # The content to format
@@ -66,7 +66,8 @@ module LLM::Anthropic::ResponseAdapter
66
66
  private
67
67
 
68
68
  def adapt_choices
69
- texts.map.with_index do |choice, index|
69
+ source = texts.empty? && tools.any? ? [{"text" => ""}] : texts
70
+ source.map.with_index do |choice, index|
70
71
  extra = {
71
72
  index:, response: self,
72
73
  tool_calls: adapt_tool_calls(tools), original_tool_calls: tools
@@ -77,7 +78,11 @@ module LLM::Anthropic::ResponseAdapter
77
78
 
78
79
  def adapt_tool_calls(tools)
79
80
  (tools || []).filter_map do |tool|
80
- {id: tool.id, name: tool.name, arguments: tool.input}
81
+ {
82
+ id: tool.id,
83
+ name: tool.name,
84
+ arguments: LLM::Anthropic.parse_tool_input(tool.input)
85
+ }
81
86
  end
82
87
  end
83
88
 
@@ -105,7 +105,7 @@ class LLM::Anthropic
105
105
  registered = LLM::Function.find_by_name(tool["name"])
106
106
  fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
107
107
  fn.id = tool["id"]
108
- fn.arguments = tool["input"]
108
+ fn.arguments = LLM::Anthropic.parse_tool_input(tool["input"])
109
109
  end
110
110
  [fn, (registered ? nil : @stream.tool_not_found(fn))]
111
111
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Anthropic
4
+ module Utils
5
+ ##
6
+ # Normalizes Anthropic tool input to a Hash suitable for kwargs.
7
+ # @param input [Hash, String, nil]
8
+ # @return [Hash]
9
+ def parse_tool_input(input)
10
+ case input
11
+ when Hash then input
12
+ when String
13
+ parsed = LLM.json.load(input)
14
+ Hash === parsed ? parsed : {}
15
+ when nil then {}
16
+ else
17
+ input.respond_to?(:to_h) ? input.to_h : {}
18
+ end
19
+ rescue *LLM.json.parser_error
20
+ {}
21
+ end
22
+ end
23
+ end
@@ -14,6 +14,7 @@ module LLM
14
14
  # ctx.talk ["Tell me about this photo", ctx.local_file("/images/photo.png")]
15
15
  # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
16
16
  class Anthropic < Provider
17
+ require_relative "anthropic/utils"
17
18
  require_relative "anthropic/error_handler"
18
19
  require_relative "anthropic/request_adapter"
19
20
  require_relative "anthropic/response_adapter"
@@ -21,6 +22,7 @@ module LLM
21
22
  require_relative "anthropic/models"
22
23
  require_relative "anthropic/files"
23
24
  include RequestAdapter
25
+ extend Utils
24
26
 
25
27
  HOST = "api.anthropic.com"
26
28
 
@@ -79,6 +81,15 @@ module LLM
79
81
  "assistant"
80
82
  end
81
83
 
84
+ ##
85
+ # Anthropic expects tool results to be sent as user messages
86
+ # containing `tool_result` content blocks rather than a distinct
87
+ # `tool` role.
88
+ # @return (see LLM::Provider#tool_role)
89
+ def tool_role
90
+ :user
91
+ end
92
+
82
93
  ##
83
94
  # Returns the default model for chat completions
84
95
  # @see https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table claude-sonnet-4-20250514
@@ -8,8 +8,10 @@ class LLM::Stream
8
8
  # returns an array of {LLM::Function::Return} values.
9
9
  class Queue
10
10
  ##
11
+ # @param [LLM::Stream] stream
11
12
  # @return [LLM::Stream::Queue]
12
- def initialize
13
+ def initialize(stream)
14
+ @stream = stream
13
15
  @items = []
14
16
  end
15
17
 
@@ -39,13 +41,24 @@ class LLM::Stream
39
41
  # @return [Array<LLM::Function::Return>]
40
42
  def wait(strategy)
41
43
  returns, tasks = @items.shift(@items.length).partition { LLM::Function::Return === _1 }
42
- returns.concat case strategy
44
+ results = case strategy
43
45
  when :thread then LLM::Function::ThreadGroup.new(tasks).wait
44
46
  when :task then LLM::Function::TaskGroup.new(tasks).wait
45
47
  when :fiber then LLM::Function::FiberGroup.new(tasks).wait
46
48
  else raise ArgumentError, "Unknown strategy: #{strategy.inspect}. Expected :thread, :task, or :fiber"
47
49
  end
50
+ returns.concat fire_hooks(tasks, results)
48
51
  end
49
52
  alias_method :value, :wait
53
+
54
+ private
55
+
56
+ def fire_hooks(tasks, results)
57
+ results.each_with_index do |ret, idx|
58
+ tool = tasks[idx]&.function
59
+ @stream.on_tool_return(tool, ret) if tool
60
+ end
61
+ results
62
+ end
50
63
  end
51
64
  end
data/lib/llm/stream.rb CHANGED
@@ -5,20 +5,20 @@ module LLM
5
5
  # The {LLM::Stream LLM::Stream} class provides the callback interface for
6
6
  # streamed model output in llm.rb.
7
7
  #
8
- # A stream object can be an instance of {LLM::Stream LLM::Stream}, a
9
- # subclass that overrides the callbacks it needs, or any other object that
10
- # implements some or all of the same interface. {#queue} provides a small
11
- # helper for collecting asynchronous tool work started from a callback, and
12
- # {#tool_not_found} returns an in-band tool error when a streamed tool
13
- # cannot be resolved.
8
+ # A stream object can be an instance of {LLM::Stream LLM::Stream} or a
9
+ # subclass that overrides the callbacks it needs. For basic streaming,
10
+ # llm.rb also accepts any object that implements `#<<`. {#queue} provides
11
+ # a small helper for collecting asynchronous tool work started from a
12
+ # callback, and {#tool_not_found} returns an in-band tool error when a
13
+ # streamed tool cannot be resolved.
14
14
  #
15
15
  # @note The `on_*` callbacks run inline with the streaming parser. They
16
16
  # therefore block streaming progress and should generally return as
17
17
  # quickly as possible.
18
18
  #
19
- # The most common callback is {#on_content}, which also maps to {#<<} for
20
- # compatibility with `StringIO`-style objects. Providers may also call
21
- # {#on_reasoning_content} and {#on_tool_call} when that data is available.
19
+ # The most common callback is {#on_content}, which also maps to {#<<}.
20
+ # Providers may also call {#on_reasoning_content} and {#on_tool_call} when
21
+ # that data is available.
22
22
  class Stream
23
23
  require_relative "stream/queue"
24
24
 
@@ -26,7 +26,7 @@ module LLM
26
26
  # Returns a lazily-initialized queue for tool results or spawned work.
27
27
  # @return [LLM::Stream::Queue]
28
28
  def queue
29
- @queue ||= Queue.new
29
+ @queue ||= Queue.new(self)
30
30
  end
31
31
 
32
32
  ##
@@ -79,6 +79,20 @@ module LLM
79
79
  nil
80
80
  end
81
81
 
82
+ ##
83
+ # Called when queued streamed tool work returns.
84
+ # @note This callback runs when {#wait} resolves work that was queued from
85
+ # {#on_tool_call}, such as values returned by `tool.spawn(:thread)`,
86
+ # `tool.spawn(:fiber)`, or `tool.spawn(:task)`.
87
+ # @param [LLM::Function] tool
88
+ # The tool that returned.
89
+ # @param [LLM::Function::Return] ret
90
+ # The completed tool return.
91
+ # @return [nil]
92
+ def on_tool_return(tool, ret)
93
+ nil
94
+ end
95
+
82
96
  # @endgroup
83
97
 
84
98
  # @group Error handlers
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.11.1"
4
+ VERSION = "4.12.0"
5
5
  end
data/llm.gemspec CHANGED
@@ -8,47 +8,15 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Antar Azri", "0x1eef", "Christos Maris", "Rodrigo Serrano"]
9
9
  spec.email = ["azantar@proton.me", "0x1eef@hardenedbsd.org"]
10
10
 
11
- spec.summary = <<~SUMMARY
12
- llm.rb is a Ruby-centric toolkit for building real LLM-powered systems — where
13
- LLMs are part of your architecture, not just API calls. It gives you explicit
14
- control over contexts, tools, concurrency, and providers, so you can compose
15
- reliable, production-ready workflows without hidden abstractions.
16
- SUMMARY
11
+ spec.summary = "System integration layer for LLMs, tools, MCP, and APIs in Ruby."
17
12
 
18
13
  spec.description = <<~DESCRIPTION
19
- llm.rb is a Ruby-centric toolkit for building real LLM-powered systems — where
20
- LLMs are part of your architecture, not just API calls. It gives you explicit
21
- control over contexts, tools, concurrency, and providers, so you can compose
22
- reliable, production-ready workflows without hidden abstractions.
23
-
24
- Built for engineers who want to understand and control their LLM systems. No
25
- frameworks, no hidden magic — just composable primitives for building real
26
- applications, from scripts to full systems like Relay.
27
-
28
- ## Key Features
29
-
30
- - **Contexts are central** — Hold history, tools, schema, usage, cost, persistence, and execution state
31
- - **Tool execution is explicit** — Run local, provider-native, and MCP tools sequentially or concurrently
32
- - **One API across providers** — Unified interface for OpenAI, Anthropic, Google, xAI, zAI, DeepSeek, Ollama, and LlamaCpp
33
- - **Thread-safe where it matters** — Providers are shareable, while contexts stay isolated and stateful
34
- - **Production-ready** — Cost tracking, observability, persistence, and performance tuning built in
35
- - **Stdlib-only by default** — Runs on Ruby standard library, with optional features loaded only when used
36
-
37
- ## Capabilities
38
-
39
- - Chat & Contexts with persistence
40
- - Streaming responses
41
- - Tool calling with JSON Schema validation
42
- - Concurrent execution (threads, fibers, async tasks)
43
- - Agents with auto-execution
44
- - Structured outputs
45
- - MCP (Model Context Protocol) support
46
- - Multimodal inputs (text, images, audio, documents)
47
- - Audio generation, transcription, translation
48
- - Image generation and editing
49
- - Files API for document processing
50
- - Embeddings and vector stores
51
- - Local model registry for capabilities, limits, and pricing
14
+ llm.rb is a Ruby-centric system integration layer for building LLM-powered
15
+ systems. It connects LLMs to real systems by turning APIs into tools and
16
+ unifying MCP, providers, contexts, and application logic in one execution
17
+ model. It supports explicit tool orchestration, concurrent execution,
18
+ streaming, multiple MCP sources, and multiple LLM providers for production
19
+ systems that integrate external and internal services.
52
20
  DESCRIPTION
53
21
 
54
22
  spec.license = "0BSD"
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.11.1
4
+ version: 4.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antar Azri
@@ -195,39 +195,12 @@ dependencies:
195
195
  - !ruby/object:Gem::Version
196
196
  version: '1.7'
197
197
  description: |
198
- llm.rb is a Ruby-centric toolkit for building real LLM-powered systems — where
199
- LLMs are part of your architecture, not just API calls. It gives you explicit
200
- control over contexts, tools, concurrency, and providers, so you can compose
201
- reliable, production-ready workflows without hidden abstractions.
202
-
203
- Built for engineers who want to understand and control their LLM systems. No
204
- frameworks, no hidden magic — just composable primitives for building real
205
- applications, from scripts to full systems like Relay.
206
-
207
- ## Key Features
208
-
209
- - **Contexts are central** — Hold history, tools, schema, usage, cost, persistence, and execution state
210
- - **Tool execution is explicit** — Run local, provider-native, and MCP tools sequentially or concurrently
211
- - **One API across providers** — Unified interface for OpenAI, Anthropic, Google, xAI, zAI, DeepSeek, Ollama, and LlamaCpp
212
- - **Thread-safe where it matters** — Providers are shareable, while contexts stay isolated and stateful
213
- - **Production-ready** — Cost tracking, observability, persistence, and performance tuning built in
214
- - **Stdlib-only by default** — Runs on Ruby standard library, with optional features loaded only when used
215
-
216
- ## Capabilities
217
-
218
- - Chat & Contexts with persistence
219
- - Streaming responses
220
- - Tool calling with JSON Schema validation
221
- - Concurrent execution (threads, fibers, async tasks)
222
- - Agents with auto-execution
223
- - Structured outputs
224
- - MCP (Model Context Protocol) support
225
- - Multimodal inputs (text, images, audio, documents)
226
- - Audio generation, transcription, translation
227
- - Image generation and editing
228
- - Files API for document processing
229
- - Embeddings and vector stores
230
- - Local model registry for capabilities, limits, and pricing
198
+ llm.rb is a Ruby-centric system integration layer for building LLM-powered
199
+ systems. It connects LLMs to real systems by turning APIs into tools and
200
+ unifying MCP, providers, contexts, and application logic in one execution
201
+ model. It supports explicit tool orchestration, concurrent execution,
202
+ streaming, multiple MCP sources, and multiple LLM providers for production
203
+ systems that integrate external and internal services.
231
204
  email:
232
205
  - azantar@proton.me
233
206
  - 0x1eef@hardenedbsd.org
@@ -300,6 +273,7 @@ files:
300
273
  - lib/llm/providers/anthropic/response_adapter/models.rb
301
274
  - lib/llm/providers/anthropic/response_adapter/web_search.rb
302
275
  - lib/llm/providers/anthropic/stream_parser.rb
276
+ - lib/llm/providers/anthropic/utils.rb
303
277
  - lib/llm/providers/deepseek.rb
304
278
  - lib/llm/providers/deepseek/request_adapter.rb
305
279
  - lib/llm/providers/deepseek/request_adapter/completion.rb
@@ -417,8 +391,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
417
391
  requirements: []
418
392
  rubygems_version: 3.6.9
419
393
  specification_version: 4
420
- summary: llm.rb is a Ruby-centric toolkit for building real LLM-powered systems
421
- where LLMs are part of your architecture, not just API calls. It gives you explicit
422
- control over contexts, tools, concurrency, and providers, so you can compose reliable,
423
- production-ready workflows without hidden abstractions.
394
+ summary: System integration layer for LLMs, tools, MCP, and APIs in Ruby.
424
395
  test_files: []