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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::DeepSeek
4
+ ##
5
+ # The {LLM::DeepSeek::Images LLM::DeepSeek::Images} class
6
+ # provides image generation capabilities through DeepSeek.
7
+ #
8
+ # DeepSeek does not provide an image generation model however
9
+ # its text-to-text models can generate vector graphics (SVGS)
10
+ # and that's the approach that this class takes. It is somewhat
11
+ # experimental.
12
+ #
13
+ # An SVG document can be converted to PNG or another format
14
+ # with tools like rsvg-convert.
15
+ class Images
16
+ ##
17
+ # @param [LLM::DeepSeek] provider
18
+ # @return [LLM::DeepSeek::Images]
19
+ def initialize(provider)
20
+ @provider = provider
21
+ end
22
+
23
+ ##
24
+ # @param [String] prompt
25
+ # A prompt
26
+ # @param [String] model
27
+ # A text-to-image model.
28
+ # @param [void] size
29
+ # This parameter is a noop.
30
+ # Exists for compatibility with other providers.
31
+ # @param [void] n
32
+ # This parameter is a noop.
33
+ # Exists for compatibility with other providers.
34
+ # @param [void] response_format
35
+ # This parameter is a noop.
36
+ # Exists for compatibility with other providers.
37
+ # @param [void] quality
38
+ # This parameter is a noop.
39
+ # Exists for compatibility with other providers.
40
+ # @param [void] style
41
+ # This parameter is a noop.
42
+ # Exists for compatibility with other providers.
43
+ # @return [LLM::Response<LLM::DeepSeek::ResponseAdapter::Image>]
44
+ # Returns a response
45
+ def create(prompt:, model: @provider.default_model, agent: nil, size: nil, n: nil, response_format: nil, quality: nil, style: nil)
46
+ agent ||= LLM::Agent.new(@provider, model:, instructions: create_instructions, response_format: {type: "json_object"})
47
+ res = agent.talk(prompt)
48
+ res = LLM::DeepSeek::ResponseAdapter.adapt(res, type: :image)
49
+ res.define_singleton_method(:agent) { agent }
50
+ res
51
+ end
52
+
53
+ ##
54
+ # @param [String] prompt
55
+ # A prompt
56
+ # @param [String] model
57
+ # A text-to-image model.
58
+ # @param [String, LLM::File] image
59
+ # The path to an SVG file
60
+ # @param [void] size
61
+ # This parameter is a noop.
62
+ # Exists for compatibility with other providers.
63
+ # @param [void] n
64
+ # This parameter is a noop.
65
+ # Exists for compatibility with other providers.
66
+ # @param [void] response_format
67
+ # This parameter is a noop.
68
+ # Exists for compatibility with other providers.
69
+ # @param [void] quality
70
+ # This parameter is a noop.
71
+ # Exists for compatibility with other providers.
72
+ # @param [void] style
73
+ # This parameter is a noop.
74
+ # Exists for compatibility with other providers.
75
+ # @return [LLM::Response<LLM::DeepSeek::ResponseAdapter::Image>]
76
+ # Returns a response
77
+ def edit(prompt:, image:, model: @provider.default_model, agent: nil, size: nil, n: nil, response_format: nil, quality: nil, style: nil)
78
+ file = LLM.File(image)
79
+ agent ||= LLM::Agent.new(@provider, model:, instructions: edit_instructions(file), response_format: {type: "json_object"})
80
+ res = agent.talk(prompt)
81
+ res = LLM::DeepSeek::ResponseAdapter.adapt(res, type: :image)
82
+ res.define_singleton_method(:agent) { agent }
83
+ res
84
+ end
85
+
86
+ private
87
+
88
+ def create_instructions
89
+ "Generate a complete SVG document that satisfies the user's prompt. " \
90
+ "Respond with a JSON object that has exactly one key: svg. " \
91
+ "The value of svg must be a valid standalone SVG document as a string. " \
92
+ "Do not include markdown, code fences, commentary, or any keys other than svg."
93
+ end
94
+
95
+ def edit_instructions(file)
96
+ file.with_io do |io|
97
+ "Edit the SVG document that is provided according to the user's prompt" \
98
+ "Respond with a JSON object that has exactly one key: svg. " \
99
+ "The value of svg must be a valid standalone SVG document as a string. " \
100
+ "Do not include markdown, code fences, commentary, or any keys other than svg." \
101
+ "The SVG document follows:\n\n#{io.read}" \
102
+ end
103
+ end
104
+
105
+ [:path, :headers, :execute, :transport].each do |m|
106
+ define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
107
+ end
108
+ end
109
+ end
@@ -5,6 +5,7 @@ class LLM::DeepSeek
5
5
  # @private
6
6
  module RequestAdapter
7
7
  require_relative "request_adapter/completion"
8
+
8
9
  ##
9
10
  # @param [Array<LLM::Message>] messages
10
11
  # The messages to adapt
@@ -17,6 +18,37 @@ class LLM::DeepSeek
17
18
 
18
19
  private
19
20
 
