llm.rb 4.14.0 → 4.16.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.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "llm/active_record/acts_as_llm"
data/lib/llm/context.rb CHANGED
@@ -2,16 +2,21 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Context LLM::Context} represents a stateful interaction with
6
- # an LLM, including conversation history, tools, execution state,
7
- # and cost tracking. It evolves over time as the system runs.
5
+ # {LLM::Context LLM::Context} is the stateful execution boundary in
6
+ # llm.rb.
8
7
  #
9
- # Context is the stateful environment in which an LLM operates.
10
- # This is not just prompt context; it is an active, evolving
11
- # execution boundary for LLM workflows.
8
+ # It holds the evolving runtime state for an LLM workflow:
9
+ # conversation history, tool calls and returns, schema and streaming
10
+ # configuration, accumulated usage, and request ownership for
11
+ # interruption.
12
12
  #
13
- # A context can use the chat completions API that all providers
14
- # support or the responses API that currently only OpenAI supports.
13
+ # This is broader than prompt context alone. A context is the object
14
+ # that lets one-off prompts, streaming turns, tool execution,
15
+ # persistence, retries, and serialized long-lived workflows all run
16
+ # through the same model.
17
+ #
18
+ # A context can drive the chat completions API that all providers
19
+ # support or the Responses API on providers that expose it.
15
20
  #
16
21
  # @example
17
22
  # #!/usr/bin/env ruby
@@ -272,13 +277,13 @@ module LLM
272
277
  ##
273
278
  # @return [Hash]
274
279
  def to_h
275
- {model:, messages:}
280
+ {schema_version: 1, model:, messages:}
276
281
  end
277
282
 
278
283
  ##
279
284
  # @return [String]
280
285
  def to_json(...)
281
- {schema_version: 1}.merge!(to_h).to_json(...)
286
+ to_h.to_json(...)
282
287
  end
283
288
 
284
289
  ##
@@ -5,6 +5,7 @@ module LLM::EventStream
5
5
  # @private
6
6
  class Parser
7
7
  COMPACT_THRESHOLD = 4096
8
+ Visitor = Struct.new(:target, :on_data, :on_event, :on_id, :on_retry, :on_chunk)
8
9
 
9
10
  ##
10
11
  # @return [LLM::EventStream::Parser]
@@ -20,7 +21,12 @@ module LLM::EventStream
20
21
  # @param [#on_data] visitor
21
22
  # @return [void]
22
23
  def register(visitor)
23
- @visitors << visitor
24
+ @visitors << Visitor.new(
25
+ visitor,
26
+ visitor.respond_to?(:on_data), visitor.respond_to?(:on_event),
27
+ visitor.respond_to?(:on_id), visitor.respond_to?(:on_retry),
28
+ visitor.respond_to?(:on_chunk)
29
+ )
24
30
  end
25
31
 
26
32
  ##
@@ -58,12 +64,16 @@ module LLM::EventStream
58
64
 
59
65
  private
60
66
 
61
- def parse!(chunk)
62
- field, value = Event.parse(chunk)
67
+ def parse_event!(chunk, field, value)
63
68
  dispatch_visitors(field, value, chunk)
64
69
  dispatch_callbacks(field, value, chunk)
65
70
  end
66
71
 
72
+ def parse!(chunk)
73
+ field, value = Event.parse(chunk)
74
+ parse_event!(chunk, field, value)
75
+ end
76
+
67
77
  def dispatch_visitors(field, value, chunk)
68
78
  @visitors.each { dispatch_visitor(_1, field, value, chunk) }
69
79
  end
@@ -76,11 +86,33 @@ module LLM::EventStream
76
86
  end
77
87
 
78
88
  def dispatch_visitor(visitor, field, value, chunk)
79
- method = "on_#{field}"
80
- if visitor.respond_to?(method)
81
- visitor.public_send(method, value, chunk)
82
- elsif visitor.respond_to?("on_chunk")
83
- visitor.on_chunk(nil, chunk)
89
+ target = visitor.target
90
+ if field == "data"
91
+ if visitor.on_data
92
+ target.on_data(value, chunk)
93
+ elsif visitor.on_chunk
94
+ target.on_chunk(nil, chunk)
95
+ end
96
+ elsif field == "event"
97
+ if visitor.on_event
98
+ target.on_event(value, chunk)
99
+ elsif visitor.on_chunk
100
+ target.on_chunk(nil, chunk)
101
+ end
102
+ elsif field == "id"
103
+ if visitor.on_id
104
+ target.on_id(value, chunk)
105
+ elsif visitor.on_chunk
106
+ target.on_chunk(nil, chunk)
107
+ end
108
+ elsif field == "retry"
109
+ if visitor.on_retry
110
+ target.on_retry(value, chunk)
111
+ elsif visitor.on_chunk
112
+ target.on_chunk(nil, chunk)
113
+ end
114
+ elsif visitor.on_chunk
115
+ target.on_chunk(nil, chunk)
84
116
  end
