llm.rb 15.0.3 → 15.2.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 +433 -3
- data/README.md +188 -71
- data/bin/llm.rb +50 -7
- data/data/alibaba.json +912 -823
- data/data/anthropic.json +234 -187
- data/data/bedrock.json +3702 -2058
- data/data/deepinfra.json +1288 -951
- data/data/deepseek.json +87 -53
- data/data/google.json +670 -670
- data/data/mistral.json +501 -460
- data/data/moonshot.json +43 -248
- data/data/openai.json +1008 -914
- data/data/openrouter.json +14417 -0
- data/data/xai.json +213 -201
- data/data/zai.json +242 -149
- data/docs/deepdive/advanced/compaction.md +5 -5
- data/docs/deepdive/advanced/context.md +8 -6
- data/docs/deepdive/advanced/guard.md +2 -2
- data/docs/deepdive/features/builtin_tools.md +93 -22
- data/docs/deepdive/features/{repl.md → console.md} +28 -28
- data/docs/deepdive/features/database.md +3 -3
- data/docs/deepdive/fundamentals/agents.md +13 -12
- data/docs/deepdive/fundamentals/providers.md +91 -6
- data/docs/deepdive/fundamentals/skills.md +14 -6
- data/docs/deepdive/fundamentals/stream.md +4 -4
- data/docs/deepdive/fundamentals/tools.md +63 -31
- data/docs/deepdive/reference/cost.md +2 -2
- data/docs/deepdive/reference/model_registry.md +2 -2
- data/docs/deepdive/reference/tracer.md +15 -13
- data/docs/deepdive.md +2 -2
- data/lib/llm/active_record/acts_as_agent.rb +9 -5
- data/lib/llm/agent.rb +40 -15
- data/lib/llm/{repl → console}/bar.rb +3 -3
- data/lib/llm/{repl → console}/buffer.rb +24 -9
- data/lib/llm/{repl → console}/color.rb +2 -2
- data/lib/llm/{repl → console}/command.rb +12 -12
- data/lib/llm/{repl → console}/commands/exit.rb +4 -4
- data/lib/llm/{repl → console}/commands/help.rb +1 -1
- data/lib/llm/{repl/commands/compact.rb → console/commands/keep.rb} +11 -9
- data/lib/llm/{repl → console}/commands/model.rb +2 -2
- data/lib/llm/{repl → console}/input/cache.rb +2 -2
- data/lib/llm/{repl → console}/input/char.rb +2 -2
- data/lib/llm/{repl → console}/input/row.rb +1 -1
- data/lib/llm/{repl → console}/input.rb +18 -10
- data/lib/llm/console/markdown/parser.rb +78 -0
- data/lib/llm/{repl → console}/markdown/table.rb +8 -5
- data/lib/llm/{repl → console}/markdown.rb +13 -30
- data/lib/llm/console/node.rb +69 -0
- data/lib/llm/{repl → console}/status.rb +11 -11
- data/lib/llm/{repl → console}/stream.rb +36 -9
- data/lib/llm/{repl → console}/walker.rb +1 -1
- data/lib/llm/{repl → console}/window.rb +17 -17
- data/lib/llm/{repl.rb → console.rb} +39 -19
- data/lib/llm/context/deserializer.rb +2 -1
- data/lib/llm/context.rb +29 -13
- data/lib/llm/cost.rb +13 -0
- data/lib/llm/function/async/reactor.rb +20 -1
- data/lib/llm/function/fork/task.rb +14 -10
- data/lib/llm/function.rb +1 -1
- data/lib/llm/json_adapter.rb +40 -28
- data/lib/llm/message.rb +7 -0
- data/lib/llm/provider.rb +31 -10
- data/lib/llm/providers/alibaba.rb +1 -1
- data/lib/llm/providers/anthropic.rb +1 -1
- data/lib/llm/providers/bedrock/models.rb +2 -2
- data/lib/llm/providers/bedrock.rb +1 -1
- data/lib/llm/providers/deepseek.rb +1 -1
- data/lib/llm/providers/google.rb +1 -1
- data/lib/llm/providers/ollama.rb +1 -1
- data/lib/llm/providers/openai/responses.rb +2 -1
- data/lib/llm/providers/openai.rb +2 -1
- data/lib/llm/providers/openrouter.rb +87 -0
- data/lib/llm/schema/leaf.rb +34 -2
- data/lib/llm/schema.rb +4 -2
- data/lib/llm/sequel/agent.rb +9 -5
- data/lib/llm/skill.rb +7 -1
- data/lib/llm/stream.rb +8 -3
- data/lib/llm/tool/param.rb +5 -1
- data/lib/llm/tool.rb +5 -0
- data/lib/llm/tools/bundle.rb +53 -0
- data/lib/llm/tools/edit-file.rb +7 -2
- data/lib/llm/tools/exec.rb +78 -0
- data/lib/llm/tools/git.rb +27 -26
- data/lib/llm/tools/mkdir.rb +12 -19
- data/lib/llm/tools/read_file.rb +69 -9
- data/lib/llm/tools/rg.rb +20 -24
- data/lib/llm/tools/ruby.rb +17 -25
- data/lib/llm/tools/utils.rb +75 -2
- data/lib/llm/tools/write_file.rb +4 -1
- data/lib/llm/tracer/logger.rb +2 -2
- data/lib/llm/tracer/pretty_logger.rb +4 -4
- data/lib/llm/tracer/telemetry.rb +2 -2
- data/lib/llm/tracer.rb +33 -0
- data/lib/llm/transport/curb.rb +5 -3
- data/lib/llm/transport/http.rb +5 -2
- data/lib/llm/transport/persistent_http.rb +6 -4
- data/lib/llm/transport/utils.rb +8 -6
- data/lib/llm/version.rb +1 -1
- data/lib/llm.rb +18 -12
- data/llm.gemspec +8 -8
- metadata +80 -37
- data/lib/llm/repl/node.rb +0 -44
- data/lib/llm/tools/shell.rb +0 -55
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class LLM::
|
|
3
|
+
class LLM::Console
|
|
4
4
|
##
|
|
5
|
-
# The {LLM::
|
|
5
|
+
# The {LLM::Console::Window LLM::Console::Window} class draws the
|
|
6
6
|
# curses screen for the REPL.
|
|
7
7
|
# @api private
|
|
8
8
|
class Window
|
|
@@ -12,21 +12,21 @@ class LLM::Repl
|
|
|
12
12
|
TOP_ROWS = 1
|
|
13
13
|
|
|
14
14
|
##
|
|
15
|
-
# @return [LLM::
|
|
15
|
+
# @return [LLM::Console::Status]
|
|
16
16
|
attr_reader :status
|
|
17
17
|
|
|
18
18
|
##
|
|
19
|
-
# @return [LLM::
|
|
19
|
+
# @return [LLM::Console::Buffer]
|
|
20
20
|
attr_reader :buffer
|
|
21
21
|
|
|
22
22
|
##
|
|
23
|
-
# @return [LLM::
|
|
23
|
+
# @return [LLM::Console::Input]
|
|
24
24
|
attr_reader :input
|
|
25
25
|
|
|
26
26
|
##
|
|
27
|
-
# @param [LLM::
|
|
27
|
+
# @param [LLM::Console] console
|
|
28
28
|
# A read-eval-print loop.
|
|
29
|
-
# @return [LLM::
|
|
29
|
+
# @return [LLM::Console::Window]
|
|
30
30
|
def initialize(repl)
|
|
31
31
|
@repl = repl
|
|
32
32
|
@status = repl.status
|
|
@@ -140,11 +140,11 @@ class LLM::Repl
|
|
|
140
140
|
remaining = buffer.width - width
|
|
141
141
|
break if remaining <= 0
|
|
142
142
|
text, attrs = chunk.text.to_s, chunk.attrs
|
|
143
|
-
clipped = text
|
|
143
|
+
clipped = Node.slice(text, remaining)
|
|
144
144
|
Curses.attron(attrs) if attrs
|
|
145
145
|
Curses.addstr(clipped)
|
|
146
146
|
Curses.attroff(attrs) if attrs
|
|
147
|
-
width += clipped
|
|
147
|
+
width += Node.width(clipped)
|
|
148
148
|
end
|
|
149
149
|
end
|
|
150
150
|
last_drawn = offset + rows.size
|
|
@@ -163,10 +163,10 @@ class LLM::Repl
|
|
|
163
163
|
Curses.clrtoeol
|
|
164
164
|
status.nodes.each { addnode(_1) }
|
|
165
165
|
context = status.context_bar
|
|
166
|
-
Curses.setpos(y, [(columns - context
|
|
166
|
+
Curses.setpos(y, [(columns - Node.width(context)) / 2, 0].max)
|
|
167
167
|
Curses.addstr(context)
|
|
168
168
|
cost = status.cost.to_s
|
|
169
|
-
Curses.setpos(y, [columns - cost
|
|
169
|
+
Curses.setpos(y, [columns - Node.width(cost), 0].max)
|
|
170
170
|
Curses.addstr(cost)
|
|
171
171
|
end
|
|
172
172
|
|
|
@@ -178,9 +178,9 @@ class LLM::Repl
|
|
|
178
178
|
def draw_meta(offset:)
|
|
179
179
|
y = Curses.lines - offset
|
|
180
180
|
fill_row(y) do
|
|
181
|
-
addnode(status.cwd, status.cwd.text
|
|
182
|
-
Curses.setpos(y, [columns - status.model.
|
|
183
|
-
addnode(status.model, status.model.text
|
|
181
|
+
addnode(status.cwd, Node.slice(status.cwd.text, columns))
|
|
182
|
+
Curses.setpos(y, [columns - status.model.size, 0].max)
|
|
183
|
+
addnode(status.model, Node.slice(status.model.text, columns))
|
|
184
184
|
end
|
|
185
185
|
end
|
|
186
186
|
|
|
@@ -239,15 +239,15 @@ class LLM::Repl
|
|
|
239
239
|
|
|
240
240
|
##
|
|
241
241
|
# 20% offset that occupies the left margin and
|
|
242
|
-
# helps center {LLM::
|
|
242
|
+
# helps center {LLM::Console::Buffer LLM::Console::Buffer}.
|
|
243
243
|
# @return [Integer]
|
|
244
244
|
def gutter
|
|
245
245
|
(columns * 0.2).floor
|
|
246
246
|
end
|
|
247
247
|
|
|
248
248
|
##
|
|
249
|
-
# Adds a {LLM::
|
|
250
|
-
# @param [LLM::
|
|
249
|
+
# Adds a {LLM::Console::Node} to the Curses window.
|
|
250
|
+
# @param [LLM::Console::Node] node
|
|
251
251
|
# @param [String] text
|
|
252
252
|
# @return [void]
|
|
253
253
|
def addnode(node, text = node.text)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module LLM
|
|
4
4
|
##
|
|
5
|
-
# The {LLM::
|
|
6
|
-
#
|
|
5
|
+
# The {LLM::Console LLM::Console} class provides a small
|
|
6
|
+
# interactive console around an instance of
|
|
7
7
|
# {LLM::Agent LLM::Agent}.
|
|
8
8
|
#
|
|
9
9
|
# It can be used to keep talking to an agent after it
|
|
@@ -11,21 +11,22 @@ module LLM
|
|
|
11
11
|
# useful when you want to confirm the agent handled the
|
|
12
12
|
# task correctly, or for it to correct course after a
|
|
13
13
|
# mistake was made.
|
|
14
|
-
class
|
|
14
|
+
class Console
|
|
15
15
|
LLM.require "curses", "~> 1.6"
|
|
16
16
|
LLM.require "kramdown", "~> 2.5"
|
|
17
|
+
LLM.require "unicode/display_width", "~> 3.2"
|
|
17
18
|
|
|
18
|
-
require_relative "
|
|
19
|
-
require_relative "
|
|
20
|
-
require_relative "
|
|
21
|
-
require_relative "
|
|
22
|
-
require_relative "
|
|
23
|
-
require_relative "
|
|
24
|
-
require_relative "
|
|
25
|
-
require_relative "
|
|
26
|
-
require_relative "
|
|
27
|
-
require_relative "
|
|
28
|
-
require_relative "
|
|
19
|
+
require_relative "console/color"
|
|
20
|
+
require_relative "console/window"
|
|
21
|
+
require_relative "console/status"
|
|
22
|
+
require_relative "console/buffer"
|
|
23
|
+
require_relative "console/input"
|
|
24
|
+
require_relative "console/bar"
|
|
25
|
+
require_relative "console/stream"
|
|
26
|
+
require_relative "console/node"
|
|
27
|
+
require_relative "console/markdown"
|
|
28
|
+
require_relative "console/command"
|
|
29
|
+
require_relative "console/walker"
|
|
29
30
|
|
|
30
31
|
attr_reader :agent, :provider, :stream,
|
|
31
32
|
:status, :buffer, :input,
|
|
@@ -42,7 +43,7 @@ module LLM
|
|
|
42
43
|
# Zero or more tools
|
|
43
44
|
# @param [Array<String>] skills
|
|
44
45
|
# Zero or more skills
|
|
45
|
-
# @return [LLM::
|
|
46
|
+
# @return [LLM::Console]
|
|
46
47
|
def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
|
|
47
48
|
@path = path
|
|
48
49
|
@name = name || "agent"
|
|
@@ -114,7 +115,7 @@ module LLM
|
|
|
114
115
|
# @param [String] chars
|
|
115
116
|
# @return [Array<Node>]
|
|
116
117
|
def markdown(chars)
|
|
117
|
-
LLM::
|
|
118
|
+
LLM::Console::Markdown.new(chars, buffer.width).ast
|
|
118
119
|
end
|
|
119
120
|
|
|
120
121
|
##
|
|
@@ -216,9 +217,15 @@ module LLM
|
|
|
216
217
|
write_message(sender, markdown(text))
|
|
217
218
|
@thread = Thread.new do
|
|
218
219
|
@queue << [:start]
|
|
219
|
-
agent.talk(text, model:, tools:, stream:)
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
res = agent.talk(text, model:, tools:, stream:)
|
|
221
|
+
@queue << [:done, res.content]
|
|
222
|
+
##
|
|
223
|
+
# When LLM::Interrupt is raised
|
|
224
|
+
# on this thread enqueue the raise
|
|
225
|
+
# so that the file write is protected
|
|
226
|
+
# from an immediate cancel.
|
|
227
|
+
int = {LLM::Interrupt => :never}
|
|
228
|
+
Thread.handle_interrupt(int) { agent.save(path:) if save? }
|
|
222
229
|
rescue LLM::Interrupt => e
|
|
223
230
|
@queue << [:cancel, e]
|
|
224
231
|
rescue => e
|
|
@@ -264,6 +271,7 @@ module LLM
|
|
|
264
271
|
# to by a subclass of {LLM::Stream LLM::Stream}.
|
|
265
272
|
# @api private
|
|
266
273
|
def read!
|
|
274
|
+
burst, max_burst = 0, 4
|
|
267
275
|
loop do
|
|
268
276
|
type, value = @queue.pop(true)
|
|
269
277
|
case type
|
|
@@ -271,12 +279,20 @@ module LLM
|
|
|
271
279
|
buffer.open
|
|
272
280
|
stream.clear
|
|
273
281
|
when :stream
|
|
282
|
+
##
|
|
283
|
+
# A fast model can push many small chunks faster than the
|
|
284
|
+
# UI can repaint. Cap the stream chunks drained per call so
|
|
285
|
+
# read! gives control back to the key loop: leftover chunks
|
|
286
|
+
# stay queued and are drained by the next read!.
|
|
274
287
|
status.text = think_text if stream.tools.empty?
|
|
275
288
|
write_message name, markdown(value), method: :replace
|
|
289
|
+
burst += 1
|
|
290
|
+
break if burst >= max_burst
|
|
276
291
|
when :status
|
|
277
292
|
self.status = value
|
|
278
293
|
when :done
|
|
279
294
|
status.text = "idle"
|
|
295
|
+
write_message name, markdown(value), method: :replace
|
|
280
296
|
buffer.close
|
|
281
297
|
@thread = nil
|
|
282
298
|
when :cancel
|
|
@@ -312,4 +328,8 @@ module LLM
|
|
|
312
328
|
File = ::File
|
|
313
329
|
private_constant :File
|
|
314
330
|
end
|
|
331
|
+
|
|
332
|
+
##
|
|
333
|
+
# Alias: LLM::Repl
|
|
334
|
+
Repl = Console
|
|
315
335
|
end
|
|
@@ -40,7 +40,8 @@ class LLM::Context
|
|
|
40
40
|
usage = payload["usage"]
|
|
41
41
|
reasoning_content = payload["reasoning_content"]
|
|
42
42
|
compaction = payload["compaction"]
|
|
43
|
-
|
|
43
|
+
created_at = payload["created_at"]
|
|
44
|
+
extra = {tool_calls:, original_tool_calls:, tools: @params[:tools], usage:, reasoning_content:, compaction:, created_at:}.compact
|
|
44
45
|
content = returns.nil? ? deserialize_content(payload["content"]) : returns
|
|
45
46
|
LLM::Message.new(payload["role"], content, extra)
|
|
46
47
|
end
|
data/lib/llm/context.rb
CHANGED
|
@@ -39,6 +39,15 @@ module LLM
|
|
|
39
39
|
include Serializer
|
|
40
40
|
include Deserializer
|
|
41
41
|
|
|
42
|
+
TRY_ERRORS = [
|
|
43
|
+
"LLM::InsufficientQuotaError",
|
|
44
|
+
"LLM::RateLimitError",
|
|
45
|
+
"Net::ReadTimeout",
|
|
46
|
+
"Net::WriteTimeout",
|
|
47
|
+
"Net::OpenTimeout"
|
|
48
|
+
]
|
|
49
|
+
private_constant :TRY_ERRORS
|
|
50
|
+
|
|
42
51
|
##
|
|
43
52
|
# Returns the set of runtime parameters that
|
|
44
53
|
# configure this context and must never be forwarded
|
|
@@ -476,7 +485,7 @@ module LLM
|
|
|
476
485
|
# Returns the model a Context is actively using
|
|
477
486
|
# @return [String]
|
|
478
487
|
def model
|
|
479
|
-
messages.find(&:assistant?)&.model || @params[:model]
|
|
488
|
+
messages.find(&:assistant?)&.model || @params[:model] || @llm.default_model
|
|
480
489
|
end
|
|
481
490
|
|
|
482
491
|
##
|
|
@@ -559,23 +568,30 @@ module LLM
|
|
|
559
568
|
|
|
560
569
|
##
|
|
561
570
|
##
|
|
562
|
-
# Runs a network call, retrying it
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
#
|
|
566
|
-
#
|
|
567
|
-
#
|
|
571
|
+
# Runs a network call, retrying it when the request is rate limited
|
|
572
|
+
# ({LLM::RateLimitError}) or times out (`Timeout::Error`, which covers
|
|
573
|
+
# `Net::OpenTimeout` and `Net::ReadTimeout`), up to the retry budget.
|
|
574
|
+
# Each retry notifies the stream and sleeps a growing interval
|
|
575
|
+
# (2s, 4s, 6s, ...) rather than the server's `retry_after`. A 429 is
|
|
576
|
+
# refused before any content streams, so retrying the same request
|
|
577
|
+
# loses nothing. The bare `retry` below re-runs the method body while
|
|
578
|
+
# `attempts ||= 0` keeps the count across attempts.
|
|
568
579
|
# @api private
|
|
569
580
|
# @return [Object]
|
|
570
581
|
def try
|
|
571
582
|
attempts ||= 0
|
|
572
583
|
yield
|
|
573
|
-
rescue
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
584
|
+
rescue => ex
|
|
585
|
+
case ex.class.to_s
|
|
586
|
+
when *TRY_ERRORS
|
|
587
|
+
raise if attempts >= retry_budget
|
|
588
|
+
attempts += 1
|
|
589
|
+
stream.on_retry(ex, attempts)
|
|
590
|
+
sleep 2.0 * attempts
|
|
591
|
+
retry
|
|
592
|
+
else
|
|
593
|
+
raise(ex)
|
|
594
|
+
end
|
|
579
595
|
end
|
|
580
596
|
|
|
581
597
|
# Executes a turn through the Responses API.
|
data/lib/llm/cost.rb
CHANGED
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
# output, input audio, output audio, input image, cache read, cache write,
|
|
7
7
|
# and reasoning costs separately and can return the total.
|
|
8
8
|
class LLM::Cost
|
|
9
|
+
##
|
|
10
|
+
# Build a zero-valued cost breakdown. Every component
|
|
11
|
+
# is nil (treated as no cost), so the total is 0.
|
|
12
|
+
# @return [LLM::Cost]
|
|
13
|
+
def self.zero
|
|
14
|
+
new
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
##
|
|
10
18
|
# Build a cost breakdown from token usage and model pricing.
|
|
11
19
|
# @param [LLM::Context] ctx
|
|
@@ -13,6 +21,11 @@ class LLM::Cost
|
|
|
13
21
|
# @return [LLM::Cost]
|
|
14
22
|
def self.from(ctx)
|
|
15
23
|
pricing = LLM.registry_for(ctx.llm).cost(model: ctx.model)
|
|
24
|
+
##
|
|
25
|
+
# A model may have no known pricing (eg OpenRouter's
|
|
26
|
+
# `openrouter/auto` auto-router). Fall back to a zero
|
|
27
|
+
# cost rather than crashing on a nil pricing.
|
|
28
|
+
return zero if pricing.nil?
|
|
16
29
|
usage = ctx.usage
|
|
17
30
|
output = usage.output_tokens - usage.reasoning_tokens
|
|
18
31
|
input = usage.input_tokens - usage.cache_read_tokens
|
|
@@ -25,24 +25,43 @@ module LLM::Function::Async
|
|
|
25
25
|
|
|
26
26
|
##
|
|
27
27
|
# Stop the reactor and wait for the thread to finish.
|
|
28
|
+
# @return [nil]
|
|
28
29
|
def stop
|
|
29
30
|
@inbox << :stop
|
|
30
31
|
@thread.join(5)
|
|
31
32
|
@thread.kill if @thread.alive?
|
|
33
|
+
nil
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
private
|
|
35
37
|
|
|
38
|
+
##
|
|
39
|
+
# Run the loop until a `:stop`, then tear down. Stopping
|
|
40
|
+
# cancels running children, running their ensure blocks,
|
|
41
|
+
# so #run returns promptly.
|
|
42
|
+
#
|
|
43
|
+
# Detach the scheduler before this thread exits. Left
|
|
44
|
+
# attached, Ruby calls scheduler_close on thread death,
|
|
45
|
+
# and its cancel path sends a non-Exception cause: through
|
|
46
|
+
# io-event's C #raise (not keyword-aware) into
|
|
47
|
+
# rb_fiber_raise, which on Ruby 4.0 raises TypeError
|
|
48
|
+
# (upstream async/io-event bug, not ours).
|
|
49
|
+
# @return [nil]
|
|
36
50
|
def run
|
|
37
51
|
reactor = ::Async::Reactor.new
|
|
38
52
|
reactor.async do
|
|
39
53
|
loop do
|
|
40
54
|
work = @inbox.pop
|
|
41
|
-
|
|
55
|
+
if work == :stop
|
|
56
|
+
reactor.stop
|
|
57
|
+
break
|
|
58
|
+
end
|
|
42
59
|
reactor.async { work.call }
|
|
43
60
|
end
|
|
44
61
|
end
|
|
45
62
|
reactor.run
|
|
63
|
+
ensure
|
|
64
|
+
::Fiber.set_scheduler(nil)
|
|
46
65
|
end
|
|
47
66
|
end
|
|
48
67
|
end
|
|
@@ -32,13 +32,15 @@ class LLM::Function
|
|
|
32
32
|
@pid = Kernel.fork do
|
|
33
33
|
##
|
|
34
34
|
# The child inherits the parent's terminal. When
|
|
35
|
-
# the runtime runs under a curses REPL,
|
|
36
|
-
# tool writing
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
35
|
+
# the runtime runs under a curses REPL, a forked
|
|
36
|
+
# tool reading or writing the tty would steal the
|
|
37
|
+
# user's input or clobber the parent's display.
|
|
38
|
+
# Point all three standard streams at null so the
|
|
39
|
+
# child keeps off the user's terminal entirely. A
|
|
40
|
+
# tool that genuinely needs the terminal can reopen
|
|
41
|
+
# it via /dev/tty; the tty fd stays available to
|
|
42
|
+
# the child.
|
|
43
|
+
$stdin.reopen(File::NULL)
|
|
42
44
|
$stdout.reopen(File::NULL)
|
|
43
45
|
$stderr.reopen(File::NULL)
|
|
44
46
|
Fork::Job.new(@function, @ch).call
|
|
@@ -83,8 +85,10 @@ class LLM::Function
|
|
|
83
85
|
@tracer&.on_tool_finish(result:, span: @span)
|
|
84
86
|
result
|
|
85
87
|
ensure
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
if @guarded.nil?
|
|
89
|
+
reap
|
|
90
|
+
[@ch.control, @ch.result].each { _1.close unless _1.closed? } if @ch
|
|
91
|
+
end
|
|
88
92
|
end
|
|
89
93
|
alias_method :value, :wait
|
|
90
94
|
|
|
@@ -97,7 +101,7 @@ class LLM::Function
|
|
|
97
101
|
private
|
|
98
102
|
|
|
99
103
|
def reap
|
|
100
|
-
return if @waited
|
|
104
|
+
return if @waited || @guarded || !@pid
|
|
101
105
|
::Process.waitpid(@pid)
|
|
102
106
|
@waited = true
|
|
103
107
|
rescue Errno::ECHILD
|
data/lib/llm/function.rb
CHANGED
|
@@ -273,7 +273,7 @@ class LLM::Function
|
|
|
273
273
|
when :fiber
|
|
274
274
|
Fiber::Task.new(self, options)
|
|
275
275
|
when :fork
|
|
276
|
-
LLM.require "xchan", "~> 0.
|
|
276
|
+
LLM.require "xchan", "~> 0.23" unless defined?(::Chan::UNIXSocket)
|
|
277
277
|
Fork::Task.new(self, options.merge(tracer: @tracer))
|
|
278
278
|
when :ractor
|
|
279
279
|
raise LLM::RactorError, "Ractor concurrency only supports class-based tools" unless Class === @runner
|
data/lib/llm/json_adapter.rb
CHANGED
|
@@ -26,6 +26,44 @@ module LLM
|
|
|
26
26
|
# @return [Exception]
|
|
27
27
|
# Returns the error raised when parsing fails
|
|
28
28
|
def self.parser_error = [StandardError]
|
|
29
|
+
|
|
30
|
+
##
|
|
31
|
+
# JSON must be UTF-8 per spec, so a compliant adapter
|
|
32
|
+
# scrubs the strings it serializes. Walks +obj+ and
|
|
33
|
+
# encodes every string found into a valid UTF-8 string,
|
|
34
|
+
# replacing any invalid bytes. Adapters should call this
|
|
35
|
+
# from their +dump+ before handing the object to the
|
|
36
|
+
# underlying library.
|
|
37
|
+
# @param [Object] obj
|
|
38
|
+
# @return [Object]
|
|
39
|
+
# The object with every string normalized to valid UTF-8
|
|
40
|
+
def self.normalize(obj)
|
|
41
|
+
case obj
|
|
42
|
+
when String then normalize_string(obj)
|
|
43
|
+
when Array then obj.map { normalize(_1) }
|
|
44
|
+
when Hash then obj.map { [_1, normalize(_2)] }.to_h
|
|
45
|
+
when LLM::Object then obj.map { [_1, normalize(_2)] }.to_h
|
|
46
|
+
else obj
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
private_class_method :normalize
|
|
50
|
+
|
|
51
|
+
##
|
|
52
|
+
# Normalizes a single string as a valid UTF-8 string that is
|
|
53
|
+
# compatible with the JSON spec. BINARY-encoded strings are
|
|
54
|
+
# read as UTF-8 and scrubbed when invalid; every other encoding
|
|
55
|
+
# is transcoded to UTF-8, replacing invalid or undefined bytes.
|
|
56
|
+
# @param [String] str
|
|
57
|
+
# @return [String]
|
|
58
|
+
def self.normalize_string(str)
|
|
59
|
+
if str.encoding == Encoding::BINARY
|
|
60
|
+
str = (+str).force_encoding("UTF-8")
|
|
61
|
+
str.valid_encoding? ? str : str.scrub
|
|
62
|
+
else
|
|
63
|
+
str.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
private_class_method :normalize_string
|
|
29
67
|
end
|
|
30
68
|
|
|
31
69
|
##
|
|
@@ -59,32 +97,6 @@ module LLM
|
|
|
59
97
|
require "json" unless defined?(::JSON)
|
|
60
98
|
[::JSON::ParserError]
|
|
61
99
|
end
|
|
62
|
-
|
|
63
|
-
##
|
|
64
|
-
# JSON 3.0 compat
|
|
65
|
-
# Walks `obj` and encodes every string that is
|
|
66
|
-
# found into a UTF-8 compatible string.
|
|
67
|
-
def self.normalize(obj)
|
|
68
|
-
case obj
|
|
69
|
-
when String then normalize_string(obj)
|
|
70
|
-
when Array then obj.map { normalize(_1) }
|
|
71
|
-
when Hash then obj.map { [_1, normalize(_2)] }.to_h
|
|
72
|
-
when LLM::Object then obj.map { [_1, normalize(_2)] }.to_h
|
|
73
|
-
else obj
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
private_class_method :normalize
|
|
77
|
-
|
|
78
|
-
##
|
|
79
|
-
# JSON 3.0 compat
|
|
80
|
-
# Normalizes a string as a UTF-8 encoded string
|
|
81
|
-
# that's compatible with the JSON spec.
|
|
82
|
-
def self.normalize_string(str)
|
|
83
|
-
return str if str.encoding == Encoding::UTF_8
|
|
84
|
-
str = (+str).force_encoding("UTF-8")
|
|
85
|
-
str.valid_encoding? ? str : str.scrub
|
|
86
|
-
end
|
|
87
|
-
private_class_method :normalize_string
|
|
88
100
|
end
|
|
89
101
|
|
|
90
102
|
##
|
|
@@ -95,7 +107,7 @@ module LLM
|
|
|
95
107
|
# @return (see JSONAdapter#dump)
|
|
96
108
|
def self.dump(obj, options = {})
|
|
97
109
|
require "oj" unless defined?(::Oj)
|
|
98
|
-
::Oj.dump(obj, options.merge(mode: :compat))
|
|
110
|
+
::Oj.dump(normalize(obj), options.merge(mode: :compat))
|
|
99
111
|
end
|
|
100
112
|
|
|
101
113
|
##
|
|
@@ -121,7 +133,7 @@ module LLM
|
|
|
121
133
|
# @return (see JSONAdapter#dump)
|
|
122
134
|
def self.dump(obj, ...)
|
|
123
135
|
require "yajl" unless defined?(::Yajl)
|
|
124
|
-
::Yajl::Encoder.encode(obj, ...)
|
|
136
|
+
::Yajl::Encoder.encode(normalize(obj), ...)
|
|
125
137
|
end
|
|
126
138
|
|
|
127
139
|
##
|
data/lib/llm/message.rb
CHANGED
|
@@ -17,6 +17,11 @@ module LLM
|
|
|
17
17
|
# @return [Hash]
|
|
18
18
|
attr_reader :extra
|
|
19
19
|
|
|
20
|
+
##
|
|
21
|
+
# Returns the time the message was created
|
|
22
|
+
# @return [Time]
|
|
23
|
+
attr_reader :created_at
|
|
24
|
+
|
|
20
25
|
##
|
|
21
26
|
# Returns a new message
|
|
22
27
|
# @param [Symbol] role
|
|
@@ -27,6 +32,7 @@ module LLM
|
|
|
27
32
|
@role = role.to_s
|
|
28
33
|
@content = content
|
|
29
34
|
@extra = LLM::Object.from(extra)
|
|
35
|
+
@created_at = extra[:created_at] ? Time.iso8601(extra[:created_at].to_s) : Time.now.utc
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
##
|
|
@@ -37,6 +43,7 @@ module LLM
|
|
|
37
43
|
role:,
|
|
38
44
|
content:,
|
|
39
45
|
reasoning_content:,
|
|
46
|
+
created_at: created_at.utc.iso8601,
|
|
40
47
|
compaction: extra.compaction,
|
|
41
48
|
tools: extra.tool_calls&.map { LLM::Object === _1 ? _1.to_h : _1 },
|
|
42
49
|
usage:,
|
data/lib/llm/provider.rb
CHANGED
|
@@ -17,7 +17,12 @@ class LLM::Provider
|
|
|
17
17
|
# @param [Integer] port
|
|
18
18
|
# The port number
|
|
19
19
|
# @param [Integer] timeout
|
|
20
|
-
# The number of seconds to wait for a response
|
|
20
|
+
# The number of seconds to wait for a response. Also serves as the
|
|
21
|
+
# default read timeout.
|
|
22
|
+
# @param [Integer, nil] read_timeout
|
|
23
|
+
# The number of seconds to wait for a response. Defaults to `timeout`.
|
|
24
|
+
# @param [Integer] connect_timeout
|
|
25
|
+
# The number of seconds to wait for a TCP connection to open.
|
|
21
26
|
# @param [Boolean] ssl
|
|
22
27
|
# Whether to use SSL for the connection
|
|
23
28
|
# @param [String] base_path
|
|
@@ -27,17 +32,27 @@ class LLM::Provider
|
|
|
27
32
|
# Requires the net-http-persistent gem.
|
|
28
33
|
# @param [LLM::Transport, Class, nil] transport
|
|
29
34
|
# Optional override with any {LLM::Transport} instance or subclass.
|
|
30
|
-
def initialize(key:, host:, port: 443, timeout:
|
|
35
|
+
def initialize(key:, host:, port: 443, timeout: 600, read_timeout: nil, connect_timeout: 5, ssl: true, base_path: "", persistent: false, transport: nil)
|
|
31
36
|
@key = key
|
|
32
37
|
@host = host
|
|
33
38
|
@port = port
|
|
34
|
-
@
|
|
39
|
+
@read_timeout = read_timeout || timeout
|
|
40
|
+
@timeout = @read_timeout
|
|
41
|
+
@connect_timeout = connect_timeout
|
|
35
42
|
@ssl = ssl
|
|
36
43
|
@base_path = LLM::Utils.normalize_base_path(base_path)
|
|
37
44
|
@base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
|
|
38
45
|
@headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
|
|
39
|
-
@transport = LLM::Transport::Utils.resolve_transport(host:, port:, timeout:, ssl:, transport:, persistent:)
|
|
40
46
|
@monitor = Monitor.new
|
|
47
|
+
@transport = LLM::Transport::Utils.resolve_transport(
|
|
48
|
+
host:,
|
|
49
|
+
port:,
|
|
50
|
+
timeout: @read_timeout,
|
|
51
|
+
connect_timeout: @connect_timeout,
|
|
52
|
+
ssl:,
|
|
53
|
+
transport:,
|
|
54
|
+
persistent:
|
|
55
|
+
)
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
##
|
|
@@ -251,13 +266,19 @@ class LLM::Provider
|
|
|
251
266
|
# Add one or more headers to all requests
|
|
252
267
|
# @example
|
|
253
268
|
# llm = LLM.openai(key: ENV["KEY"])
|
|
254
|
-
# llm.with(
|
|
255
|
-
# llm.with(
|
|
269
|
+
# llm.with("OpenAI-Organization" => ENV["ORG"])
|
|
270
|
+
# llm.with("OpenAI-Project" => ENV["PROJECT"])
|
|
256
271
|
# @param [Hash<String,String>] headers
|
|
257
272
|
# One or more headers
|
|
273
|
+
# @note
|
|
274
|
+
# For backwards compatibility, headers can be
|
|
275
|
+
# provided via the `headers:` keyword argument,
|
|
276
|
+
# or provided directly as a Hash without the
|
|
277
|
+
# `headers:` key namespace.
|
|
258
278
|
# @return [LLM::Provider]
|
|
259
279
|
# Returns self
|
|
260
|
-
def with(headers
|
|
280
|
+
def with(**headers)
|
|
281
|
+
headers = headers.merge(headers.delete(:headers) || {})
|
|
261
282
|
lock do
|
|
262
283
|
tap { @headers.merge!(headers) }
|
|
263
284
|
end
|
|
@@ -337,7 +358,7 @@ class LLM::Provider
|
|
|
337
358
|
# whenever no scoped override is active.
|
|
338
359
|
# @example
|
|
339
360
|
# llm = LLM.openai(key: ENV["KEY"])
|
|
340
|
-
# llm.tracer = LLM::Tracer
|
|
361
|
+
# llm.tracer = LLM::Tracer.logger(llm, path: "/path/to/log.txt")
|
|
341
362
|
# @param [LLM::Tracer] tracer
|
|
342
363
|
# A tracer
|
|
343
364
|
# @return [void]
|
|
@@ -350,7 +371,7 @@ class LLM::Provider
|
|
|
350
371
|
# This is useful when you want per-request or per-turn tracing without
|
|
351
372
|
# replacing the provider's default tracer.
|
|
352
373
|
# @example
|
|
353
|
-
# llm.with_tracer(LLM::Tracer
|
|
374
|
+
# llm.with_tracer(LLM::Tracer.logger(llm, io: $stdout)) do
|
|
354
375
|
# llm.complete("hello", model: "gpt-5.4-mini")
|
|
355
376
|
# end
|
|
356
377
|
# @param [LLM::Tracer] tracer
|
|
@@ -412,7 +433,7 @@ class LLM::Provider
|
|
|
412
433
|
"#{@base_path}#{suffix}"
|
|
413
434
|
end
|
|
414
435
|
|
|
415
|
-
attr_reader :base_uri, :host, :port, :timeout, :ssl, :transport
|
|
436
|
+
attr_reader :base_uri, :host, :port, :timeout, :read_timeout, :connect_timeout, :ssl, :transport
|
|
416
437
|
|
|
417
438
|
##
|
|
418
439
|
# The headers to include with a request
|
|
@@ -11,7 +11,7 @@ module LLM
|
|
|
11
11
|
#
|
|
12
12
|
# The default host is the pay-as-you-go DashScope
|
|
13
13
|
# international endpoint (`dashscope-intl.aliyuncs.com`). Configure
|
|
14
|
-
# a different host either globally through the `
|
|
14
|
+
# a different host either globally through the `DASHSCOPE_API_HOST`
|
|
15
15
|
# environment variable, or per instance through
|
|
16
16
|
# `LLM.alibaba(host: "token-plan.ap-southeast-1.maas.aliyuncs.com")`
|
|
17
17
|
# (for example Alibaba's Token Plan).
|
|
@@ -159,7 +159,7 @@ module LLM
|
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
def normalize_complete_params(params)
|
|
162
|
-
params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
|
|
162
|
+
params = {role: :user, model: params.delete(:model) || default_model, max_tokens: 1024}.merge!(params)
|
|
163
163
|
tools = resolve_tools(params.delete(:tools))
|
|
164
164
|
params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
|
|
165
165
|
role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
|
|
@@ -51,7 +51,7 @@ class LLM::Bedrock
|
|
|
51
51
|
# @param [String] host
|
|
52
52
|
# @return [LLM::Transport]
|
|
53
53
|
def build_transport(host)
|
|
54
|
-
transport.class.new(host:, port: 443, timeout:, ssl: true)
|
|
54
|
+
transport.class.new(host:, port: 443, timeout:, connect_timeout:, ssl: true)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
##
|
|
@@ -102,7 +102,7 @@ class LLM::Bedrock
|
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
[:timeout, :tracer, :transport].each do |m|
|
|
105
|
+
[:timeout, :connect_timeout, :tracer, :transport].each do |m|
|
|
106
106
|
define_method(m) { @provider.send(m) }
|
|
107
107
|
end
|
|
108
108
|
end
|