llm.rb 4.7.0 → 4.9.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +335 -587
  3. data/data/anthropic.json +770 -0
  4. data/data/deepseek.json +75 -0
  5. data/data/google.json +1050 -0
  6. data/data/openai.json +1421 -0
  7. data/data/xai.json +792 -0
  8. data/data/zai.json +330 -0
  9. data/lib/llm/agent.rb +42 -41
  10. data/lib/llm/bot.rb +1 -263
  11. data/lib/llm/buffer.rb +7 -0
  12. data/lib/llm/{session → context}/deserializer.rb +4 -3
  13. data/lib/llm/context.rb +292 -0
  14. data/lib/llm/cost.rb +26 -0
  15. data/lib/llm/error.rb +8 -0
  16. data/lib/llm/eventstream/parser.rb +0 -5
  17. data/lib/llm/function/array.rb +61 -0
  18. data/lib/llm/function/fiber_group.rb +91 -0
  19. data/lib/llm/function/task_group.rb +89 -0
  20. data/lib/llm/function/thread_group.rb +94 -0
  21. data/lib/llm/function.rb +75 -10
  22. data/lib/llm/mcp/command.rb +108 -0
  23. data/lib/llm/mcp/error.rb +31 -0
  24. data/lib/llm/mcp/pipe.rb +82 -0
  25. data/lib/llm/mcp/rpc.rb +118 -0
  26. data/lib/llm/mcp/transport/stdio.rb +85 -0
  27. data/lib/llm/mcp.rb +102 -0
  28. data/lib/llm/message.rb +13 -11
  29. data/lib/llm/model.rb +115 -0
  30. data/lib/llm/prompt.rb +17 -7
  31. data/lib/llm/provider.rb +60 -32
  32. data/lib/llm/providers/anthropic/error_handler.rb +1 -1
  33. data/lib/llm/providers/anthropic/files.rb +3 -3
  34. data/lib/llm/providers/anthropic/models.rb +1 -1
  35. data/lib/llm/providers/anthropic/request_adapter.rb +20 -3
  36. data/lib/llm/providers/anthropic/response_adapter/models.rb +13 -0
  37. data/lib/llm/providers/anthropic/response_adapter.rb +2 -0
  38. data/lib/llm/providers/anthropic.rb +21 -5
  39. data/lib/llm/providers/deepseek.rb +10 -3
  40. data/lib/llm/providers/{gemini → google}/audio.rb +6 -6
  41. data/lib/llm/providers/{gemini → google}/error_handler.rb +20 -5
  42. data/lib/llm/providers/{gemini → google}/files.rb +11 -11
  43. data/lib/llm/providers/{gemini → google}/images.rb +7 -7
  44. data/lib/llm/providers/{gemini → google}/models.rb +5 -5
  45. data/lib/llm/providers/{gemini → google}/request_adapter/completion.rb +7 -3
  46. data/lib/llm/providers/{gemini → google}/request_adapter.rb +1 -1
  47. data/lib/llm/providers/{gemini → google}/response_adapter/completion.rb +7 -7
  48. data/lib/llm/providers/{gemini → google}/response_adapter/embedding.rb +1 -1
  49. data/lib/llm/providers/{gemini → google}/response_adapter/file.rb +1 -1
  50. data/lib/llm/providers/{gemini → google}/response_adapter/files.rb +1 -1
  51. data/lib/llm/providers/{gemini → google}/response_adapter/image.rb +1 -1
  52. data/lib/llm/providers/google/response_adapter/models.rb +13 -0
  53. data/lib/llm/providers/{gemini → google}/response_adapter/web_search.rb +2 -2
  54. data/lib/llm/providers/{gemini → google}/response_adapter.rb +8 -8
  55. data/lib/llm/providers/{gemini → google}/stream_parser.rb +3 -3
  56. data/lib/llm/providers/{gemini.rb → google.rb} +41 -26
  57. data/lib/llm/providers/llamacpp.rb +10 -3
  58. data/lib/llm/providers/ollama/error_handler.rb +1 -1
  59. data/lib/llm/providers/ollama/models.rb +1 -1
  60. data/lib/llm/providers/ollama/response_adapter/models.rb +13 -0
  61. data/lib/llm/providers/ollama/response_adapter.rb +2 -0
  62. data/lib/llm/providers/ollama.rb +19 -4
  63. data/lib/llm/providers/openai/error_handler.rb +18 -3
  64. data/lib/llm/providers/openai/files.rb +3 -3
  65. data/lib/llm/providers/openai/images.rb +17 -11
  66. data/lib/llm/providers/openai/models.rb +1 -1
  67. data/lib/llm/providers/openai/response_adapter/completion.rb +9 -1
  68. data/lib/llm/providers/openai/response_adapter/models.rb +13 -0
  69. data/lib/llm/providers/openai/response_adapter/responds.rb +9 -1
  70. data/lib/llm/providers/openai/response_adapter.rb +2 -0
  71. data/lib/llm/providers/openai/responses.rb +16 -1
  72. data/lib/llm/providers/openai/stream_parser.rb +2 -0
  73. data/lib/llm/providers/openai.rb +28 -6
  74. data/lib/llm/providers/xai/images.rb +7 -6
  75. data/lib/llm/providers/xai.rb +10 -3
  76. data/lib/llm/providers/zai.rb +9 -2
  77. data/lib/llm/registry.rb +81 -0
  78. data/lib/llm/schema/enum.rb +16 -0
  79. data/lib/llm/schema/parser.rb +109 -0
  80. data/lib/llm/schema.rb +5 -0
  81. data/lib/llm/server_tool.rb +5 -5
  82. data/lib/llm/session.rb +10 -1
  83. data/lib/llm/tool/param.rb +1 -1
  84. data/lib/llm/tool.rb +86 -5
  85. data/lib/llm/tracer/langsmith.rb +144 -0
  86. data/lib/llm/tracer/logger.rb +9 -1
  87. data/lib/llm/tracer/null.rb +8 -0
  88. data/lib/llm/tracer/telemetry.rb +98 -78
  89. data/lib/llm/tracer.rb +108 -4
  90. data/lib/llm/usage.rb +5 -0
  91. data/lib/llm/version.rb +1 -1
  92. data/lib/llm.rb +40 -6
  93. data/llm.gemspec +45 -8
  94. metadata +87 -28
  95. data/lib/llm/providers/gemini/response_adapter/models.rb +0 -15
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  module Completion
5
5
  ##