85
117
  end
86
118
 
data/lib/llm/provider.rb CHANGED
@@ -22,15 +22,18 @@ class LLM::Provider
22
22
  # The number of seconds to wait for a response
23
23
  # @param [Boolean] ssl
24
24
  # Whether to use SSL for the connection
25
+ # @param [String] base_path
26
+ # Optional base path prefix for HTTP API routes.
25
27
  # @param [Boolean] persistent
26
28
  # Whether to use a persistent connection.
27
29
  # Requires the net-http-persistent gem.
28
- def initialize(key:, host:, port: 443, timeout: 60, ssl: true, persistent: false)
30
+ def initialize(key:, host:, port: 443, timeout: 60, ssl: true, base_path: "", persistent: false)
29
31
  @key = key
30
32
  @host = host
31
33
  @port = port
32
34
  @timeout = timeout
33
35
  @ssl = ssl
36
+ @base_path = normalize_base_path(base_path)
34
37
  @base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
35
38
  @headers = {"User-Agent" => "llm.rb v#{LLM::VERSION}"}
36
39
  @transport = Transport::HTTP.new(host:, port:, timeout:, ssl:, persistent:)
@@ -330,6 +333,18 @@ class LLM::Provider
330
333
 
331
334
  private
332
335
 
336
+ def path(suffix)
337
+ return suffix if @base_path.empty?
338
+ "#{@base_path}#{suffix}"
339
+ end
340
+
341
+ def normalize_base_path(path)
342
+ path = path.to_s.strip
343
+ return "" if path.empty? || path == "/"
344
+ path = "/#{path}" unless path.start_with?("/")
345
+ path.sub(%r{/+\z}, "")
346
+ end
347
+
333
348
  attr_reader :base_uri, :host, :port, :timeout, :ssl, :transport
334
349
 
335
350
  ##
@@ -16,6 +16,9 @@ class LLM::Anthropic
16
16
  def initialize(stream)
17
17
  @body = {"role" => "assistant", "content" => []}
18
18
  @stream = stream
19
+ @can_emit_content = stream.respond_to?(:on_content)
20
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
21
+ @can_push_content = stream.respond_to?(:<<)
19
22
  end
20
23
 
21
24
  ##
@@ -88,15 +91,15 @@ class LLM::Anthropic
88
91
  end
89
92
 
90
93
  def emit_content(value)
91
- if @stream.respond_to?(:on_content)
94
+ if @can_emit_content
92
95
  @stream.on_content(value)
93
- elsif @stream.respond_to?(:<<)
96
+ elsif @can_push_content
94
97
  @stream << value
95
98
  end
96
99
  end
97
100
 
98
101
  def emit_tool(tool)
99
- return unless @stream.respond_to?(:on_tool_call)
102
+ return unless @can_emit_tool_call
100
103
  function, error = resolve_tool(tool)
101
104
  @stream.on_tool_call(function, error)
102
105
  end
@@ -17,6 +17,9 @@ class LLM::Google
17
17
  @body = {"candidates" => []}
18
18
  @stream = stream
19
19
  @emits = {tools: []}
20
+ @can_emit_content = stream.respond_to?(:on_content)
21
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
22
+ @can_push_content = stream.respond_to?(:<<)
20
23
  end
21
24
 
22
25
  ##
@@ -126,15 +129,15 @@ class LLM::Google
126
129
  end
127
130
 
128
131
  def emit_content(value)
129
- if @stream.respond_to?(:on_content)
132
+ if @can_emit_content
130
133
  @stream.on_content(value)
131
- elsif @stream.respond_to?(:<<)
134
+ elsif @can_push_content
132
135
  @stream << value
133
136
  end
134
137
  end
135
138
 
136
139
  def emit_tool(pindex, cindex, part)
137
- return unless @stream.respond_to?(:on_tool_call)
140
+ return unless @can_emit_tool_call
138
141
  return unless complete_tool?(part)
139
142
  key = [cindex, pindex]
140
143
  return if @emits[:tools].include?(key)
@@ -14,6 +14,7 @@ class LLM::Ollama
14
14
  def initialize(stream)
15
15
  @body = {}
16
16
  @stream = stream
17
+ @can_push_content = stream.respond_to?(:<<)
17
18
  end
18
19
 
19
20
  ##
@@ -36,10 +37,10 @@ class LLM::Ollama
36
37
  if key == "message"
37
38
  if @body[key]
38
39
  @body[key]["content"] << value["content"]
