llm.rb 11.3.1 → 12.0.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +242 -1
  3. data/LICENSE +92 -17
  4. data/README.md +204 -623
  5. data/data/anthropic.json +433 -249
  6. data/data/bedrock.json +2097 -1055
  7. data/data/deepinfra.json +993 -0
  8. data/data/deepseek.json +53 -28
  9. data/data/google.json +389 -771
  10. data/data/openai.json +1053 -771
  11. data/data/xai.json +133 -292
  12. data/data/zai.json +249 -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/context.rb +9 -5
  17. data/lib/llm/contract/completion.rb +2 -2
  18. data/lib/llm/provider.rb +2 -2
  19. data/lib/llm/providers/deepinfra/audio.rb +66 -0
  20. data/lib/llm/providers/deepinfra/images.rb +90 -0
  21. data/lib/llm/providers/deepinfra/response_adapter.rb +36 -0
  22. data/lib/llm/providers/deepinfra.rb +100 -0
  23. data/lib/llm/providers/deepseek/images.rb +109 -0
  24. data/lib/llm/providers/deepseek/request_adapter.rb +32 -0
  25. data/lib/llm/providers/deepseek/response_adapter/image.rb +9 -0
  26. data/lib/llm/providers/deepseek/response_adapter.rb +29 -0
  27. data/lib/llm/providers/deepseek.rb +4 -2
  28. data/lib/llm/providers/google/request_adapter.rb +22 -5
  29. data/lib/llm/providers/google.rb +4 -4
  30. data/lib/llm/providers/openai/audio.rb +6 -2
  31. data/lib/llm/providers/openai/images.rb +9 -50
  32. data/lib/llm/providers/openai/request_adapter/respond.rb +38 -4
  33. data/lib/llm/providers/openai/response_adapter/audio.rb +5 -1
  34. data/lib/llm/providers/openai/response_adapter/completion.rb +1 -1
  35. data/lib/llm/providers/openai/response_adapter/image.rb +0 -4
  36. data/lib/llm/providers/openai/responses.rb +1 -0
  37. data/lib/llm/providers/openai/stream_parser.rb +5 -6
  38. data/lib/llm/providers/openai.rb +2 -2
  39. data/lib/llm/providers/xai/images.rb +49 -26
  40. data/lib/llm/providers/xai.rb +2 -2
  41. data/lib/llm/response.rb +10 -0
  42. data/lib/llm/schema/leaf.rb +7 -1
  43. data/lib/llm/schema/renderer.rb +121 -0
  44. data/lib/llm/schema.rb +30 -0
  45. data/lib/llm/sequel/agent.rb +2 -43
  46. data/lib/llm/sequel/plugin.rb +25 -7
  47. data/lib/llm/tracer/telemetry.rb +4 -6
  48. data/lib/llm/tracer.rb +9 -21
  49. data/lib/llm/transport/execution.rb +16 -1
  50. data/lib/llm/transport/net_http_adapter.rb +1 -1
  51. data/lib/llm/uridata.rb +16 -0
  52. data/lib/llm/version.rb +1 -1
  53. data/lib/llm.rb +9 -0
  54. data/llm.gemspec +5 -18
  55. data/resources/deepdive.md +798 -264
  56. metadata +15 -18
  57. data/lib/llm/tracer/langsmith.rb +0 -144
@@ -53,7 +53,7 @@ module LLM
53
53
  # @param params (see LLM::Provider#embed)
54
54
  # @raise (see LLM::Provider#request)
55
55
  # @return [LLM::Response]
56
- def embed(input, model: "gemini-embedding-001", **params)
56
+ def embed(input, model: "gemini-embedding-2", **params)
57
57
  model = model.respond_to?(:id) ? model.id : model
58
58
  path = ["/v1beta/models/#{model}", "embedContent?key=#{@key}"].join(":")
59
59
  req = LLM::Transport::Request.post(path, headers)
@@ -118,10 +118,10 @@ module LLM
118
118
 
119
119
  ##
120
120
  # Returns the default model for chat completions
121
- # @see https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash gemini-2.5-flash
121
+ # @see https://ai.google.dev/gemini-api/docs/models#gemini-31-flash-lite gemini-3.1-flash-lite
122
122
  # @return [String]
123
123
  def default_model
124
- "gemini-2.5-flash"
124
+ "gemini-3.1-flash-lite"
125
125
  end
126
126
 
127
127
  ##
@@ -196,7 +196,7 @@ module LLM
196
196
  def normalize_complete_params(params)
197
197
  params = {role: :user, model: default_model}.merge!(params)
198
198
  tools = resolve_tools(params.delete(:tools))
199
- params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
199
+ params = [params, adapt_generation_config(params), adapt_tools(tools)].inject({}, &:merge!).compact
200
200
  role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
201
201
  [params, stream, tools, role, model]
