llm.rb 11.3.1 → 12.1.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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +354 -2
  3. data/LICENSE +93 -17
  4. data/README.md +226 -616
  5. data/data/anthropic.json +322 -426
  6. data/data/bedrock.json +2634 -1144
  7. data/data/deepinfra.json +1513 -0
  8. data/data/deepseek.json +57 -28
  9. data/data/google.json +411 -771
  10. data/data/openai.json +1104 -771
  11. data/data/xai.json +141 -292
  12. data/data/zai.json +263 -141
  13. data/lib/llm/active_record/acts_as_agent.rb +3 -41
  14. data/lib/llm/active_record/acts_as_llm.rb +18 -0
  15. data/lib/llm/active_record.rb +3 -3
  16. data/lib/llm/agent.rb +25 -1
  17. data/lib/llm/context.rb +17 -5
  18. data/lib/llm/contract/completion.rb +2 -2
  19. data/lib/llm/json_adapter.rb +29 -3
  20. data/lib/llm/provider.rb +3 -3
  21. data/lib/llm/providers/deepinfra/audio.rb +66 -0
  22. data/lib/llm/providers/deepinfra/images.rb +90 -0
  23. data/lib/llm/providers/deepinfra/response_adapter.rb +36 -0
  24. data/lib/llm/providers/deepinfra.rb +100 -0
  25. data/lib/llm/providers/deepseek/images.rb +109 -0
  26. data/lib/llm/providers/deepseek/request_adapter.rb +32 -0
  27. data/lib/llm/providers/deepseek/response_adapter/image.rb +9 -0
  28. data/lib/llm/providers/deepseek/response_adapter.rb +29 -0
  29. data/lib/llm/providers/deepseek.rb +4 -2
  30. data/lib/llm/providers/google/request_adapter.rb +22 -5
  31. data/lib/llm/providers/google.rb +4 -4
  32. data/lib/llm/providers/llamacpp.rb +5 -5
  33. data/lib/llm/providers/openai/audio.rb +6 -2
  34. data/lib/llm/providers/openai/images.rb +9 -50
  35. data/lib/llm/providers/openai/request_adapter/respond.rb +38 -4
  36. data/lib/llm/providers/openai/response_adapter/audio.rb +5 -1
  37. data/lib/llm/providers/openai/response_adapter/completion.rb +1 -1
  38. data/lib/llm/providers/openai/response_adapter/image.rb +0 -4
  39. data/lib/llm/providers/openai/responses.rb +1 -0
  40. data/lib/llm/providers/openai/stream_parser.rb +5 -6
  41. data/lib/llm/providers/openai.rb +2 -2
  42. data/lib/llm/providers/xai/images.rb +49 -26
  43. data/lib/llm/providers/xai.rb +2 -2
  44. data/lib/llm/repl/input.rb +64 -0
  45. data/lib/llm/repl/status.rb +30 -0
  46. data/lib/llm/repl/stream.rb +46 -0
  47. data/lib/llm/repl/transcript.rb +61 -0
  48. data/lib/llm/repl/window.rb +107 -0
  49. data/lib/llm/repl.rb +78 -0
  50. data/lib/llm/response.rb +10 -0
  51. data/lib/llm/schema/leaf.rb +7 -1
  52. data/lib/llm/schema/renderer.rb +121 -0
  53. data/lib/llm/schema.rb +30 -0
  54. data/lib/llm/sequel/agent.rb +2 -43
  55. data/lib/llm/sequel/plugin.rb +25 -7
  56. data/lib/llm/tools/chdir.rb +23 -0
  57. data/lib/llm/tools/git.rb +41 -0
  58. data/lib/llm/tools/mkdir.rb +32 -0
  59. data/lib/llm/tools/pwd.rb +20 -0
  60. data/lib/llm/tools/read_file.rb +40 -0
  61. data/lib/llm/tools/rg.rb +46 -0
  62. data/lib/llm/tools/shell.rb +48 -0
  63. data/lib/llm/tools/swap_text.rb +25 -0
  64. data/lib/llm/tools/write_file.rb +24 -0
  65. data/lib/llm/tools.rb +5 -0
  66. data/lib/llm/tracer/telemetry.rb +4 -6
  67. data/lib/llm/tracer.rb +9 -21
  68. data/lib/llm/transport/execution.rb +16 -1
  69. data/lib/llm/transport/net_http_adapter.rb +1 -1
  70. data/lib/llm/uridata.rb +16 -0
  71. data/lib/llm/version.rb +1 -1
  72. data/lib/llm.rb +9 -0
  73. data/llm.gemspec +5 -18
  74. data/resources/deepdive.md +829 -263
  75. metadata +31 -18
  76. data/lib/llm/tracer/langsmith.rb +0 -144