39
- @stream << value["content"] if @stream.respond_to?(:<<)
40
+ @stream << value["content"] if @can_push_content
40
41
  else
41
42
  @body[key] = value
42
- @stream << value["content"] if @stream.respond_to?(:<<)
43
+ @stream << value["content"] if @can_push_content
43
44
  end
44
45
  else
45
46
  @body[key] = value
@@ -32,7 +32,7 @@ class LLM::OpenAI
32
32
  # @raise (see LLM::Provider#request)
33
33
  # @return [LLM::Response]
34
34
  def create_speech(input:, voice: "alloy", model: "gpt-4o-mini-tts", response_format: "mp3", **params)
35
- req = Net::HTTP::Post.new("/v1/audio/speech", headers)
35
+ req = Net::HTTP::Post.new(path("/audio/speech"), headers)
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 } }
@@ -55,7 +55,7 @@ class LLM::OpenAI
55
55
  # @return [LLM::Response]
56
56
  def create_transcription(file:, model: "whisper-1", **params)
57
57
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
58
- req = Net::HTTP::Post.new("/v1/audio/transcriptions", headers)
58
+ req = Net::HTTP::Post.new(path("/audio/transcriptions"), headers)
59
59
  req["content-type"] = multi.content_type
60
60
  set_body_stream(req, multi.body)
61
61
  res, span, tracer = execute(request: req, operation: "request")
@@ -79,7 +79,7 @@ class LLM::OpenAI
79
79
  # @return [LLM::Response]
80
80
  def create_translation(file:, model: "whisper-1", **params)
81
81
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), model:))
82
- req = Net::HTTP::Post.new("/v1/audio/translations", headers)
82
+ req = Net::HTTP::Post.new(path("/audio/translations"), headers)
83
83
  req["content-type"] = multi.content_type
84
84
  set_body_stream(req, multi.body)
85
85
  res, span, tracer = execute(request: req, operation: "request")
@@ -90,7 +90,7 @@ class LLM::OpenAI
90
90
 
91
91
  private
92
92
 
93
- [:headers, :execute, :set_body_stream].each do |m|
93
+ [:path, :headers, :execute, :set_body_stream].each do |m|
94
94
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
95
95
  end
96
96
  end
@@ -40,7 +40,7 @@ class LLM::OpenAI
40
40
  # @return [LLM::Response]
41
41
  def all(**params)
42
42
  query = URI.encode_www_form(params)
43
- req = Net::HTTP::Get.new("/v1/files?#{query}", headers)
43
+ req = Net::HTTP::Get.new(path("/files?#{query}"), headers)
44
44
  res, span, tracer = execute(request: req, operation: "request")
45
45
  res = ResponseAdapter.adapt(res, type: :enumerable)
46
46
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -60,7 +60,7 @@ class LLM::OpenAI
60
60
  # @return [LLM::Response]
61
61
  def create(file:, purpose: "assistants", **params)
62
62
  multi = LLM::Multipart.new(params.merge!(file: LLM.File(file), purpose:))
63
- req = Net::HTTP::Post.new("/v1/files", headers)
63
+ req = Net::HTTP::Post.new(path("/files"), headers)
64
64
  req["content-type"] = multi.content_type
65
65
  set_body_stream(req, multi.body)
66
66
  res, span, tracer = execute(request: req, operation: "request")
@@ -83,7 +83,7 @@ class LLM::OpenAI
83
83
  def get(file:, **params)
84
84
  file_id = file.respond_to?(:id) ? file.id : file
85
85
  query = URI.encode_www_form(params)
86
- req = Net::HTTP::Get.new("/v1/files/#{file_id}?#{query}", headers)
86
+ req = Net::HTTP::Get.new(path("/files/#{file_id}?#{query}"), headers)
87
87
  res, span, tracer = execute(request: req, operation: "request")
88
88
  res = ResponseAdapter.adapt(res, type: :file)
89
89
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -105,7 +105,7 @@ class LLM::OpenAI
105
105
  def download(file:, **params)
106
106
  query = URI.encode_www_form(params)
107
107
  file_id = file.respond_to?(:id) ? file.id : file
108
- req = Net::HTTP::Get.new("/v1/files/#{file_id}/content?#{query}", headers)
108
+ req = Net::HTTP::Get.new(path("/files/#{file_id}/content?#{query}"), headers)
109
109
  io = StringIO.new("".b)
110
110
  res, span, tracer = execute(request: req, operation: "request") { |res| res.read_body { |chunk| io << chunk } }
111
111
  res = LLM::Response.new(res).tap { _1.define_singleton_method(:file) { io } }
@@ -125,7 +125,7 @@ class LLM::OpenAI
125
125
  # @return [LLM::Response]
126
126
  def delete(file:)
127
127
  file_id = file.respond_to?(:id) ? file.id : file