202
202
  end
@@ -22,7 +22,7 @@ class LLM::OpenAI
22
22
  # @example
23
23
  # llm = LLM.openai(key: ENV["KEY"])
24
24
  # res = llm.images.create_speech(input: "A dog on a rocket to the moon")
25
- # File.binwrite("rocket.mp3", res.audio.string)
25
+ # IO.copy_stream res.audio.decoded, "rocket.mp3"
26
26
  # @see https://platform.openai.com/docs/api-reference/audio/createSpeech OpenAI docs
27
27
  # @param [String] input The text input
28
28
  # @param [String] voice The voice to use
@@ -36,7 +36,11 @@ class LLM::OpenAI
36
36
  req.body = LLM.json.dump({input:, voice:, model:, response_format:}.merge!(params))
37
37
  io = StringIO.new("".b)
38
38
  res, span, tracer = execute(request: req, operation: "request") { _1.read_body { |chunk| io << chunk } }
39
- res = LLM::Response.new(res).tap { _1.define_singleton_method(:audio) { io } }
39
+ content_type = res["content-type"].to_s.split(";").first
40
+ content_type = content_type.empty? ? LLM::Mime[".#{response_format}"] : content_type
41
+ data = "data:#{content_type};base64,#{[io.string].pack("m0")}"
42
+ res.body = LLM::Object.from(audio: data)
43
+ res = ResponseAdapter.adapt(LLM::Response.new(res), type: :audio)
40
44
  tracer.on_request_finish(operation: "request", model:, res:, span:)
41
45
  res
42
46
  end
@@ -4,28 +4,14 @@ class LLM::OpenAI
4
4
  ##
5
5
  # The {LLM::OpenAI::Images LLM::OpenAI::Images} class provides an interface
6
6
  # for [OpenAI's images API](https://platform.openai.com/docs/api-reference/images).
7
- # OpenAI supports multiple response formats: temporary URLs, or binary strings
8
- # encoded in base64. The default is to return base64-encoded image data.
7
+ # OpenAI's GPT Image models return 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.openai(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.openai(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
31
17
  ##
@@ -45,40 +31,13 @@ class LLM::OpenAI
45
31
  # @see https://platform.openai.com/docs/api-reference/images/create OpenAI docs
46
32
  # @param [String] prompt The prompt
47
33
  # @param [String] model The model to use
48
- # @param [String] response_format The response format ("b64_json" or "url")
34
+ # @param [String] output_format The output format ("png", "webp", or "jpeg")
49
35
  # @param [Hash] params Other parameters (see OpenAI docs)
50
36
  # @raise (see LLM::Provider#request)
51
37
  # @return [LLM::Response]
52
- def create(prompt:, model: "dall-e-3", response_format: "b64_json", **params)
38
+ def create(prompt:, model: "gpt-image-1-mini", output_format: "png", **params)
53
39
  req = LLM::Transport::Request.post(path("/images/generations"), headers)
54
- req.body = LLM.json.dump({prompt:, n: 1, model:, response_format:}.merge!(params))
55
- res, span, tracer = execute(request: req, operation: "request")
56
- res = ResponseAdapter.adapt(res, type: :image)
57
- tracer.on_request_finish(operation: "request", model:, res:, span:)
58
- res
59
- end
60
-
61
- ##
62
- # Create image variations
63
- # @example
64
- # llm = LLM.openai(key: ENV["KEY"])
65
- # res = llm.images.create_variation(image: "/images/hat.png", n: 5)
66
- # res.images.each.with_index do |image, index|
67
- # IO.copy_stream image, "variation#{index}.png"
68
- # end
69
- # @see https://platform.openai.com/docs/api-reference/images/createVariation OpenAI docs
70
- # @param [File] image The image to create variations from
71
- # @param [String] model The model to use
72
- # @param [String] response_format The response format ("b64_json" or "url")
73
- # @param [Hash] params Other parameters (see OpenAI docs)
74
- # @raise (see LLM::Provider#request)
75
- # @return [LLM::Response]
76
- def create_variation(image:, model: "dall-e-2", response_format: "b64_json", **params)
77
- image = LLM.File(image)
78
- multi = LLM::Multipart.new(params.merge!(image:, model:, response_format:))
79
- req = LLM::Transport::Request.post(path("/images/variations"), headers)
80
- req["content-type"] = multi.content_type
81
- transport.set_body_stream(req, multi.body)
40
+ req.body = LLM.json.dump({prompt:, n: 1, model:, output_format:}.merge!(params))
82
41
  res, span, tracer = execute(request: req, operation: "request")
83
42
  res = ResponseAdapter.adapt(res, type: :image)
84
43
  tracer.on_request_finish(operation: "request", model:, res:, span:)
@@ -95,13 +54,13 @@ class LLM::OpenAI
95
54
  # @param [File] image The image to edit