@@ -4,30 +4,21 @@ class LLM::XAI
4
4
  ##
5
5
  # The {LLM::XAI::Images LLM::XAI::Images} class provides an interface
6
6
  # for [xAI's images API](https://docs.x.ai/docs/guides/image-generations).
7
- # xAI supports multiple response formats: temporary URLs, or binary strings
8
- # encoded in base64. The default is to return base64-encoded image data.
7
+ # xAI returns base64-encoded image data.
9
8
  #
10
- # @example Temporary URLs
9
+ # @example
11
10
  # #!/usr/bin/env ruby
12
11
  # require "llm"
13
- # require "open-uri"
14
- # require "fileutils"
15
12
  #
16
13
  # llm = LLM.xai(key: ENV["KEY"])
17
- # res = llm.images.create prompt: "A dog on a rocket to the moon",
18
- # response_format: "url"
19
- # FileUtils.mv OpenURI.open_uri(res.urls[0]).path,
20
- # "rocket.png"
21
- #
22
- # @example Binary strings
23
- # #!/usr/bin/env ruby
24
- # require "llm"
25
- #
26
- # llm = LLM.xai(key: ENV["KEY"])
27
- # res = llm.images.create prompt: "A dog on a rocket to the moon",
28
- # response_format: "b64_json"
14
+ # res = llm.images.create prompt: "A dog on a rocket to the moon"
29
15
  # IO.copy_stream res.images[0], "rocket.png"
30
16
  class Images < LLM::OpenAI::Images