6
6
  # (see LLM::Contract::Completion#messages)
@@ -64,17 +64,17 @@ module LLM::Gemini::ResponseAdapter
64
64
  content = choice.content || LLM::Object.new
65
65
  role = content.role || "model"
66
66
  parts = content.parts || [{"text" => choice.finishReason}]
67
- text = parts.filter_map { _1["text"] }.join
68
- tools = parts.filter_map { _1["functionCall"] }
67
+ text = parts.filter_map { _1["text"] }.join
68
+ tools = parts.select { _1["functionCall"] }
69
69
  extra = {index:, response: self, tool_calls: adapt_tool_calls(tools), original_tool_calls: tools}
70
70
  LLM::Message.new(role, text, extra)
71
71
  end
72
72
  end
73
73
 
74
- def adapt_tool_calls(tools)
75
- (tools || []).map do |tool|
76
- function = {name: tool.name, arguments: tool.args}
77
- function
74
+ def adapt_tool_calls(parts)
75
+ (parts || []).map do |part|
76
+ tool = part["functionCall"]
77
+ {name: tool.name, arguments: tool.args}
78
78
  end
79
79
  end
80
80
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  module Embedding
5
5
  def model = "text-embedding-004"
6
6
  def embeddings = body.dig("embedding", "values")
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  module File
5
5
  def name = respond_to?(:file) ? file.name : body.name