96
55
  # @param [String] prompt The prompt
97
56
  # @param [String] model The model to use
98
- # @param [String] response_format The response format ("b64_json" or "url")
57
+ # @param [String] output_format The output format ("png", "webp", or "jpeg")
99
58
  # @param [Hash] params Other parameters (see OpenAI docs)
100
59
  # @raise (see LLM::Provider#request)
101
60
  # @return [LLM::Response]
102
- def edit(image:, prompt:, model: "dall-e-2", response_format: "b64_json", **params)
61
+ def edit(image:, prompt:, model: "gpt-image-1-mini", output_format: "png", **params)
103
62
  image = LLM.File(image)
104
- multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:, response_format:))
63
+ multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:, output_format:))
105
64
  req = LLM::Transport::Request.post(path("/images/edits"), headers)
106
65
  req["content-type"] = multi.content_type
107
66
  transport.set_body_stream(req, multi.body)
@@ -16,7 +16,7 @@ module LLM::OpenAI::RequestAdapter
16
16
  if Hash === message
17
17
  {role: message[:role], content: adapt_content(message[:content])}
18
18
  elsif message.tool_call?
19
- message.extra[:original_tool_calls]
19
+ adapt_tool_calls(message.extra[:original_tool_calls])
20
20
  else
21
21
  adapt_message
22
22
  end
@@ -33,11 +33,12 @@ module LLM::OpenAI::RequestAdapter
33
33
  when LLM::Message then adapt_content(content.content, role: content.role)
34
34
  when LLM::Object
35
35
  case content.kind
36
- when :image_url then [{type: :image_url, image_url: {url: content.value.to_s}}]
36
+ when :image_url then [{type: :input_image, image_url: content.value.to_s}]
37
37
  when :remote_file then adapt_remote_file(content.value)
38
- when :local_file then prompt_error!(content)
38
+ when :local_file then adapt_local_file(content.value)
39
39
  else prompt_error!(content)
40
40
  end
41
+ when Array then content.flat_map { adapt_content(_1, role:) }
41
42
  else
42
43
  prompt_error!(content)
43
44
  end
@@ -45,6 +46,8 @@ module LLM::OpenAI::RequestAdapter
45
46
 
46
47
  def adapt_message
47
48
  case content
49
+ when LLM::Function::Return
50
+ adapt_returns([content])
48
51
  when Array
49
52
  adapt_array
50
53
  else
@@ -56,12 +59,35 @@ module LLM::OpenAI::RequestAdapter
56
59
  if content.empty?
57
60
  nil
58
61
  elsif returns.any?
59
- returns.map { {type: "function_call_output", call_id: _1.id, output: LLM.json.dump(_1.value)} }
62
+ adapt_returns(returns)
60
63
  else
61
64
  {role: message.role, content: content.flat_map { adapt_content(_1, role: message.role) }}
62
65
  end
63
66
  end
64
67
 
68
+ def adapt_returns(returns)
69
+ returns.map { {type: "function_call_output", call_id: _1.id, output: LLM.json.dump(_1.value)} }
70
+ end
71
+
72
+ def adapt_tool_calls(tools)
73
+ [*tools].map do |tool|
74
+ h = LLM::Object.from(tool.to_h)
75
+ # Backward compatibility for conversations that
76
+ # started under the chat completions API and are
77
+ # later continued through Responses.
78
+ if h.type.to_s == "function"
79
+ {
80
+ type: "function_call",
81
+ call_id: h.id,
82
+ name: h.function.name,
83
+ arguments: h.function.arguments || "{}"
84
+ }
85
+ else
86
+ tool
87
+ end
88
+ end
89
+ end
90
+
65
91
  def adapt_remote_file(content)
66
92
  prompt_error!(content) unless content.file?
67
93
  file = LLM::File(content.filename)
@@ -72,6 +98,14 @@ module LLM::OpenAI::RequestAdapter
72
98
  end
73
99
  end
74
100
 
101
+ def adapt_local_file(file)
102
+ if file.image?
103
+ [{type: :input_image, image_url: file.to_data_uri}]
104
+ else
105
+ [{type: :input_file, filename: file.basename, file_data: file.to_data_uri}]
106
+ end
107
+ end
108
+
75
109
  def prompt_error!(content)
76
110
  if LLM::Object === content
77
111
  raise LLM::PromptError, "The given LLM::Object with kind '#{content.kind}' is not " \
@@ -2,6 +2,10 @@
2
2
 
3
3
  module LLM::OpenAI::ResponseAdapter
4
4
  module Audio
5
- def audio = body.audio
5
+ ##
6
+ # @return [LLM::URIData]
7
+ def audio
8
+ @audio ||= LLM::URIData.parse(super)
9
+ end
6
10
  end
7
11
  end
@@ -5,7 +5,7 @@ module LLM::OpenAI::ResponseAdapter
5
5
  ##
