llm.rb 12.1.0 → 12.3.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +248 -0
  3. data/README.md +77 -27
  4. data/data/anthropic.json +3 -2
  5. data/data/bedrock.json +47 -0
  6. data/data/deepinfra.json +2 -2
  7. data/data/google.json +32 -0
  8. data/data/mistral.json +968 -0
  9. data/data/openai.json +422 -0
  10. data/data/xai.json +60 -1
  11. data/lib/llm/active_record/acts_as_agent.rb +11 -0
  12. data/lib/llm/agent.rb +27 -3
  13. data/lib/llm/compactor.rb +2 -2
  14. data/lib/llm/context.rb +18 -26
  15. data/lib/llm/function.rb +6 -0
  16. data/lib/llm/object.rb +13 -0
  17. data/lib/llm/provider.rb +12 -9
  18. data/lib/llm/providers/anthropic.rb +4 -5
  19. data/lib/llm/providers/bedrock.rb +4 -5
  20. data/lib/llm/providers/google.rb +7 -3
  21. data/lib/llm/providers/mistral/request_adapter/completion.rb +122 -0
  22. data/lib/llm/providers/mistral/request_adapter.rb +20 -0
  23. data/lib/llm/providers/mistral.rb +145 -0
  24. data/lib/llm/providers/ollama/response_adapter/completion.rb +39 -0
  25. data/lib/llm/providers/ollama.rb +2 -3
  26. data/lib/llm/providers/openai/responses.rb +6 -3
  27. data/lib/llm/providers/openai.rb +2 -3
  28. data/lib/llm/repl/bar.rb +52 -0
  29. data/lib/llm/repl/input.rb +143 -30
  30. data/lib/llm/repl/markdown.rb +85 -0
  31. data/lib/llm/repl/status.rb +16 -5
  32. data/lib/llm/repl/stream.rb +15 -5
  33. data/lib/llm/repl/transcript.rb +107 -18
  34. data/lib/llm/repl/window.rb +43 -17
  35. data/lib/llm/repl.rb +99 -18
  36. data/lib/llm/sequel/agent.rb +11 -0
  37. data/lib/llm/skill.rb +1 -1
  38. data/lib/llm/stream/disabled.rb +23 -0
  39. data/lib/llm/stream/io.rb +43 -0
  40. data/lib/llm/stream.rb +34 -0
  41. data/lib/llm/tools/git.rb +2 -3
  42. data/lib/llm/tools/pwd.rb +0 -1
  43. data/lib/llm/tools/rg.rb +2 -1
  44. data/lib/llm/tools/swap_text.rb +6 -0
  45. data/lib/llm/transport/execution.rb +1 -0
  46. data/lib/llm/version.rb +1 -1
  47. data/lib/llm.rb +19 -0
  48. data/llm.gemspec +8 -4
  49. data/resources/deepdive.md +170 -50
  50. metadata +15 -4
data/lib/llm/context.rb CHANGED
@@ -92,6 +92,8 @@ module LLM
92
92
  @params[:tools] = tools unless tools.empty?
93
93
  @params[:store] ||= false if @mode == :responses
94
94
  @messages = LLM::Buffer.new(llm)
95
+ extra = @params.slice(:model, :tools).merge!(ctx: self, tracer:)
96
+ @params[:stream] = LLM::Stream.try(@params[:stream], extra:)
95
97
  end
96
98
 
97
99
  ##
@@ -310,10 +312,7 @@ module LLM
310
312
  # A list of functions to exclude from the wait
311
313
  # @return [Array<LLM::Function::Return>]
312
314
  def wait(strategy, except: [])
313
- if LLM::Stream === stream && !stream.queue.empty?
314
- @queue = stream.queue
315
- @queue.wait
316
- else
315
+ if stream.queue.empty?
317
316
  tools = except.empty? ? functions : functions - except
318
317
  guards = guarded_returns(tools:)
319
318
  return guards if guards
@@ -321,6 +320,9 @@ module LLM
321
320
  returns = @queue.wait