6
6
  def display_name = respond_to?(:file) ? file.displayName : body.displayName
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  module Files
5
5
  include ::Enumerable
6
6
  def each(&)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  module Image
5
5
  ##
6
6
  # @return [Array<StringIO>]
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Google::ResponseAdapter
4
+ module Models
5
+ include LLM::Model::Collection
6
+
7
+ private
8
+
9
+ def raw_models
10
+ body.models || []
11
+ end
12
+ end
13
+ end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module LLM::Gemini::ResponseAdapter
3
+ module LLM::Google::ResponseAdapter
4
4
  ##
5
- # The {LLM::Gemini::ResponseAdapter::WebSearch LLM::Gemini::ResponseAdapter::WebSearch}
5
+ # The {LLM::Google::ResponseAdapter::WebSearch LLM::Google::ResponseAdapter::WebSearch}
6
6
  # module provides methods for accessing web search results from a web search
7
7
  # tool call made via the {LLM::Provider#web_search LLM::Provider#web_search}
8
8
  # method.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Gemini
3
+ class LLM::Google
4
4
  ##
5
5
  # @private
6
6
  module ResponseAdapter
@@ -27,13 +27,13 @@ class LLM::Gemini
27
27
  # @api private
28
28
  def select(type)
29
29
  case type
30
- when :completion then LLM::Gemini::ResponseAdapter::Completion
31
- when :embedding then LLM::Gemini::ResponseAdapter::Embedding
32
- when :file then LLM::Gemini::ResponseAdapter::File
33
- when :files then LLM::Gemini::ResponseAdapter::Files
34
- when :image then LLM::Gemini::ResponseAdapter::Image
35
- when :models then LLM::Gemini::ResponseAdapter::Models
36
- when :web_search then LLM::Gemini::ResponseAdapter::WebSearch
30
+ when :completion then LLM::Google::ResponseAdapter::Completion
31
+ when :embedding then LLM::Google::ResponseAdapter::Embedding
32
+ when :file then LLM::Google::ResponseAdapter::File
33
+ when :files then LLM::Google::ResponseAdapter::Files
34
+ when :image then LLM::Google::ResponseAdapter::Image
35
+ when :models then LLM::Google::ResponseAdapter::Models
36
+ when :web_search then LLM::Google::ResponseAdapter::WebSearch
37
37
  else
38
38
  raise ArgumentError, "Unknown response adapter type: #{type.inspect}"
39
39
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class LLM::Gemini
3
+ class LLM::Google
4
4
  ##
5
5
  # @private
6
6
  class StreamParser
@@ -11,7 +11,7 @@ class LLM::Gemini
11
11
 
12
12
  ##
13
13
  # @param [#<<] io An IO-like object
14
- # @return [LLM::Gemini::StreamParser]
14
+ # @return [LLM::Google::StreamParser]
15
15
  def initialize(io)
16
16
  @body = {"candidates" => []}
17
17
  @io = io
@@ -19,7 +19,7 @@ class LLM::Gemini
19
19
 
20
20
  ##
21
21
  # @param [Hash] chunk
22
- # @return [LLM::Gemini::StreamParser]
22
+ # @return [LLM::Google::StreamParser]
23
23
  def parse!(chunk)
24
24
  tap { merge_chunk!(chunk) }
25
25
  end
@@ -2,8 +2,8 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # The Gemini class implements a provider for
6
- # [Gemini](https://ai.google.dev/). The Gemini provider
5
+ # The Google class implements a provider for
6
+ # [Gemini](https://ai.google.dev/). The Google provider
7
7
  # can accept multiple inputs (text, images, audio, and video).
8
8
  # The inputs can be provided inline via the prompt for files
9
9
  # under 20MB or via the Gemini Files API for files