21
+ ##
22
+ # Adapt a schema for the DeepSeek chat completions API.
23
+ #
24
+ # DeepSeek does not support OpenAI's `json_schema` response format,
25
+ # so llm.rb falls back to `json_object` and injects a system message
26
+ # that describes the expected shape in prompt-friendly terms.
27
+ #
28
+ # @param [Hash] params
29
+ # The request params
30
+ # @return [Hash]
31
+ def adapt_schema(params)
32
+ return {} unless params && params[:schema]
33
+ schema = params.delete(:schema)
34
+ schema = schema.respond_to?(:object) ? schema.object : schema
35
+ params[:messages] ||= []
36
+ params[:messages] << LLM::Message.new(system_role, adapt_prompt(schema))
37
+ {response_format: {type: "json_object"}}
38
+ end
39
+
40
+ ##
41
+ # Build the system prompt that describes the schema.
42
+ # @param [#to_s] schema
43
+ # The schema object
44
+ # @return [String]
45
+ def adapt_prompt(schema)
46
+ "Respond with a single valid JSON object. " \
47
+ "Do not include markdown, code fences, commentary, or any text outside the JSON object. " \
48
+ "The JSON object must match this schema: " \
49
+ "#{schema}"
50
+ end
51
+
20
52
  ##
21
53
  # @param [Array<LLM::Function>] tools
22
54
  # @return [Hash]
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::DeepSeek::ResponseAdapter
4
+ module Image
5
+ def images
6
+ [StringIO.new(content!.svg)]
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::DeepSeek
4
+ ##
5
+ # @private
6
+ module ResponseAdapter
7
+ require_relative "response_adapter/image"
8
+ module_function
9
+
10
+ ##
11
+ # @param [LLM::Response, Net::HTTPResponse] res
12
+ # @param [Symbol] type
13
+ # @return [LLM::Response]
14
+ def adapt(res, type:)
15
+ response = (LLM::Response === res) ? res : LLM::Response.new(res)
16
+ adapter = select(type)
17
+ response.extend(adapter)
18
+ end
19
+
20
+ ##
21
+ # @api private
22
+ def select(type)
23
+ case type
24
+ when :image then LLM::DeepSeek::ResponseAdapter::Image
25
+ else LLM::OpenAI::ResponseAdapter.select(type)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -19,6 +19,8 @@ module LLM
19
19
  # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
20
20
  class DeepSeek < OpenAI
21
21
  require_relative "deepseek/request_adapter"
22
+ require_relative "deepseek/response_adapter"
23
+ require_relative "deepseek/images"
22
24
  include DeepSeek::RequestAdapter
23
25
 
24
26
  ##
@@ -42,9 +44,9 @@ module LLM
42
44
  end
43
45
 
44
46
  ##
45
- # @raise [NotImplementedError]
47
+ # @raise [LLM::DeepSeek::Images]
46
48
  def images
47
- raise NotImplementedError
49
+ LLM::DeepSeek::Images.new(self)
48
50
  end
49
51
 
50
52
  ##
@@ -21,11 +21,17 @@ class LLM::Google
21
21
  ##
22
22
  # @param [Hash] params
23
23
  # @return [Hash]
24
- def adapt_schema(params)
25
- return {} unless params and params[:schema]
26
- schema = params.delete(:schema)
27
- schema = schema.respond_to?(:object) ? schema.object : schema
28
- {generationConfig: {response_mime_type: "application/json", response_schema: schema}}
24
+ def adapt_generation_config(params)
25
+ return {} unless params
26
+ config = {}
27
+ if params[:schema]
28
+ schema = params.delete(:schema)
29
+ schema = schema.respond_to?(:object) ? schema.object : schema
30
+ config.merge!(response_mime_type: "application/json", response_schema: schema)
31
+ end
32
+ params_map.each { config[_1] = params.delete(_2) if params.key?(_2) }
33
+ config.merge!(params)
34
+ config.empty? ? {} : {generationConfig: config}
29
35
  end
30
36
 
31
37
  ##
@@ -36,5 +42,16 @@ class LLM::Google
36
42
  platform, functions = [tools.grep(LLM::ServerTool), tools.grep(LLM::Function)]
37
43
  {tools: [*platform, {functionDeclarations: functions.map { _1.adapt(self) }}]}
38
44
  end
45
+
46
+ ##
47
+ # @return [Hash]
48
+ def params_map
49
+ {
50
+ topP: :top_p,
51
+ topK: :top_k,
52
+ maxOutputTokens: :max_tokens,
53
+ stopSequences: :stop
54
+ }
55
+ end
39
56
  end
40
57
  end
@@ -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
@@ -23,7 +23,7 @@ module LLM
23
23
  ##
24
24
  # @param (see LLM::Provider#initialize)
25
25
  # @return [LLM::LlamaCpp]
26
- def initialize(host: "localhost", port: 8080, ssl: false, **)
26
+ def initialize(host: "localhost", port: 8013, ssl: false, **)
27
27
  super
28
28
  end
29
29
 
@@ -71,11 +71,11 @@ module LLM
71
71
  end
72
72
 
73
73
  ##
74
- # Returns the default model for chat completions
75
- # @see https://ollama.com/library/qwen3 qwen3
76
- # @return [String]
74
+ # Returns nil.
75
+ # Whatever model is served by llamacpp acts as the default.
76
+ # @return [nil]
77
77
  def default_model
78
- "qwen3"
78
+ nil
79
79
  end
80
80
  end
81
81
  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
  ##