322
321
  emit_tool_returns(tools, returns)
323
322
  returns
323
+ else
324
+ @queue = stream.queue
325
+ @queue.wait
324
326
  end
325
327
  ensure
326
328
  @queue = nil
@@ -426,7 +428,7 @@ module LLM
426
428
  # A tracer, or nil.
427
429
  # @return [void]
428
430
  def tracer=(other)
429
- @llm.tracer = nil
431
+ @llm.tracer = other || LLM::Tracer::Null.new(@llm)
430
432
  end
431
433
 
432
434
  ##
@@ -502,25 +504,11 @@ module LLM
502
504
 
503
505
  private
504
506
 
505
- ##
506
- # Binds runtime metadata onto an active stream.
507
- # @api private
508
- def bind!(stream, model, tools)
509
- return unless LLM::Stream === stream
510
- @stream = stream
511
- stream.extra[:ctx] = self
512
- stream.extra[:tracer] = tracer
513
- stream.extra[:model] = model
514
- stream.extra[:tools] = tools
515
- end
516
-
517
507
  ##
518
508
  # Returns the bound stream queue, if available.
519
509
  # @api private
520
510
  def queue
521
- [@queue, stream&.queue].compact.first
522
- rescue NoMethodError
523
- nil
511
+ [@queue, stream.queue].compact.first
524
512
  end
525
513
 
526
514
  ##
@@ -543,12 +531,13 @@ module LLM
543
531
  # Rewrites a prompt and params through the configured transformer.
544
532
  # @api private
545
533
  def transform(prompt, params)
534
+ transformer = self.transformer
546
535
  return [prompt, params] unless transformer
547
536
  stream = params[:stream]
548
- stream.on_transform(self, transformer) if LLM::Stream === stream
537
+ stream.on_transform(self, transformer)
549
538
  transformer.call(self, prompt, params)
550
539
  ensure
551
- stream.on_transform_finish(self, transformer) if LLM::Stream === stream
540
+ stream.on_transform_finish(self, transformer) if transformer
552
541
  end
553
542
 
554
543
  ##
@@ -556,8 +545,10 @@ module LLM
556
545
  # @api private
557
546
  def respond(prompt, params)
558
547
  params = @params.merge(params)
548
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:)
549
+ params[:stream] = LLM::Stream.try(params[:stream], extra:)
559
550
  prompt, params = transform(prompt, params)
560
- bind!(params[:stream], params[:model], params[:tools])
551
+ @stream = params[:stream]
561
552
  res_id = params[:store] == false ? nil : @messages.find(&:assistant?)&.response&.response_id
562
553
  input = res_id ? [] : @messages.to_a
563
554
  params = params.merge(previous_response_id: res_id, input:).compact
@@ -570,8 +561,10 @@ module LLM
570
561
  def complete(prompt, params)
571
562
  params = params.merge(messages: @messages.to_a)
572
563
  params = @params.merge(params)
564
+ extra = params.slice(:model, :tools).merge!(ctx: self, tracer:)
565
+ params[:stream] = LLM::Stream.try(params[:stream], extra:)
573
566
  prompt, params = transform(prompt, params)
574
- bind!(params[:stream], params[:model], params[:tools])
567
+ @stream = params[:stream]
575
568
  [prompt, params, @llm.complete(prompt, params)]
576
569
  end
577
570
 
@@ -590,7 +583,6 @@ module LLM
590
583
  # Emits tool return callbacks for directly waited function work.
591
584
  # @api private
592
585
  def emit_tool_returns(tools, returns)
593
- return unless LLM::Stream === stream
594
586
  returns.each_with_index { |result, index| stream.on_tool_return(tools[index], result) }
595
587
  end
596
588
 
@@ -604,7 +596,7 @@ module LLM
604
596
  def repair!(messages, prompt)
605
597
  message = messages.last
606
598
  return unless message&.tool_call?