@@ -13,19 +13,19 @@ module LLM
13
13
  # #!/usr/bin/env ruby
14
14
  # require "llm"
15
15
  #
16
- # llm = LLM.gemini(key: ENV["KEY"])
17
- # ses = LLM::Session.new(llm)
18
- # ses.talk ["Tell me about this photo", ses.local_file("/images/photo.png")]
19
- # ses.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
20
- class Gemini < Provider
21
- require_relative "gemini/error_handler"
22
- require_relative "gemini/request_adapter"
23
- require_relative "gemini/response_adapter"
24
- require_relative "gemini/stream_parser"
25
- require_relative "gemini/models"
26
- require_relative "gemini/images"
27
- require_relative "gemini/audio"
28
- require_relative "gemini/files"
16
+ # llm = LLM.google(key: ENV["KEY"])
17
+ # ctx = LLM::Context.new(llm)
18
+ # ctx.talk ["Tell me about this photo", ctx.local_file("/images/photo.png")]
19
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
20
+ class Google < Provider
21
+ require_relative "google/error_handler"
22
+ require_relative "google/request_adapter"
23
+ require_relative "google/response_adapter"
24
+ require_relative "google/stream_parser"
25
+ require_relative "google/models"
26
+ require_relative "google/images"
27
+ require_relative "google/audio"
28
+ require_relative "google/files"
29
29
 
30
30
  include RequestAdapter
31
31
 
@@ -37,6 +37,13 @@ module LLM
37
37
  super(host: HOST, **)
38
38
  end
39
39
 
40
+ ##
41
+ # @return [Symbol]
42
+ # Returns the provider's name
43
+ def name
44
+ :google
45
+ end
46
+
40
47
  ##
41
48
  # Provides an embedding
42
49
  # @param input (see LLM::Provider#embed)
@@ -78,33 +85,33 @@ module LLM
78
85
  ##
79
86
  # Provides an interface to Gemini's audio API
80
87
  # @see https://ai.google.dev/gemini-api/docs/audio Gemini docs
81
- # @return [LLM::Gemini::Audio]
88
+ # @return [LLM::Google::Audio]
82
89
  def audio
83
- LLM::Gemini::Audio.new(self)
90
+ LLM::Google::Audio.new(self)
84
91
  end
85
92
 
86
93
  ##
87
94
  # Provides an interface to Gemini's image generation API
88
95
  # @see https://ai.google.dev/gemini-api/docs/image-generation Gemini docs
89
- # @return [see LLM::Gemini::Images]
96
+ # @return [see LLM::Google::Images]
90
97
  def images
91
- LLM::Gemini::Images.new(self)
98
+ LLM::Google::Images.new(self)
92
99
  end
93
100
 
94
101
  ##
95
102
  # Provides an interface to Gemini's file management API
96
103
  # @see https://ai.google.dev/gemini-api/docs/files Gemini docs
97
- # @return [LLM::Gemini::Files]
104
+ # @return [LLM::Google::Files]
98
105
  def files
99
- LLM::Gemini::Files.new(self)
106
+ LLM::Google::Files.new(self)
100
107
  end
101
108
 
102
109
  ##
103
110
  # Provides an interface to Gemini's models API
104
111
  # @see https://ai.google.dev/gemini-api/docs/models Gemini docs
105
- # @return [LLM::Gemini::Models]
112
+ # @return [LLM::Google::Models]
106
113
  def models
107
- LLM::Gemini::Models.new(self)
114
+ LLM::Google::Models.new(self)
108
115
  end
109
116
 
110
117
  ##
@@ -177,11 +184,11 @@ module LLM
177
184
  end
178
185
 
179
186
  def stream_parser
180
- LLM::Gemini::StreamParser
187
+ LLM::Google::StreamParser
181
188
  end
182
189
 
183
190
  def error_handler
184
- LLM::Gemini::ErrorHandler
191
+ LLM::Google::ErrorHandler
185
192
  end