17
+ ##
18
+ # @api private
19
+ PATTERN = %r{\A(?:https?://|data:)}
20
+ private_constant :PATTERN
21
+
31
22
  ##
32
23
  # Create an image
33
24
  # @example
@@ -40,20 +31,52 @@ class LLM::XAI
40
31
  # @param [Hash] params Other parameters (see xAI docs)
41
32
  # @raise (see LLM::Provider#request)
42
33
  # @return [LLM::Response]
43
- def create(prompt:, model: "grok-imagine-image", **params)
44
- super
34
+ def create(prompt:, model: "grok-imagine-image-quality", **params)
35
+ req = LLM::Transport::Request.post(path("/images/generations"), headers)
36
+ req.body = LLM.json.dump({prompt:, n: 1, model:, response_format: "b64_json"}.merge!(params))
37
+ res, span, tracer = execute(request: req, operation: "request")
38
+ res = LLM::OpenAI::ResponseAdapter.adapt(res, type: :image)
39
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
40
+ res
45
41
  end
46
42
 
47
43
  ##
48
- # @raise [NotImplementedError]
49
- def edit(model: "grok-imagine-image", **)
50
- raise NotImplementedError
44
+ # Edit an image
45
+ # @example
46
+ # llm = LLM.xai(key: ENV["KEY"])
47
+ # res = llm.images.edit(image: "/images/book.png", prompt: "The book is floating in the clouds")
48
+ # IO.copy_stream res.images[0], "floating-book.png"
49
+ # @see https://docs.x.ai/docs/guides/image-generations xAI docs
50
+ # @param [String, LLM::File, File] image The image to edit
51
+ # @param [String] prompt The prompt
52
+ # @param [String] model The model to use
53
+ # @param [Hash] params Other parameters (see xAI docs)
54
+ # @raise (see LLM::Provider#request)
55
+ # @return [LLM::Response]
56
+ def edit(image:, prompt:, model: "grok-imagine-image-quality", **params)
57
+ req = LLM::Transport::Request.post(path("/images/edits"), headers)
58
+ req.body = LLM.json.dump({
59
+ prompt:,
60
+ model:,
61
+ image: image_url(image),
62
+ response_format: "b64_json"
63
+ }.merge!(params))
64
+ res, span, tracer = execute(request: req, operation: "request")
65
+ res = LLM::OpenAI::ResponseAdapter.adapt(res, type: :image)
66
+ tracer.on_request_finish(operation: "request", model:, res:, span:)
67
+ res
51
68
  end
52
69
 
53
- ##
54
- # @raise [NotImplementedError]
55
- def create_variation(model: "grok-imagine-image", **)
56
- raise NotImplementedError
70
+ private
71
+
72
+ def image_url(image)
73
+ case image
74
+ when String
75
+ url = image.match?(PATTERN) ? image : LLM.File(image).to_data_uri
76
+ else
77
+ url = LLM.File(image).to_data_uri
78
+ end
79
+ {url:, type: "image_url"}
57
80
  end
58
81
  end
59
82
  end
@@ -70,10 +70,10 @@ module LLM
70
70
 
71
71
  ##
72
72
  # Returns the default model for chat completions
73
- # #see https://docs.x.ai/docs/models grok-4-0709
73
+ # #see https://docs.x.ai/docs/models grok-4.3
74
74
  # @return [String]
75
75
  def default_model
76
- "grok-4-0709"
76
+ "grok-4.3"
77
77
  end
78
78
  end
79
79
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Input LLM::Repl::Input} class manages
6
+ # the editable input line shown at the bottom of the REPL.
7
+ # @api private
8
+ class Input
9
+ UP = Curses::Key::UP
10
+ DOWN = Curses::Key::DOWN
11
+ ENTER = [Curses::Key::ENTER, 10, 13]
12
+ BACKSPACE = [Curses::Key::BACKSPACE, 127]
13
+ EOF = [nil, 4]
14
+
15
+ ##
16
+ # @param [String, Symbol] provider
17
+ # @return [LLM::Repl::Input]
18
+ def initialize(provider)
19
+ @provider = provider
20
+ @buffer = +""
21
+ end
22
+
23
+ ##
24
+ # @param [LLM::Repl::Window] window
25
+ # @return [String, nil]
26
+ def readline(window)
27
+ catch(:done) do
28
+ @buffer.clear
29
+ loop do
30
+ on_char(window, window.getch)
31
+ window.redraw
32
+ end
33
+ end
34
+ end
35
+
36
+ ##
37
+ # @return [String]
38
+ def to_s
39
+ "> #{@buffer}"
40
+ end
41
+
42
+ private
43
+
44
+ def on_char(window, char)
45
+ if EOF.include?(char)
46
+ throw(:done, nil)
47
+ elsif BACKSPACE.include?(char)
48
+ @buffer.chop!
49
+ elsif ENTER.include?(char)
50
+ buf = @buffer.dup
51
+ @buffer.clear
52
+ throw(:done, buf)
53
+ elsif char == UP
54
+ window.scroll_up
55
+ elsif char == DOWN
56
+ window.scroll_down
57
+ elsif String === char
58
+ @buffer << char
59
+ else
60
+ # ???
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Status LLM::Repl::Status} class stores
6
+ # the small status line shown at the top of the REPL.
7
+ # @api private
8
+ class Status
9
+ ##
10
+ # @param [String, Symbol] provider
11
+ # @return [LLM::Repl::Status]
12
+ def initialize(provider)
13
+ @provider = provider
14
+ @text = "idle"
15
+ end
16
+
17
+ ##
18
+ # @return [String]
19
+ attr_reader :provider
20
+
21
+ ##
22
+ # @param [String] value
23
+ # @return [void]
24
+ attr_accessor :text
25
+
26
+ ##
27
+ # @return [String]
28
+ alias_method :to_s, :text
29
+ end
30
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Stream LLM::Repl::Stream} class manages
6
+ # the stream for the {LLM::Repl LLM::Repl} class. This class
7
+ # has defined hooks that receive text, tool calls, and
8
+ # tool returns.
9
+ # @api private
10
+ class Stream < LLM::Stream
11
+ ##
12
+ # @param [LLM::Repl] repl
13
+ # @return [LLM::Repl::Stream]
14
+ def initialize(repl)
15
+ @repl = repl
16
+ end
17
+
18
+ ##
19
+ # @param [String] chars
20
+ # One or more chars
21
+ # @return [void]
22
+ def on_content(chars)
23
+ @repl.write(chars)
24
+ end
25
+
26
+ ##
27
+ # @param [LLM::Function] tool
28
+ # @param [LLM::Function::Return, nil] error
29
+ # @return [void]
30
+ def on_tool_call(tool, error)
31
+ if error
32
+ @repl.status = "tool error: #{tool.name}"
33
+ else
34
+ @repl.status = "tool: #{tool.name}"
35
+ end
36
+ end
37
+
38
+ ##
39
+ # @param [LLM::Function] _tool
40
+ # @param [LLM::Function::Return] result
41
+ # @return [void]
42
+ def on_tool_return(_tool, result)
43
+ @repl.status = "tool done: #{result.name}"
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Transcript LLM::Repl::Transcript} class
6
+ # stores streamed output for the REPL.
7
+ # @api private
8
+ class Transcript
9
+ WIDTH = 80
10
+
11
+ ##
12
+ # @return [LLM::Repl::Transcript]
13
+ def initialize
14
+ @lines = [+""]
15
+ @offset = 0
16
+ end
17
+
18
+ ##
19
+ # @param [String] chars
20
+ # @return [void]
21
+ def write(chars)
22
+ chars.each_char { write_char(_1) }
23
+ end
24
+
25
+ ##
26
+ # @return [void]
27
+ def scroll_up(height)
28
+ max = [@lines.size - height, 0].max
29
+ @offset = [@offset + 1, max].min
30
+ end
31
+
32
+ ##
33
+ # @return [void]
34
+ def scroll_down
35
+ @offset = [@offset - 1, 0].max
36
+ end
37
+
38
+ ##
39
+ # @param [Integer] height
40
+ # @return [Array<String>]
41
+ def visible(height)
42
+ last = @lines.size - 1 - @offset
43
+ first = [last - height + 1, 0].max
44
+ @lines[first..last] || []
45
+ end
46
+
47
+ private
48
+
49
+ def write_char(char)
50
+ if char == "\n"
51
+ @offset += 1 if @offset > 0
52
+ @lines << +""
53
+ elsif char == " " and @lines.last.length >= WIDTH
54
+ @offset += 1 if @offset > 0
55
+ @lines << +""
56
+ else
57
+ @lines.last << char
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # The {LLM::Repl::Window LLM::Repl::Window} class draws the
6
+ # curses screen for the REPL.
7
+ # @api private
8
+ class Window
9
+ ##
10
+ # @return [LLM::Repl::Status]
11
+ attr_reader :status
12
+
13
+ ##
14
+ # @return [LLM::Repl::Transcript]
15
+ attr_reader :transcript
16
+
17
+ ##
18
+ # @return [LLM::Repl::Input]
19
+ attr_reader :input
20
+
21
+ ##
22
+ # @param [LLM::Repl::Status] status
23
+ # @param [LLM::Repl::Transcript] transcript
24
+ # @param [LLM::Repl::Input] input
25
+ # @return [LLM::Repl::Window]
26
+ def initialize(status, transcript, input)
27
+ @status = status
28
+ @transcript = transcript
29
+ @input = input
30
+ end
31
+
32
+ ##
33
+ # @yield
34
+ # @return [void]
35
+ def open
36
+ Curses.init_screen
37
+ Curses.cbreak
38
+ Curses.noecho
39
+ Curses.stdscr.keypad(true)
40
+ yield
41
+ ensure
42
+ Curses.close_screen
43
+ end
44
+
45
+ ##
46
+ # @return [void]
47
+ def redraw
48
+ Curses.clear
49
+ draw_status
50
+ draw_transcript
51
+ draw_input
52
+ Curses.refresh
53
+ end
54
+
55
+ ##
56
+ # @return [Integer]
57
+ def rows
58
+ [Curses.lines - 3, 1].max
59
+ end
60
+
61
+ ##
62
+ # @return [Object]
63
+ def getch
64
+ Curses.getch
65
+ end
66
+
67
+ ##
68
+ # @return [void]
69
+ def scroll_up
70
+ transcript.scroll_up(rows)
71
+ end
72
+
73
+ ##
74
+ # @return [void]
75
+ def scroll_down
76
+ transcript.scroll_down
77
+ end
78
+
79
+ private
80
+
81
+ def draw_status
82
+ Curses.setpos(0, 0)
83
+ Curses.addstr(status.to_s)
84
+ provider = status.provider.to_s
85
+ Curses.setpos(0, [columns - provider.length, 0].max)
86
+ Curses.addstr(provider)
87
+ end
88
+
89
+ def draw_transcript
90
+ visible = transcript.visible(rows)
91
+ visible.each_with_index do |row, index|
92
+ Curses.setpos(index + 2, 0)
93
+ Curses.addstr(row)
94
+ end
95
+ end
96
+
97
+ def draw_input
98
+ Curses.setpos(Curses.lines - 1, 0)
99
+ Curses.clrtoeol
100
+ Curses.addstr(input.to_s)
101
+ end
102
+
103
+ def columns
104
+ Curses.cols
105
+ end
106
+ end
107
+ end
data/lib/llm/repl.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ LLM.require "curses"
4
+
5
+ module LLM
6
+ ##
7
+ # The {LLM::Repl LLM::Repl} class provides a small
8
+ # read-eval-print loop around an instance of
9
+ # {LLM::Agent LLM::Agent}.
10
+ #
11
+ # It can be used to keep talking to an agent after it
12
+ # has been set up or has performed a task. This can be
13
+ # useful when you want to confirm the agent handled the
14
+ # task correctly, or for it to correct course after a
15
+ # mistake was made.
16
+ class Repl
17
+ require_relative "repl/window"
18
+ require_relative "repl/status"
19
+ require_relative "repl/transcript"
20
+ require_relative "repl/input"
21
+ require_relative "repl/stream"
22
+
23
+ ##
24
+ # @param [LLM::Agent] agent
25
+ # @return [LLM::Repl]
26
+ def initialize(agent)
27
+ @agent = agent
28
+ @provider = agent.llm.name
29
+ @status = Status.new(@provider)
30
+ @transcript = Transcript.new
31
+ @input = Input.new(@provider)
32
+ @window = Window.new(@status, @transcript, @input)
33
+ @stream = Stream.new(self)
34
+ end
35
+
36
+ ##
37
+ # @return [void]
38
+ def start
39
+ window.open do
40
+ loop do
41
+ window.redraw
42
+ text = input.readline(window)
43
+ break if text.nil?
44
+ next if text.empty?
45
+ status.text = "thinking"
46
+ write("user: #{text}\n")
47
+ window.redraw
48
+ write("agent: ")
49
+ agent.talk(text, stream:)
50
+ status.text = "idle"
51
+ write("\n\n")
52
+ end
53
+ end
54
+ end
55
+
56
+ ##
57
+ # @param [String] chars
58
+ # @return [void]
59
+ def write(chars)
60
+ transcript.write(chars)
61
+ window.redraw
62
+ end
63
+
64
+ ##
65
+ # @param [String] value
66
+ # @return [void]
67
+ def status=(value)
68
+ status.text = value
69
+ window.redraw
70
+ end
71
+
72
+ private
73
+
74
+ attr_reader :agent, :provider, :stream,
75
+ :status, :transcript, :input,
76
+ :window
77
+ end
78
+ end
data/lib/llm/response.rb CHANGED
@@ -56,6 +56,16 @@ module LLM
56
56
  @res.success?