128
- req = Net::HTTP::Delete.new("/v1/files/#{file_id}", headers)
128
+ req = Net::HTTP::Delete.new(path("/files/#{file_id}"), headers)
129
129
  res, span, tracer = execute(request: req, operation: "request")
130
130
  res = LLM::Response.new(res)
131
131
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -134,7 +134,7 @@ class LLM::OpenAI
134
134
 
135
135
  private
136
136
 
137
- [:headers, :execute, :set_body_stream].each do |m|
137
+ [:path, :headers, :execute, :set_body_stream].each do |m|
138
138
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
139
139
  end
140
140
  end
@@ -50,7 +50,7 @@ class LLM::OpenAI
50
50
  # @raise (see LLM::Provider#request)
51
51
  # @return [LLM::Response]
52
52
  def create(prompt:, model: "dall-e-3", response_format: "b64_json", **params)
53
- req = Net::HTTP::Post.new("/v1/images/generations", headers)
53
+ req = Net::HTTP::Post.new(path("/images/generations"), headers)
54
54
  req.body = LLM.json.dump({prompt:, n: 1, model:, response_format:}.merge!(params))
55
55
  res, span, tracer = execute(request: req, operation: "request")
56
56
  res = ResponseAdapter.adapt(res, type: :image)
@@ -76,7 +76,7 @@ class LLM::OpenAI
76
76
  def create_variation(image:, model: "dall-e-2", response_format: "b64_json", **params)
77
77
  image = LLM.File(image)
78
78
  multi = LLM::Multipart.new(params.merge!(image:, model:, response_format:))
79
- req = Net::HTTP::Post.new("/v1/images/variations", headers)
79
+ req = Net::HTTP::Post.new(path("/images/variations"), headers)
80
80
  req["content-type"] = multi.content_type
81
81
  set_body_stream(req, multi.body)
82
82
  res, span, tracer = execute(request: req, operation: "request")
@@ -102,7 +102,7 @@ class LLM::OpenAI
102
102
  def edit(image:, prompt:, model: "dall-e-2", response_format: "b64_json", **params)
103
103
  image = LLM.File(image)
104
104
  multi = LLM::Multipart.new(params.merge!(image:, prompt:, model:, response_format:))
105
- req = Net::HTTP::Post.new("/v1/images/edits", headers)
105
+ req = Net::HTTP::Post.new(path("/images/edits"), headers)
106
106
  req["content-type"] = multi.content_type
107
107
  set_body_stream(req, multi.body)
108
108
  res, span, tracer = execute(request: req, operation: "request")
@@ -113,7 +113,7 @@ class LLM::OpenAI
113
113
 
114
114
  private
115
115
 
116
- [:headers, :execute, :set_body_stream].each do |m|
116
+ [:path, :headers, :execute, :set_body_stream].each do |m|
117
117
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
118
118
  end
119
119
  end
@@ -39,7 +39,7 @@ class LLM::OpenAI
39
39
  # @return [LLM::Response]
40
40
  def all(**params)
41
41
  query = URI.encode_www_form(params)
42
- req = Net::HTTP::Get.new("/v1/models?#{query}", headers)
42
+ req = Net::HTTP::Get.new(path("/models?#{query}"), headers)
43
43
  res, span, tracer = execute(request: req, operation: "request")
44
44
  res = ResponseAdapter.adapt(res, type: :models)
45
45
  tracer.on_request_finish(operation: "request", res:, span:)
@@ -48,7 +48,7 @@ class LLM::OpenAI
48
48
 
49
49
  private
50
50
 
51
- [:headers, :execute, :set_body_stream].each do |m|
51
+ [:path, :headers, :execute, :set_body_stream].each do |m|
52
52
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
53
53
  end
54
54
  end
@@ -47,7 +47,7 @@ class LLM::OpenAI
47
47
  # @param [String, LLM::Model] model The model to use
48
48
  # @return [LLM::Response]
49
49
  def create(input:, model: "omni-moderation-latest", **params)
50
- req = Net::HTTP::Post.new("/v1/moderations", headers)
50
+ req = Net::HTTP::Post.new(path("/moderations"), headers)
51
51
  input = RequestAdapter::Moderation.new(input).adapt
52
52
  req.body = LLM.json.dump({input:, model:}.merge!(params))
53
53
  res, span, tracer = execute(request: req, operation: "request")
@@ -58,7 +58,7 @@ class LLM::OpenAI
58
58
 
59
59
  private
60
60
 
61
- [:headers, :execute].each do |m|
61
+ [:path, :headers, :execute].each do |m|
62
62
  define_method(m) { |*args, **kwargs, &b| @provider.send(m, *args, **kwargs, &b) }
63
63
  end
64
64
  end