607
- returns = self.returns + [*prompt].grep(LLM::Function::Return)
599
+ returns = [self.returns, prompt].flatten.grep(LLM::Function::Return)
608
600
  cancelled = []
609
601
  [*message.extra.tool_calls].each do |tool|
610
602
  next if returns.any? { _1.id == tool[:id] }
data/lib/llm/function.rb CHANGED
@@ -390,6 +390,12 @@ class LLM::Function
390
390
  type: "function", name: @name, description: @description,
391
391
  parameters: (@params || {type: "object", properties: {}}).to_h.merge(additionalProperties: false), strict: false
392
392
  }.compact
393
+ when "LLM::Mistral"
394
+ params = @params || {type: "object", properties: {}}
395
+ {
396
+ type: "function",
397
+ function: {name: @name, description: @description, parameters: params}
398
+ }.compact
393
399
  else
394
400
  params = @params || {type: "object", properties: {}}
395
401
  {
data/lib/llm/object.rb CHANGED
@@ -148,6 +148,19 @@ class LLM::Object < BasicObject
148
148
  SINGLETON.from @h.merge(other)
149
149
  end
150
150
 
151
+ ##
152
+ # @param [Hash, #to_h] other
153
+ # The hash to merge in place
154
+ # @return [LLM::Object]
155
+ # Returns self
156
+ def merge!(other = UNDEFINED)
157
+ return SINGLETON.get(@h, :merge!) if other.equal?(UNDEFINED)
158
+ other = ::Hash.try_convert(other)
159
+ raise TypeError, "#{other} cannot be coerced into a Hash" unless other
160
+ @h.merge!(other)
161
+ self
162
+ end
163
+
151
164
  ##
152
165
  # @param [#to_s, #to_sym] k
153
166
  # The key name
data/lib/llm/provider.rb CHANGED
@@ -26,7 +26,7 @@ class LLM::Provider
26
26
  # Requires the net-http-persistent gem.
27
27
  # @param [LLM::Transport, Class, nil] transport
28
28
  # Optional override with any {LLM::Transport} instance or subclass.
29
- def initialize(key:, host:, port: 443, timeout: 60, ssl: true, base_path: "", persistent: false, transport: nil)
29
+ def initialize(key:, host:, port: 443, timeout: 180, ssl: true, base_path: "", persistent: false, transport: nil)
30
30
  @key = key
31
31
  @host = host
32
32
  @port = port
@@ -71,6 +71,16 @@ class LLM::Provider
71
71
  raise NotImplementedError
72
72
  end
73
73
 
74
+ ##
75
+ # @note
76
+ # This feature is not implemented by all providers,
77
+ # and it will raise NotImplementedError for providers
78
+ # that do not support it.
79
+ # @return [LLM::Response]
80
+ def ocr(...)
81
+ raise NotImplementedError
82
+ end
83
+
74
84
  ##
75
85
  # Provides an interface to the chat completions API
76
86
  # @example
@@ -303,7 +313,7 @@ class LLM::Provider
303
313
  def with_tracer(tracer)
304
314
  had_override = weakmap.key?(self)
305
315
  previous = weakmap[self]
306
- weakmap[self] = tracer
316
+ weakmap[self] = tracer || LLM::Tracer::Null.new(self)
307
317
  yield
308
318
  ensure
309
319
  if had_override
@@ -332,13 +342,6 @@ class LLM::Provider
332
342
  transport.request_owner
333
343
  end
334
344
 
335
- ##
336
- # @param [Object] stream
337
- # @return [Boolean]
338
- def streamable?(stream)
339
- LLM::Stream === stream || stream.respond_to?(:<<)
340
- end
341
-
342
345
  ##
343
346
  # @return [Boolean]
344
347
  # Returns true when an API key is configured
@@ -92,10 +92,10 @@ module LLM
92
92
 
93
93
  ##
94
94
  # Returns the default model for chat completions
95
- # @see https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table claude-sonnet-4-20250514
95
+ # @see https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table claude-opus-4-8
96
96
  # @return [String]
97
97
  def default_model
98
- "claude-sonnet-4-20250514"
98
+ "claude-opus-4-8"
99
99
  end
100
100
 
101
101
  ##
@@ -151,9 +151,8 @@ module LLM
151
151
  params = {role: :user, model: default_model, max_tokens: 1024}.merge!(params)
152
152
  tools = resolve_tools(params.delete(:tools))
153
153
  params = [params, adapt_tools(tools)].inject({}, &:merge!).compact
154
- role, stream = params.delete(:role), params.delete(:stream)
155
- params[:stream] = true if streamable?(stream) || stream == true
156
- [params, stream, tools, role]
154
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
155
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
157
156
  end
158
157
 
159
158
  def build_complete_request(prompt, params, role)
@@ -205,9 +205,8 @@ module LLM
205
205
  params = {role: :user, model: default_model, max_tokens: 2048}.merge!(params)
206
206
  tools = resolve_tools(params.delete(:tools))
207
207
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
208
- role, stream = params.delete(:role), params.delete(:stream)
209
- params[:stream] = true if streamable?(stream) || stream == true
210
- [params, stream, tools, role]
208
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
209
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
211
210
  end
212
211
 
213
212
  def build_complete_request(prompt, params, role, stream: nil)
@@ -215,8 +214,8 @@ module LLM
215
214
  model_id = params.delete(:model) || default_model
216
215
  payload = build_converse_payload(messages, params)
217
216
  body = LLM.json.dump(payload)
218
- path = stream ? "/model/#{model_id}/converse-stream" \
219
- : "/model/#{model_id}/converse"
217
+ path = stream&.enabled? ? "/model/#{model_id}/converse-stream" \
218
+ : "/model/#{model_id}/converse"
220
219
  req = LLM::Transport::Request.post(path, headers)
221
220
  transport.set_body_stream(req, StringIO.new(body))
222
221
  [req, messages, body]
@@ -194,11 +194,15 @@ module LLM
194
194
  end
195
195
 
196
196
  def normalize_complete_params(params)
197
+ except = %i[role model messages stream]
197
198
  params = {role: :user, model: default_model}.merge!(params)
198
199
  tools = resolve_tools(params.delete(:tools))
199
- params = [params, adapt_generation_config(params), adapt_tools(tools)].inject({}, &:merge!).compact
200
- role, model, stream = [:role, :model, :stream].map { params.delete(_1) }
201
- [params, stream, tools, role, model]
200
+ config = adapt_generation_config(params.except(*except))
201
+ params = [params.except(:schema), config, adapt_tools(tools)].inject({}, &:merge!).compact
202
+ role, model, stream = params.delete(:role),
203
+ params.delete(:model),
204
+ LLM::Stream.try(params.delete(:stream))
205
+ [params.merge!(stream: stream.enabled?), stream, tools, role, model]
202
206
  end
203
207
 
204
208
  def build_complete_request(prompt, params, role, model, stream)
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM::Mistral::RequestAdapter
4
+ ##
5
+ # @private
6
+ class Completion
7
+ ##
8
+ # @param [LLM::Message, Hash] message
9
+ # The message to format
10
+ def initialize(message)
11
+ @message = message
12
+ end
13
+
14
+ ##
15
+ # Adapts the message for the Mistral chat completions API
16
+ # @return [Hash]
17
+ def adapt
18
+ catch(:abort) do
19
+ if Hash === message
20
+ {role: message[:role], content: adapt_content(message[:content])}
21
+ elsif message.tool_call?
22
+ {role: message.role, content: nil, tool_calls: message.extra[:original_tool_calls]}
23
+ else
24
+ adapt_message
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def adapt_message
32
+ case content
33
+ when Array
34
+ adapt_array
35
+ else
36
+ {role: message.role, content: adapt_content(content)}
37
+ end
38
+ end
39
+
40
+ def adapt_array
41
+ if content.empty?
42
+ nil
43
+ elsif returns.any?
44
+ returns.map do
45
+ {
46
+ role: "tool",
47
+ name: _1.name,
48
+ tool_call_id: _1.id,
49
+ content: LLM.json.dump(_1.value)
50
+ }
51
+ end
52
+ else
53
+ {role: message.role, content: content.flat_map { adapt_content(_1) }}
54
+ end
55
+ end
56
+
57
+ def adapt_content(content)
58
+ case content
59
+ when LLM::Object
60
+ adapt_object(content)
61
+ when String
62
+ [{type: :text, text: content.to_s}]
63
+ when LLM::Response
64
+ adapt_remote_file(content)
65
+ when LLM::Message
66
+ adapt_content(content.content)
67
+ when LLM::Function::Return
68
+ throw(:abort, {
69
+ role: "tool",
70
+ name: content.name,
71
+ tool_call_id: content.id,
72
+ content: LLM.json.dump(content.value)
73
+ })
74
+ else
75
+ prompt_error!(content)
76
+ end
77
+ end
78
+
79
+ def adapt_object(object)
80
+ case object.kind
81
+ when :image_url
82
+ [{type: :image_url, image_url: {url: object.value}}]
83
+ when :local_file
84
+ adapt_local_file(object.value)
85
+ when :remote_file
86
+ adapt_remote_file(object.value)
87
+ else
88
+ prompt_error!(object)
89
+ end
90
+ end
91
+
92
+ def adapt_local_file(file)
93
+ if file.image?
94
+ [{type: :image_url, image_url: {url: file.to_data_uri}}]
95
+ else
96
+ [{type: :file, file: {filename: file.basename, file_data: file.to_data_uri}}]
97
+ end
98
+ end
99
+
100
+ def adapt_remote_file(file)
101
+ if file.file?
102
+ [{type: :file, file: {file_id: file.id}}]
103
+ else
104
+ prompt_error!(file)
105
+ end
106
+ end
107
+
108
+ def prompt_error!(content)
109
+ if LLM::Object === content
110
+ raise LLM::PromptError, "The given LLM::Object with kind '#{content.kind}' is not " \
111
+ "supported by the Mistral chat completions API."
112
+ else
113
+ raise LLM::PromptError, "The given object (an instance of #{content.class}) " \
114
+ "is not supported by the Mistral chat completions API."
115
+ end
116
+ end
117
+
118
+ def message = @message
119
+ def content = message.content
120
+ def returns = content.grep(LLM::Function::Return)
121
+ end
122
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Mistral
4
+ ##
5
+ # @private
6
+ module RequestAdapter
7
+ require_relative "request_adapter/completion"
8
+ include LLM::OpenAI::RequestAdapter
9
+
10
+ ##
11
+ # @param [Array<LLM::Message>] messages
12
+ # The messages to adapt
13
+ # @return [Array<Hash>]
14
+ def adapt(messages, mode: nil)
15
+ messages.filter_map do |message|
16
+ Completion.new(message).adapt
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai" unless defined?(LLM::OpenAI)
4
+
5
+ module LLM
6
+ ##
7
+ # The Mistral class implements a provider for
8
+ # [Mistral](https://mistral.ai) through its
9
+ # OpenAI-compatible API.
10
+ #
11
+ # @example
12
+ # #!/usr/bin/env ruby
13
+ # require "llm"
14
+ #
15
+ # llm = LLM.mistral(key: ENV["KEY"])
16
+ # ctx = LLM::Context.new(llm)
17
+ # ctx.talk "Hello"
18
+ # ctx.messages.select(&:assistant?).each { print "[#{_1.role}]", _1.content, "\n" }
19
+ class Mistral < OpenAI
20
+ require_relative "mistral/request_adapter"
21
+ include Mistral::RequestAdapter
22
+
23
+ HOST = "api.mistral.ai"
24
+ BASE_PATH = "/v1/"
25
+
26
+ ##
27
+ # @param key (see LLM::Provider#initialize)
28
+ # @param host (see LLM::Provider#initialize)
29
+ # @param base_path (see LLM::Provider#initialize)
30
+ # @return [LLM::Mistral]
31
+ def initialize(host: HOST, base_path: BASE_PATH, **)
32
+ super
33
+ end
34
+
35
+ ##
36
+ # @return [Symbol]
37
+ # Returns the provider's name
38
+ def name
39
+ :mistral
40
+ end
41
+
42
+ ##
43
+ # @return [NotImplementedError]
44
+ def images
45
+ raise NotImplementedError
46
+ end
47
+
48
+ ##
49
+ # Provides an embedding.
50
+ # @param input (see LLM::Provider#embed)
51
+ # @param model (see LLM::Provider#embed)
52
+ # @param params (see LLM::Provider#embed)
53
+ # @raise (see LLM::Provider#request)
54
+ # @return (see LLM::Provider#embed)
55
+ def embed(input, model: "mistral-embed", **params)
56
+ super
57
+ end
58
+
59
+ ##
60
+ # Runs OCR on a remote image or document URL.
61
+ # @see https://docs.mistral.ai/api/endpoint/ocr#operation-ocr_v1_ocr_post Mistral OCR docs
62
+ # @param [String, nil] image_url
63
+ # A remote HTTP(S) URL to the image
64
+ # @param [String, nil] document_url
65
+ # A remote HTTP(S) URL to the document
66
+ # @param [String] model
67
+ # The OCR model to use
68
+ # @param [Hash] params
69
+ # Additional OCR parameters
70
+ # @raise (see LLM::Provider#request)
71
+ # @return [LLM::Response]
72
+ def ocr(image_url: nil, document_url: nil, model: "mistral-ocr-latest", **params)
73
+ if [image_url, document_url].all?(&:nil?)
74
+ raise ArgumentError, "must provide one of: image_url, document_url"
75
+ elsif [image_url, document_url].compact.size > 1
76
+ raise ArgumentError, "must provide one of: image_url, document_url"
77
+ end
78
+ document = parse_document(image_url, document_url)
79
+ req = LLM::Transport::Request.post("/v1/ocr", headers)
80
+ req.body = LLM.json.dump({model:, document:}.merge!(params))
81
+ res, = execute(request: req, operation: "ocr", model:)
82
+ LLM::Response.new(res)
83
+ end
84
+
85
+ ##
86
+ # @raise [NotImplementedError]
87
+ def responses
88
+ raise NotImplementedError
89
+ end
90
+
91
+ ##
92
+ # @return [LLM::Mistral::Audio]
93
+ def audio
94
+ raise NotImplementedError
95
+ end
96
+
97
+ ##
98
+ # @raise [NotImplementedError]
99
+ def files
100
+ raise NotImplementedError
101
+ end
102
+
103
+ ##
104
+ # @raise [NotImplementedError]
105
+ def moderations
106
+ raise NotImplementedError
107
+ end
108
+
109
+ ##
110
+ # @raise [NotImplementedError]
111
+ def vector_stores
112
+ raise NotImplementedError
113
+ end
114
+
115
+ ##
116
+ # Returns the default model for chat completions
117
+ # @return [String]
118
+ def default_model
119
+ "mistral-large-latest"
120
+ end
121
+
122
+ private
123
+
124
+ ##
125
+ # @api private
126
+ def headers
127
+ lock do
128
+ (@headers || {}).merge(
129
+ "Authorization" => "Bearer #{@key}",
130
+ "Content-Type" => "application/json"
131
+ )
132
+ end
133
+ end
134
+
135
+ ##
136
+ # @api private
137
+ def parse_document(image_url, document_url)
138
+ if image_url
139
+ {type: "image_url", image_url:}
140
+ elsif document_url
141
+ {type: "document_url", document_url:}
142
+ end
143
+ end
144
+ end
145
+ end
@@ -9,6 +9,16 @@ module LLM::Ollama::ResponseAdapter
9
9
  end
10
10
  alias_method :choices, :messages
11
11
 
12
+ ##
13
+ # Returns the response body, parsing NDJSON when the
14
+ # transport returned a raw string (non-streaming path).
15
+ # @return [LLM::Object]
16
+ def body
17
+ raw = super
18
+ return raw unless String === raw
19
+ parse_ndjson(raw)
20
+ end
21
+
12
22
  ##
13
23
  # (see LLM::Contract::Completion#input_tokens)
14
24
  def input_tokens
@@ -110,6 +120,35 @@ module LLM::Ollama::ResponseAdapter
110
120
  end
111
121
  end
112
122
 
123
+ ##
124
+ # Parses a raw NDJSON string into an LLM::Object.
125
+ # Ollama's streaming API returns application/x-ndjson,
126
+ # but the transport only parses application/json. When
127
+ # the response body arrives as a raw string, we merge
128
+ # the NDJSON lines here.
129
+ # @param [String] raw
130
+ # @return [LLM::Object]
131
+ def parse_ndjson(raw)
132
+ lines = raw.split("\n").reject(&:empty?)
133
+ merged = lines.each_with_object({}) do |line, hash|
134
+ parsed = LLM.json.load(line)
135
+ next unless parsed.is_a?(Hash)
136
+ parsed.each do |key, value|
137
+ if key == "message" && value.is_a?(Hash)
138
+ if hash.key?("message")
139
+ hash["message"]["content"] << value["content"].to_s
140
+ else
141
+ hash["message"] = {"role" => value["role"], "content" => value["content"].to_s}
142
+ hash["message"]["tool_calls"] = value["tool_calls"] if value.key?("tool_calls")
143
+ end
144
+ else
145
+ hash[key] = value
146
+ end
147
+ end
148
+ end
149
+ LLM::Object.from(merged)
150
+ end
151
+
113
152
  include LLM::Contract::Completion
114
153
  end
115
154
  end
@@ -121,9 +121,8 @@ module LLM
121
121
  params = {role: :user, model: default_model, stream: true}.merge!(params)
122
122
  tools = resolve_tools(params.delete(:tools))
123
123
  params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
124
- role, stream = params.delete(:role), params.delete(:stream)
125
- params[:stream] = true if streamable?(stream) || stream == true
126
- [params, stream, tools, role]
124
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
125
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
127
126
  end
128
127
 
129
128
  def build_complete_request(prompt, params, role)
@@ -36,10 +36,13 @@ class LLM::OpenAI
36
36
  # @return [LLM::Response]
37
37
  def create(prompt, params = {})
38
38
  params = {role: :user, model: @provider.default_model}.merge!(params)
39
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
39
40
  tools = resolve_tools(params.delete(:tools))
40
- params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
41
- role, stream = params.delete(:role), params.delete(:stream)
42
- params[:stream] = true if @provider.streamable?(stream) || stream == true
41
+ params = [
42
+ params.merge!(stream: stream.enabled?),
43
+ adapt_schema(params),
44
+ adapt_tools(tools)
45
+ ].inject({}, &:merge!).compact
43
46
  req = LLM::Transport::Request.post(path("/responses"), headers)
44
47
  messages = build_complete_messages(prompt, params, role)
45
48
  @provider.tracer.set_request_metadata(user_input: extract_user_input(messages, fallback: prompt))
@@ -211,12 +211,11 @@ module LLM
211
211
  params = {role: :user, model: default_model}.merge!(params)
212
212
  tools = resolve_tools(params.delete(:tools))
213
213
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
214
- role, stream = params.delete(:role), params.delete(:stream)
215
- params[:stream] = true if streamable?(stream) || stream == true
214
+ role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
216
215
  if params[:stream]
217
216
  params[:stream_options] = {include_usage: true}.merge!(params[:stream_options] || {})
218
217
  end
219
- [params, stream, tools, role]
218
+ [params.merge!(stream: stream.enabled?), stream, tools, role]
220
219
  end
221
220
 
222
221
  def build_complete_request(prompt, params, role)