186
193
 
187
194
  def normalize_complete_params(params)
@@ -197,10 +204,18 @@ module LLM
197
204
  model.respond_to?(:id) ? model.id : model
198
205
  path = ["/v1beta/models/#{model}", action].join(":")
199
206
  req = Net::HTTP::Post.new(path, headers)
200
- messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
207
+ messages = build_complete_messages(prompt, params, role)
201
208
  body = LLM.json.dump({contents: adapt(messages)}.merge!(params))
202
209
  set_body_stream(req, StringIO.new(body))
203
210
  req
204
211
  end
212
+
213
+ def build_complete_messages(prompt, params, role)
214
+ if LLM::Prompt === prompt
215
+ [*(params.delete(:messages) || []), *prompt.to_a]
216
+ else
217
+ [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
218
+ end
219
+ end
205
220
  end
206
221
  end
@@ -16,9 +16,9 @@ module LLM
16
16
  # require "llm"
17
17
  #
18
18
  # llm = LLM.llamacpp(key: nil)
19
- # ses = LLM::Session.new(llm)
20
- # ses.talk ["Tell me about this photo", ses.local_file("/images/photo.png")]
21
- # ses.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
19
+ # ctx = LLM::Context.new(llm)
20
+ # ctx.talk ["Tell me about this photo", ctx.local_file("/images/photo.png")]
21
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
22
22
  class LlamaCpp < OpenAI
23
23
  ##
24
24
  # @param (see LLM::Provider#initialize)
@@ -27,6 +27,13 @@ module LLM
27
27
  super
28
28
  end
29
29
 
30
+ ##
31
+ # @return [Symbol]
32
+ # Returns the provider's name
33
+ def name
34
+ :llamacpp
35
+ end
36
+
30
37
  ##
31
38
  # @raise [NotImplementedError]
32
39
  def files
@@ -35,7 +35,7 @@ class LLM::Ollama
35
35
  ex = error
36
36
  @tracer.on_request_error(ex:, span:)
37
37
  ensure
38
- raise(ex)
38
+ raise(ex) if ex
39
39
  end
40
40
 
41
41
  private
@@ -44,7 +44,7 @@ class LLM::Ollama
44
44
  query = URI.encode_www_form(params)
45
45
  req = Net::HTTP::Get.new("/api/tags?#{query}", headers)
46
46
  res, span, tracer = execute(request: req, operation: "request")
47
- res = LLM::Response.new(res)
47
+ res = ResponseAdapter.adapt(res, type: :models)
48
48
  tracer.on_request_finish(operation: "request", res:, span:)
49
49
  res
50
50
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Ollama::ResponseAdapter
4
+ module Models
5
+ include LLM::Model::Collection
6
+
7
+ private
8
+
9
+ def raw_models
10
+ body.models || []
11
+ end
12
+ end
13
+ end
@@ -6,6 +6,7 @@ class LLM::Ollama
6
6
  module ResponseAdapter
7
7
  require_relative "response_adapter/completion"
8
8
  require_relative "response_adapter/embedding"
9
+ require_relative "response_adapter/models"
9
10
 
10
11
  module_function
11
12
 
@@ -24,6 +25,7 @@ class LLM::Ollama
24
25
  case type
25
26
  when :completion then LLM::Ollama::ResponseAdapter::Completion
26
27
  when :embedding then LLM::Ollama::ResponseAdapter::Embedding
28
+ when :models then LLM::Ollama::ResponseAdapter::Models
27
29
  else
28
30
  raise ArgumentError, "Unknown response adapter type: #{type.inspect}"
29
31
  end
@@ -12,9 +12,9 @@ module LLM
12
12
  # require "llm"
13
13
  #
14
14
  # llm = LLM.ollama(key: nil)
15
- # ses = LLM::Session.new(llm, model: "llava")
16
- # ses.talk ["Tell me about this image", ses.local_file("/images/photo.png")]
17
- # ses.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
15
+ # ctx = LLM::Context.new(llm, model: "llava")
16
+ # ctx.talk ["Tell me about this image", ctx.local_file("/images/photo.png")]
17
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
18
18
  class Ollama < Provider
19
19
  require_relative "ollama/error_handler"
20
20
  require_relative "ollama/request_adapter"
@@ -32,6 +32,13 @@ module LLM
32
32
  super(host: HOST, port: 11434, ssl: false, **)
33
33
  end
34
34
 
35
+ ##
36
+ # @return [Symbol]
37
+ # Returns the provider's name
38
+ def name
39
+ :ollama
40
+ end
41
+
35
42
  ##
36
43
  # Provides an embedding
37
44
  # @param input (see LLM::Provider#embed)
@@ -120,11 +127,19 @@ module LLM
120
127
  end
121
128
 
122
129
  def build_complete_request(prompt, params, role)
123
- messages = [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
130
+ messages = build_complete_messages(prompt, params, role)
124
131
  body = LLM.json.dump({messages: [adapt(messages)].flatten}.merge!(params))
125
132
  req = Net::HTTP::Post.new("/api/chat", headers)
126
133
  set_body_stream(req, StringIO.new(body))
127
134
  req
128
135
  end
136
+
137
+ def build_complete_messages(prompt, params, role)
138
+ if LLM::Prompt === prompt
139
+ [*(params.delete(:messages) || []), *prompt.to_a]
140
+ else
141
+ [*(params.delete(:messages) || []), LLM::Message.new(role, prompt)]
142
+ end
143
+ end
129
144
  end
130
145
  end
@@ -35,15 +35,15 @@ class LLM::OpenAI
35
35
  ex = error
36
36
  @tracer.on_request_error(ex:, span:)
37
37
  ensure
38
- raise(ex)
38
+ raise(ex) if ex
39
39
  end
40
40
 
41
41
  private
42
42
 
43
43
  ##
44
- # @return [LLM::Object]
44
+ # @return [String, LLM::Object]
45
45
  def body
46
- @body ||= LLM.json.load(res.body)
46
+ @body ||= parse_body!
47
47
  end
48
48
 
49
49
  ##
@@ -79,5 +79,20 @@ class LLM::OpenAI
79
79
  LLM::InvalidRequestError.new(error["message"]).tap { _1.response = res }
80
80
  end
81
81
  end
82
+
83
+ ##
84
+ # Tries to parse the response body as a LLM::Object
85
+ # @return [String, LLM::Object]
86
+ def parse_body!
87
+ if String === res.body
88
+ LLM::Object.from LLM.json.load(res.body)
89
+ elsif Hash === res.body
90
+ LLM::Object.from(res.body)
91
+ else
92
+ res.body
93
+ end
94
+ rescue
95
+ res.body
96
+ end
82
97
  end
83
98
  end
@@ -13,10 +13,10 @@ class LLM::OpenAI
13
13
  # require "llm"
14
14
  #
15
15
  # llm = LLM.openai(key: ENV["KEY"])
16
- # ses = LLM::Session.new(llm)
16
+ # ctx = LLM::Context.new(llm)
17
17
  # file = llm.files.create file: "/books/goodread.pdf"
18
- # ses.talk ["Tell me about this PDF", file]
19
- # ses.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
18
+ # ctx.talk ["Tell me about this PDF", file]
19
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
20
20
  class Files
21
21
  ##
22
22
  # Returns a new Files object
@@ -5,7 +5,7 @@ class LLM::OpenAI
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
7
  # OpenAI supports multiple response formats: temporary URLs, or binary strings
8
- # encoded in base64. The default is to return temporary URLs.
8
+ # encoded in base64. The default is to return base64-encoded image data.
9
9
  #
10
10
  # @example Temporary URLs
11
11
  # #!/usr/bin/env ruby
@@ -14,7 +14,8 @@ class LLM::OpenAI
14
14
  # require "fileutils"
15
15
  #
16
16
  # llm = LLM.openai(key: ENV["KEY"])
17
- # res = llm.images.create prompt: "A dog on a rocket to the moon"
17
+ # res = llm.images.create prompt: "A dog on a rocket to the moon",
18
+ # response_format: "url"
18
19
  # FileUtils.mv OpenURI.open_uri(res.urls[0]).path,
19
20
  # "rocket.png"
20
21
  #
@@ -40,16 +41,17 @@ class LLM::OpenAI
40
41
  # @example
41
42
  # llm = LLM.openai(key: ENV["KEY"])
42
43
  # res = llm.images.create prompt: "A dog on a rocket to the moon"
43
- # res.urls.each { print _1, "\n" }
44
+ # IO.copy_stream res.images[0], "rocket.png"
44
45
  # @see https://platform.openai.com/docs/api-reference/images/create OpenAI docs
45
46
  # @param [String] prompt The prompt
46
47
  # @param [String] model The model to use
48
+ # @param [String] response_format The response format ("b64_json" or "url")
47
49
  # @param [Hash] params Other parameters (see OpenAI docs)
48
50
  # @raise (see LLM::Provider#request)
49
51
  # @return [LLM::Response]
50
- def create(prompt:, model: "dall-e-3", **params)
52
+ def create(prompt:, model: "dall-e-3", response_format: "b64_json", **params)
51
53
  req = Net::HTTP::Post.new("/v1/images/generations", headers)
52
- req.body = LLM.json.dump({prompt:, n: 1, model:}.merge!(params))
54
+ req.body = LLM.json.dump({prompt:, n: 1, model:, response_format:}.merge!(params))
53
55
  res, span, tracer = execute(request: req, operation: "request")
54
56
  res = ResponseAdapter.adapt(res, type: :image)
55
57
  tracer.on_request_finish(operation: "request", model:, res:, span:)
@@ -61,16 +63,19 @@ class LLM::OpenAI
61
63
  # @example
62
64
  # llm = LLM.openai(key: ENV["KEY"])
63
65
  # res = llm.images.create_variation(image: "/images/hat.png", n: 5)
64
- # p res.urls
66
+ # res.images.each.with_index do |image, index|
67
+ # IO.copy_stream image, "variation#{index}.png"
68
+ # end
65
69
  # @see https://platform.openai.com/docs/api-reference/images/createVariation OpenAI docs
66
70
  # @param [File] image The image to create variations from
67
71
  # @param [String] model The model to use
72
+ # @param [String] response_format The response format ("b64_json" or "url")
68
73
  # @param [Hash] params Other parameters (see OpenAI docs)
69
74
  # @raise (see LLM::Provider#request)
70
75
  # @return [LLM::Response]
71
- def create_variation(image:, model: "dall-e-2", **params)
76
+ def create_variation(image:, model: "dall-e-2", response_format: "b64_json", **params)
72
77
  image = LLM.File(image)
73
- multi = LLM::Multipart.new(params.merge!(image:, model:))
78
+ multi = LLM::Multipart.new(params.merge!(image:, model:, response_format:))
74
79
  req = Net::HTTP::Post.new("/v1/images/variations", headers)
75
80
  req["content-type"] = multi.content_type
76
81
  set_body_stream(req, multi.body)
@@ -85,17 +90,18 @@ class LLM::OpenAI
85
90
  # @example
86
91
  # llm = LLM.openai(key: ENV["KEY"])
87
92
  # res = llm.images.edit(image: "/images/hat.png", prompt: "A cat wearing this hat")
88
- # p res.urls
93
+ # IO.copy_stream res.images[0], "hatoncat.png"
89
94
  # @see https://platform.openai.com/docs/api-reference/images/createEdit OpenAI docs
90
95
  # @param [File] image The image to edit
91
96
  # @param [String] prompt The prompt
92
97
  # @param [String] model The model to use
98
+ # @param [String] response_format The response format ("b64_json" or "url")
93
99
  # @param [Hash] params Other parameters (see OpenAI docs)
94
100
  # @raise (see LLM::Provider#request)
95
101
  # @return [LLM::Response]
96
- def edit(image:, prompt:, model: "dall-e-2", **params)
102
+ def edit(image:, prompt:, model: "dall-e-2", response_format: "b64_json", **params)
97
103
  image = LLM.File(image)
98
- multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:))
104
+ multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:, response_format:))
99
105
  req = Net::HTTP::Post.new("/v1/images/edits", headers)