6
6
  # (see LLM::Contract::Completion#messages)
7
7
  def messages
8
- body.choices.map.with_index do |choice, index|
8
+ [*body.choices].map.with_index do |choice, index|
9
9
  message = choice.message
10
10
  extra = {
11
11
  index:, response: self,
@@ -2,10 +2,6 @@
2
2
 
3
3
  module LLM::OpenAI::ResponseAdapter
4
4
  module Image
5
- def urls
6
- data.filter_map { _1["url"] }
7
- end
8
-
9
5
  def images
10
6
  data.filter_map do
11
7
  next unless _1["b64_json"]
@@ -100,6 +100,7 @@ class LLM::OpenAI
100
100
  def adapt_schema(params)
101
101
  return {} unless params && params[:schema]
102
102
  schema = params.delete(:schema)
103
+ schema = schema.respond_to?(:object) ? schema.object : schema
103
104
  schema = schema.to_h.merge(additionalProperties: false)
104
105
  name = "JSONSchema"
105
106
  {text: {format: {type: "json_schema", name:, schema:}}}
@@ -66,26 +66,25 @@ class LLM::OpenAI
66
66
  end
67
67
 
68
68
  def merge_delta!(target_message, delta)
69
- if delta.length == 1
70
- merge_single_delta!(target_message, delta)
71
- elsif content = delta["content"]
69
+ if delta.key?("content") and (content = delta["content"])
72
70
  if target_content = target_message["content"]
73
71
  target_content << content
74
72
  else
75
73
  target_message["content"] = content
76
74
  end
77
75
  emit_content(content)
78
- elsif reasoning = delta["reasoning_content"]
76
+ end
77
+ if delta.key?("reasoning_content") and (reasoning = delta["reasoning_content"])
79
78
  if target_reasoning = target_message["reasoning_content"]
80
79
  target_reasoning << reasoning
81
80
  else
82
81
  target_message["reasoning_content"] = reasoning
83
82
  end
84
83
  emit_reasoning_content(reasoning)
85
- elsif tool_calls = delta["tool_calls"]
84
+ end
85
+ if delta.key?("tool_calls") and (tool_calls = delta["tool_calls"])
86
86
  merge_tools!(target_message, tool_calls)
87
87
  end
88
- return if delta.length <= 1
89
88
  delta.each do |key, value|
90
89
  next if value.nil? || key == "content" || key == "reasoning_content" || key == "tool_calls"
91
90
  target_message[key] = value
@@ -146,10 +146,10 @@ module LLM
146
146
 
147
147
  ##
148
148
  # Returns the default model for chat completions
149
- # @see https://platform.openai.com/docs/models/gpt-4.1 gpt-4.1
149
+ # @see https://platform.openai.com/docs/models/gpt-5.4-mini gpt-5.4-mini
150
150
  # @return [String]
151
151
  def default_model
152
- "gpt-4.1"
152
+ "gpt-5.4-mini"
153
153
  end
154
154
 
155
155
  ##
@@ -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
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
data/lib/llm/schema.rb CHANGED
@@ -34,6 +34,7 @@
34
34
  class LLM::Schema
35
35
  require_relative "schema/version"
36
36
  require_relative "schema/parser"
37
+ require_relative "schema/renderer"
37
38
  require_relative "schema/leaf"
38
39
  require_relative "schema/object"
39
40
  require_relative "schema/array"
@@ -121,6 +122,19 @@ class LLM::Schema
121
122
  end
122
123
  end
123
124
 
125
+ ##
126
+ # @param [Hash] defaults
127
+ # @return [LLM::Schema::Object]
128
+ def self.defaults(defaults)
129
+ lock do
130
+ object.tap do |schema|
131
+ defaults.each do |name, val|
132
+ Utils.fetch(schema.properties, name).default(val)
133
+ end
134
+ end
135
+ end
136
+ end
137
+
124
138
  ##
125
139
  # @api private
126
140
  # @return [LLM::Schema]
@@ -139,6 +153,14 @@ class LLM::Schema
139
153
  end
140
154
  end
141
155
 
156
+ ##
157
+ # Render the schema as a prompt-friendly string.
158
+ # @return [String]
159
+ def self.to_s
160
+ Renderer.render(object, root: true)
161
+ end
162
+ (class << self; self; end).alias_method(:inspect, :to_s)
163
+
142
164
  ##
143
165
  # @api private
144
166
  def self.lock(&)
@@ -220,4 +242,12 @@ class LLM::Schema
220
242
  def null
221
243
  Null.new
222
244
  end
245
+
246
+ ##
247
+ # Render a schema leaf as a prompt-friendly string.
248
+ # @return [String]
249
+ def to_s
250
+ self.class.to_s
251
+ end
252
+ alias_method :inspect, :to_s
223
253
  end