57
57
  end
58
58
 
59
+ ##
60
+ # Returns the provider response id when present.
61
+ # @return [String, nil]
62
+ def id
63
+ return nil unless LLM::Object === body
64
+ body.id ||
65
+ body.responseId || body.response_id ||
66
+ body.requestId || body.request_id
67
+ end
68
+
59
69
  ##
60
70
  # Returns true if the response is from the Files API
61
71
  # @return [Boolean]
@@ -95,7 +95,7 @@ class LLM::Schema
95
95
  ##
96
96
  # @return [Hash]
97
97
  def to_h
98
- {description: @description, default: @default, enum: @enum}.compact
98
+ {description: @description, default: @default, enum: @enum, const: @const}.compact
99
99
  end
100
100
 
101
101
  ##
@@ -104,6 +104,12 @@ class LLM::Schema
104
104
  to_h.to_json(options)
105
105
  end
106
106
 
107
+ ##
108
+ # @return [String]
109
+ def to_s
110
+ LLM::Schema::Renderer.render(self)
111
+ end
112
+
107
113
  ##
108
114
  # @param [LLM::Schema::Leaf] other
109
115
  # An object to compare
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Schema
4
+ ##
5
+ # Internal renderer for prompt-friendly schema output.
6
+ # @api private
7
+ module Renderer
8
+ extend self
9
+
10
+ ##
11
+ # Render a schema node as a human-readable string.
12
+ # @param [LLM::Schema::Leaf] node
13
+ # The schema node to render
14
+ # @param [Integer] indent
15
+ # The indentation level
16
+ # @param [String, Symbol, nil] name
17
+ # The property name for nested nodes
18
+ # @param [Boolean] root
19
+ # Whether the node is the root schema object
20
+ # @return [String]
21
+ def render(node, indent: 0, name: nil, root: false)
22
+ line = (" " * indent).to_s
23
+ if name
24
+ line << name.to_s
25
+ line << "?" unless node.required?
26
+ line << ": "
27
+ end
28
+ line << type_name(node)
29
+ metadata = metadata_for(node, include_required: !root)
30
+ line << " (#{metadata.join(", ")})" unless metadata.empty?
31
+ line << " - #{node.description}" if node.respond_to?(:description) && node.description
32
+ nested = nested_lines(node, indent: indent + 2)
33
+ ([line] + nested).join("\n")
34
+ end
35
+
36
+ private
37
+
38
+ ##
39
+ # Render nested lines for compound schema nodes.
40
+ # @param [LLM::Schema::Leaf] node
41
+ # The schema node
42
+ # @param [Integer] indent
43
+ # The indentation level
44
+ # @return [Array<String>]
45
+ def nested_lines(node, indent:)
46
+ case node
47
+ when LLM::Schema::Object
48
+ node.properties.map { |key, val| render(val, indent:, name: key) }
49
+ when LLM::Schema::Array
50
+ items = node.to_h[:items]
51
+ items.is_a?(LLM::Schema::Object) ? [render(items, indent:, name: "items")] : []
52
+ else
53
+ []
54
+ end
55
+ end
56
+
57
+ ##
58
+ # Return the printable type name for a schema node.
59
+ # @param [LLM::Schema::Leaf] node
60
+ # The schema node
61
+ # @return [String]
62
+ def type_name(node)
63
+ h = node.to_h
64
+ return "array<#{inline_type(h[:items])}>" if node.is_a?(LLM::Schema::Array)
65
+ return "anyOf<#{inline_types(h[:anyOf])}>" if node.is_a?(LLM::Schema::AnyOf)
66
+ return "oneOf<#{inline_types(h[:oneOf])}>" if node.is_a?(LLM::Schema::OneOf)
67
+ return "allOf<#{inline_types(h[:allOf])}>" if node.is_a?(LLM::Schema::AllOf)
68
+ h[:type] || "unknown"
69
+ end
70
+
71
+ ##
72
+ # Return the inline type description for a nested node.
73
+ # @param [LLM::Schema::Leaf, Object] node
74
+ # The nested schema node
75
+ # @return [String]
76
+ def inline_type(node)
77
+ return type_name(node) if node.is_a?(LLM::Schema::Leaf)
78
+ node.inspect
79
+ end
80
+
81
+ ##
82
+ # Return the inline type description for a list of nodes.
83
+ # @param [Array<LLM::Schema::Leaf>] values
84
+ # The union members
85
+ # @return [String]
86
+ def inline_types(values)
87
+ values.map { inline_type(_1) }.join(", ")
88
+ end
89
+
90
+ ##
91
+ # Extract printable metadata for a schema node.
92
+ # @param [LLM::Schema::Leaf] node
93
+ # The schema node
94
+ # @param [Boolean] include_required
95
+ # Whether to include the required marker
96
+ # @return [Array<String>]
97
+ def metadata_for(node, include_required:)
98
+ h = node.to_h.dup
99
+ details = []
100
+ details << "required" if include_required && node.required?
101
+ details << "default: #{value(node.default)}" if node.default
102
+ details << "enum: #{node.enum.map { value(_1) }.join(" | ")}" if node.enum
103
+ details << "const: #{value(node.const)}" if node.const
104
+ h.except(:type, :description, :default, :enum, :const, :required, :properties, :items, :anyOf, :oneOf, :allOf)
105
+ .each { |key, val| details << "#{key}: #{value(val)}" }
106
+ details
107
+ end
108
+
109
+ ##
110
+ # Convert a scalar value into its printable representation.
111
+ # @param [Object] val
112
+ # The value to render
113
+ # @return [String]
114
+ def value(val)
115
+ case val
116
+ when ::String then val.inspect
117
+ else val.to_s
118
+ end
119
+ end
120
+ end
121
+ end