100
106
  req["content-type"] = multi.content_type
101
107
  set_body_stream(req, multi.body)
@@ -41,7 +41,7 @@ class LLM::OpenAI
41
41
  query = URI.encode_www_form(params)
42
42
  req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
- res = ResponseAdapter.adapt(res, type: :enumerable)
44
+ res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
46
46
  res
47
47
  end
@@ -74,10 +74,18 @@ module LLM::OpenAI::ResponseAdapter
74
74
  def adapt_tool_calls(tools)
75
75
  (tools || []).filter_map do |tool|
76
76
  next unless tool.function
77
- {id: tool.id, name: tool.function.name, arguments: LLM.json.load(tool.function.arguments)}
77
+ {id: tool.id, name: tool.function.name, arguments: parse_tool_arguments(tool.function.arguments)}
78
78
  end
79
79
  end
80
80
 
81
+ def parse_tool_arguments(arguments)
82
+ return {} if arguments.to_s.empty?
83
+ parsed = LLM.json.load(arguments)
84
+ Hash === parsed ? parsed : {}
85
+ rescue *LLM.json.parser_error
86
+ {}
87
+ end
88
+
81
89
  include LLM::Contract::Completion
82
90
  end
83
91
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::OpenAI::ResponseAdapter
4
+ module Models
5
+ include LLM::Model::Collection
6
+
7
+ private
8
+
9
+ def raw_models
10
+ data || []
11
+ end
12
+ end
13
+ end
@@ -38,7 +38,15 @@ module LLM::OpenAI::ResponseAdapter
38
38
  end
39
39
 
40
40
  def adapt_tool(tool)
41
- {id: tool.call_id, name: tool.name, arguments: LLM.json.load(tool.arguments)}
41
+ {id: tool.call_id, name: tool.name, arguments: parse_tool_arguments(tool.arguments)}
42
+ end
43
+
44
+ def parse_tool_arguments(arguments)
45
+ return {} if arguments.to_s.empty?
46
+ parsed = LLM.json.load(arguments)
47
+ Hash === parsed ? parsed : {}
48
+ rescue *LLM.json.parser_error
49
+ {}
42
50
  end
43
51
  end
44
52
  end
@@ -11,6 +11,7 @@ class LLM::OpenAI
11
11
  require_relative "response_adapter/file"
12
12
  require_relative "response_adapter/image"
13
13
  require_relative "response_adapter/moderations"
14
+ require_relative "response_adapter/models"
14
15
  require_relative "response_adapter/responds"
15
16
  require_relative "response_adapter/web_search"
16
17
 
@@ -37,6 +38,7 @@ class LLM::OpenAI
37
38
  when :file then LLM::OpenAI::ResponseAdapter::File
38
39
  when :image then LLM::OpenAI::ResponseAdapter::Image
39
40
  when :moderations then LLM::OpenAI::ResponseAdapter::Moderations
41
+ when :models then LLM::OpenAI::ResponseAdapter::Models
40
42
  when :responds then LLM::OpenAI::ResponseAdapter::Responds
41
43
  when :web_search then LLM::OpenAI::ResponseAdapter::WebSearch
42
44
  else