llm_gateway 0.6.0 → 0.7.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +255 -1
  4. data/docs/migration_guide_0.7.0.md +193 -0
  5. data/lib/llm_gateway/adapters/adapter.rb +1 -1
  6. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  7. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
  8. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  9. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  11. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
  12. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  13. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  14. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  15. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
  16. data/lib/llm_gateway/adapters/structs.rb +45 -10
  17. data/lib/llm_gateway/agents/event.rb +105 -0
  18. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  19. data/lib/llm_gateway/agents/harness.rb +176 -0
  20. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  21. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  22. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  23. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  24. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  25. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  26. data/lib/llm_gateway/base_client.rb +3 -3
  27. data/lib/llm_gateway/clients/anthropic.rb +5 -5
  28. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  29. data/lib/llm_gateway/clients/openai.rb +2 -2
  30. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  31. data/lib/llm_gateway/prompt.rb +105 -68
  32. data/lib/llm_gateway/utils.rb +116 -13
  33. data/lib/llm_gateway/version.rb +1 -1
  34. data/lib/llm_gateway.rb +4 -0
  35. metadata +12 -2
@@ -41,7 +41,7 @@ module LlmGateway
41
41
  request.set_form(form_data, "multipart/form-data")
42
42
 
43
43
  # Headers (excluding Content-Type because set_form already sets it)
44
- multipart_headers = build_headers.reject { |k, _| k.downcase == "content-type" }
44
+ multipart_headers = build_headers.except("content-type", "Content-Type")
45
45
  multipart_headers.each { |key, value| request[key] = value }
46
46
 
47
47
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
@@ -112,7 +112,7 @@ module LlmGateway
112
112
  next if data_str == "[DONE]"
113
113
 
114
114
  data = begin
115
- LlmGateway::Utils.deep_symbolize_keys(JSON.parse(data_str))
115
+ JSON.parse(data_str).deep_symbolize_keys
116
116
  rescue JSON::ParserError
117
117
  { raw: data_str }
118
118
  end
@@ -141,7 +141,7 @@ module LlmGateway
141
141
  when 200
142
142
  content_type = response["content-type"]
143
143
  if content_type&.include?("application/json")
144
- LlmGateway::Utils.deep_symbolize_keys(JSON.parse(response.body))
144
+ JSON.parse(response.body).deep_symbolize_keys
145
145
  else
146
146
  response.body
147
147
  end
@@ -54,19 +54,19 @@ module LlmGateway
54
54
  }
55
55
 
56
56
  tools = apply_tools_cache_control(tools, cache_retention)
57
- body.merge!(tools: tools) if LlmGateway::Utils.present?(tools)
57
+ body.merge!(tools: tools) if tools.present?
58
58
 
59
59
  system = prepend_claude_code_identity(system) if claude_code_oauth_api_key?
60
60
  system = apply_system_cache_control(system, cache_retention)
61
61
 
62
- body.merge!(system: system) if LlmGateway::Utils.present?(system)
62
+ body.merge!(system: system) if system.present?
63
63
  body.merge!(cache_control: cache_control) unless cache_control.nil?
64
64
  body.merge!(options)
65
65
  body
66
66
  end
67
67
 
68
68
  def apply_system_cache_control(system, cache_retention)
69
- return system if system.nil? || system.empty? || !system.is_a?(Array)
69
+ return system if system.blank? || !system.is_a?(Array)
70
70
 
71
71
  cache_control = anthropic_cache_control_for(cache_retention)
72
72
  return system if cache_control.nil?
@@ -84,7 +84,7 @@ module LlmGateway
84
84
  end
85
85
 
86
86
  def apply_tools_cache_control(tools, cache_retention)
87
- return tools if tools.nil? || tools.empty? || !tools.is_a?(Array)
87
+ return tools if tools.blank? || !tools.is_a?(Array)
88
88
 
89
89
  cache_control = anthropic_cache_control_for(cache_retention)
90
90
  return tools if cache_control.nil?
@@ -149,7 +149,7 @@ module LlmGateway
149
149
  text: "You are Claude Code, Anthropic's official CLI for Claude."
150
150
  }
151
151
 
152
- if system.nil? || system.empty?
152
+ if system.blank?
153
153
  [ identity ]
154
154
  else
155
155
  [ identity ] + system
@@ -105,7 +105,7 @@ module LlmGateway
105
105
  code = uri.query && URI.decode_www_form(uri.query).to_h["code"]
106
106
  state = uri.query && URI.decode_www_form(uri.query).to_h["state"]
107
107
 
108
- raise ArgumentError, "Callback URL is missing code parameter" if code.nil? || code.empty?
108
+ raise ArgumentError, "Callback URL is missing code parameter" if code.blank?
109
109
 
110
110
  { code: code, state: state }
111
111
  rescue URI::InvalidURIError => e
@@ -116,7 +116,7 @@ module LlmGateway
116
116
 
117
117
  def extract_code_and_state(auth_code_or_callback, state)
118
118
  value = auth_code_or_callback.to_s.strip
119
- raise ArgumentError, "Authorization code is required" if value.empty?
119
+ raise ArgumentError, "Authorization code is required" if value.blank?
120
120
 
121
121
  if looks_like_url?(value)
122
122
  callback = parse_callback(value)
@@ -114,7 +114,7 @@ module LlmGateway
114
114
 
115
115
  def build_codex_body(messages, system, tools, model:, **options)
116
116
  instructions = Array(system).filter_map { |s| s.is_a?(Hash) ? s[:content] : s }.join("\n")
117
- instructions = "You are a helpful assistant." if instructions.empty?
117
+ instructions = instructions.presence || "You are a helpful assistant."
118
118
 
119
119
  body = {
120
120
  model: model,
@@ -196,7 +196,7 @@ module LlmGateway
196
196
  end
197
197
  # If we get here, we didn't handle it specifically
198
198
  fallback_body = response.body.to_s.strip
199
- fallback_message = if fallback_body.empty?
199
+ fallback_message = if fallback_body.blank?
200
200
  "OpenAI request failed with status #{response.code}"
201
201
  else
202
202
  "OpenAI request failed with status #{response.code}: #{fallback_body}"
@@ -96,7 +96,7 @@ module LlmGateway
96
96
  uri = URI.parse(callback_url)
97
97
  params = URI.decode_www_form(uri.query.to_s).to_h
98
98
  code = params["code"]
99
- raise ArgumentError, "Callback URL is missing code parameter" if code.nil? || code.empty?
99
+ raise ArgumentError, "Callback URL is missing code parameter" if code.blank?
100
100
 
101
101
  { code: code, state: params["state"] }
102
102
  rescue URI::InvalidURIError => e
@@ -120,7 +120,7 @@ module LlmGateway
120
120
  input = tty.gets&.strip
121
121
  tty.close
122
122
 
123
- raise "No authorization code provided" if input.nil? || input.empty?
123
+ raise "No authorization code provided" if input.blank?
124
124
 
125
125
  exchange_code(input, flow[:code_verifier], expected_state: flow[:state])
126
126
  end
@@ -183,7 +183,7 @@ module LlmGateway
183
183
  auth = payload[JWT_CLAIM_PATH]
184
184
  account_id = auth&.dig("chatgpt_account_id")
185
185
 
186
- account_id.is_a?(String) && !account_id.empty? ? account_id : nil
186
+ account_id.is_a?(String) ? account_id.presence : nil
187
187
  rescue StandardError
188
188
  nil
189
189
  end
@@ -214,7 +214,7 @@ module LlmGateway
214
214
  end
215
215
 
216
216
  def parse_authorization_input(input, expected_state = nil)
217
- return nil if input.nil? || input.empty?
217
+ return nil if input.blank?
218
218
 
219
219
  value = input.to_s.strip
220
220
 
@@ -2,112 +2,149 @@
2
2
 
3
3
  module LlmGateway
4
4
  class Prompt
5
- UNSET = Object.new.freeze
5
+ class_attribute :provider, :model, :reasoning
6
+ class_attribute :before_execute_callbacks, :after_execute_callbacks, instance_accessor: false, default: []
7
+ attr_accessor :cache_key, :cache_retention
6
8
 
7
- attr_reader :provider, :model
9
+ def self.before_execute(*methods, &block)
10
+ self.before_execute_callbacks += methods
11
+ self.before_execute_callbacks += [ block ] if block_given?
12
+ end
8
13
 
9
- class << self
10
- def provider(value = UNSET)
11
- @provider = value unless value.equal?(UNSET)
12
- @provider
13
- end
14
+ def self.after_execute(*methods, &block)
15
+ self.after_execute_callbacks += methods
16
+ self.after_execute_callbacks += [ block ] if block_given?
17
+ end
14
18
 
15
- def provider=(value)
16
- @provider = value
17
- end
19
+ def initialize(provider: nil, model: nil, reasoning: nil, cache_key: nil, cache_retention: nil)
20
+ @provider = provider || self.class.provider
21
+ @model = model || self.class.model
22
+ @reasoning = reasoning || self.class.reasoning
23
+ @cache_key = cache_key
24
+ @cache_retention = cache_retention
25
+ end
18
26
 
19
- def model(value = UNSET)
20
- @model = value unless value.equal?(UNSET)
21
- @model
22
- end
27
+ def run(provider: nil, model: nil, reasoning: nil, **options, &block)
28
+ # Resolve the prompt once so dynamic or expensive prompt builders are not
29
+ # evaluated multiple times during a single run.
30
+ input = prompt
23
31
 
24
- def model=(value)
25
- @model = value
26
- end
27
- end
32
+ run_callbacks(:before_execute, input)
28
33
 
29
- def self.before_execute(*methods, &block)
30
- before_execute_callbacks.concat(methods)
31
- before_execute_callbacks << block if block_given?
34
+ response = run_tool_loop(input, provider: resolved_provider(provider), model: model, reasoning: reasoning, **options, &block)
35
+
36
+ run_callbacks(:after_execute, response)
37
+
38
+ response
32
39
  end
33
40
 
34
- def self.after_execute(*methods, &block)
35
- after_execute_callbacks.concat(methods)
36
- after_execute_callbacks << block if block_given?
41
+ def stream(input = prompt, provider: nil, model: nil, reasoning: nil, **options, &block)
42
+ stream_provider = resolved_provider(provider)
43
+ stream_options = default_stream_options(model: model, reasoning: reasoning).merge(options)
44
+
45
+ stream_provider.stream(input, **stream_options, &block)
37
46
  end
38
47
 
39
- def self.before_execute_callbacks
40
- @before_execute_callbacks ||= []
48
+ def self.tools
49
+ const_defined?(:TOOLS, false) ? self::TOOLS : []
41
50
  end
42
51
 
43
- def self.after_execute_callbacks
44
- @after_execute_callbacks ||= []
52
+ def self.find_tool(name)
53
+ tools.find { |tool| tool.tool_name == name }
45
54
  end
46
55
 
47
- def self.inherited(subclass)
48
- super
49
- subclass.instance_variable_set(:@before_execute_callbacks, before_execute_callbacks.dup)
50
- subclass.instance_variable_set(:@after_execute_callbacks, after_execute_callbacks.dup)
51
- subclass.provider = provider
52
- subclass.model = model
56
+ def tools
57
+ self.class.tools.map(&:definition)
53
58
  end
54
59
 
55
- def initialize(provider = nil, model = nil)
56
- @provider = provider || self.class.provider
57
- @model = model || self.class.model
60
+ def system_prompt
61
+ nil
58
62
  end
59
63
 
60
- def run
61
- run_callbacks(:before_execute, prompt)
64
+ private
62
65
 
63
- response = stream
66
+ def find_and_execute_tool(tool_request)
67
+ tool_name = tool_request.name
68
+ tool_input = tool_request.input
69
+ tool_class = self.class.find_tool(tool_name)
64
70
 
65
- response_content = if respond_to?(:extract_response)
66
- extract_response(response)
67
- else
68
- response[:choices][0][:content]
71
+ result = begin
72
+ if tool_class
73
+ execute_tool(tool_class, tool_input)
74
+ else
75
+ "Unknown tool: #{tool_name}"
76
+ end
77
+ rescue StandardError => e
78
+ "Error executing tool: #{e.message}"
69
79
  end
80
+ ToolResult.new(
81
+ type: "tool_result",
82
+ tool_use_id: tool_request.id,
83
+ content: result,
84
+ )
85
+ end
70
86
 
71
- result = if respond_to?(:parse_response)
72
- parse_response(response_content)
73
- else
74
- response_content
75
- end
87
+ def execute_tool(tool_class, tool_input)
88
+ tool_class.new.execute(tool_input)
89
+ end
90
+
91
+ def run_tool_loop(input, provider: nil, model: nil, reasoning: nil, **options, &block)
92
+ response = stream(input, provider: provider, model: model, reasoning: reasoning, **options, &block)
76
93
 
77
- run_callbacks(:after_execute, response, response_content)
94
+ while tool_requests(response).any?
95
+ input = prompt_with_tool_results(input, response, tool_requests(response))
96
+ response = stream(input, provider: provider, model: model, reasoning: reasoning, **options, &block)
97
+ end
78
98
 
79
- result
99
+ response
80
100
  end
81
101
 
82
- def stream(provider: nil, model: nil, **options)
83
- stream_provider = provider || self.provider
84
- stream_model = model || self.model
85
- options[:model] = stream_model if stream_model
102
+ def tool_requests(response)
103
+ return [] unless response.respond_to?(:content)
86
104
 
87
- stream_provider.stream(prompt, tools: tools, system: system_prompt, **options)
105
+ response.content.select { |content| content.respond_to?(:type) && content.type == "tool_use" }
88
106
  end
89
107
 
90
- def tools
91
- nil
108
+ def prompt_with_tool_results(input, response, requests)
109
+ messages = input.is_a?(Array) ? input.dup : [ { role: "user", content: input } ]
110
+ messages << response.to_h
111
+ messages << {
112
+ role: "user",
113
+ content: requests.map { |request| find_and_execute_tool(request).to_h }
114
+ }
115
+ messages
92
116
  end
93
117
 
94
- def self.find_tool(tool_name)
95
- tools.find { |tool| tool.tool_name == tool_name }
118
+ def default_stream_options(model: nil, reasoning: nil)
119
+ {
120
+ tools: tools,
121
+ system: system_prompt,
122
+ model: resolved_model(model),
123
+ reasoning: resolved_reasoning(reasoning),
124
+ cache_key: cache_key,
125
+ cache_retention: cache_retention
126
+ }.compact
96
127
  end
97
128
 
98
- def system_prompt
99
- nil
129
+ def resolved_provider(provider)
130
+ provider || self.provider
100
131
  end
101
132
 
102
- private
133
+ def resolved_model(model)
134
+ model || self.model
135
+ end
136
+
137
+ def resolved_reasoning(reasoning)
138
+ reasoning || self.reasoning
139
+ end
103
140
 
104
141
  def run_callbacks(callback_type, *args)
105
- callbacks = self.class.send("#{callback_type}_callbacks")
106
- callbacks.each do |callback|
107
- if callback.is_a?(Proc)
142
+ self.class.public_send("#{callback_type}_callbacks").each do |callback|
143
+ case callback
144
+ when Proc
108
145
  instance_exec(*args, &callback)
109
- elsif callback.is_a?(Symbol) || callback.is_a?(String)
110
- send(callback, *args)
146
+ when Symbol, String
147
+ public_send(callback, *args)
111
148
  end
112
149
  end
113
150
  end
@@ -4,16 +4,20 @@ module LlmGateway
4
4
  module Utils
5
5
  module_function
6
6
 
7
- def deep_symbolize_keys(hash)
8
- case hash
7
+ def symbolize_keys(hash)
8
+ hash.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
9
+ end
10
+
11
+ def deep_symbolize_keys(value)
12
+ case value
9
13
  when Hash
10
- hash.each_with_object({}) do |(key, value), result|
11
- result[key.to_sym] = deep_symbolize_keys(value)
14
+ value.each_with_object({}) do |(key, nested_value), result|
15
+ result[symbolize_key(key)] = deep_symbolize_keys(nested_value)
12
16
  end
13
17
  when Array
14
- hash.map { |item| deep_symbolize_keys(item) }
18
+ value.map { |item| deep_symbolize_keys(item) }
15
19
  else
16
- hash
20
+ value
17
21
  end
18
22
  end
19
23
 
@@ -21,19 +25,118 @@ module LlmGateway
21
25
  !blank?(value)
22
26
  end
23
27
 
28
+ def presence(value)
29
+ present?(value) ? value : nil
30
+ end
31
+
24
32
  def blank?(value)
25
33
  case value
26
- when nil
34
+ when nil, false
27
35
  true
28
- when String
29
- value.strip.empty?
30
- when Array, Hash
31
- value.empty?
32
- when Numeric
36
+ when true, Numeric
33
37
  false
38
+ when String
39
+ value.match?(/\A[[:space:]]*\z/)
34
40
  else
35
- value.respond_to?(:empty?) ? value.empty? : false
41
+ value.respond_to?(:empty?) ? !!value.empty? : false
36
42
  end
37
43
  end
44
+
45
+ def symbolize_key(key)
46
+ key.respond_to?(:to_sym) ? key.to_sym : key
47
+ rescue StandardError
48
+ key
49
+ end
50
+ end
51
+ end
52
+
53
+ unless Class.method_defined?(:class_attribute)
54
+ class Class
55
+ def class_attribute(*names, instance_accessor: true, instance_reader: instance_accessor, instance_writer: instance_accessor, instance_predicate: true, default: nil)
56
+ names.each do |name|
57
+ ivar = :"@#{name}"
58
+ instance_variable_set(ivar, default)
59
+
60
+ unset = Object.new
61
+
62
+ define_singleton_method(name) do |value = unset|
63
+ unless value.equal?(unset)
64
+ instance_variable_set(ivar, value)
65
+ next value
66
+ end
67
+
68
+ if instance_variable_defined?(ivar)
69
+ instance_variable_get(ivar)
70
+ elsif superclass.respond_to?(name)
71
+ superclass.public_send(name)
72
+ end
73
+ end
74
+
75
+ define_singleton_method("#{name}=") do |value|
76
+ instance_variable_set(ivar, value)
77
+ end
78
+
79
+ define_singleton_method("#{name}?") { !!public_send(name) } if instance_predicate
80
+
81
+ if instance_reader
82
+ define_method(name) do
83
+ if instance_variable_defined?(ivar)
84
+ instance_variable_get(ivar)
85
+ else
86
+ self.class.public_send(name)
87
+ end
88
+ end
89
+ end
90
+
91
+ define_method("#{name}=") { |value| instance_variable_set(ivar, value) } if instance_writer
92
+
93
+ define_method("#{name}?") { !!public_send(name) } if instance_predicate
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ class Object
100
+ def blank?
101
+ LlmGateway::Utils.blank?(self)
102
+ end
103
+
104
+ def present?
105
+ LlmGateway::Utils.present?(self)
106
+ end
107
+
108
+ def presence
109
+ LlmGateway::Utils.presence(self)
110
+ end
111
+ end
112
+
113
+ class Hash
114
+ def symbolize_keys
115
+ transform_keys { |key| LlmGateway::Utils.symbolize_key(key) }
116
+ end
117
+
118
+ def symbolize_keys!
119
+ replace(symbolize_keys)
120
+ end
121
+
122
+ def deep_symbolize_keys
123
+ LlmGateway::Utils.deep_symbolize_keys(self)
124
+ end
125
+
126
+ def deep_symbolize_keys!
127
+ replace(deep_symbolize_keys)
128
+ end
129
+
130
+ unless method_defined?(:except)
131
+ def except(*keys)
132
+ reject { |key, _| keys.include?(key) }
133
+ end
134
+ end
135
+
136
+ unless method_defined?(:except!)
137
+ def except!(*keys)
138
+ keys.each { |key| delete(key) }
139
+ self
140
+ end
38
141
  end
39
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmGateway
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/llm_gateway.rb CHANGED
@@ -7,6 +7,10 @@ require_relative "llm_gateway/base_client"
7
7
  require_relative "llm_gateway/client"
8
8
  require_relative "llm_gateway/prompt"
9
9
  require_relative "llm_gateway/tool"
10
+ require_relative "llm_gateway/agents/event"
11
+ require_relative "llm_gateway/agents/in_memory_session_manager"
12
+ require_relative "llm_gateway/agents/file_session_manager"
13
+ require_relative "llm_gateway/agents/harness"
10
14
 
11
15
  # Load clients - order matters for inheritance
12
16
  require_relative "llm_gateway/clients/anthropic"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_gateway
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - billybonks
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-27 00:00:00.000000000 Z
11
+ date: 2026-06-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-struct
@@ -43,6 +43,7 @@ files:
43
43
  - Rakefile
44
44
  - docs/migration-guide.md
45
45
  - docs/migration_guide_0.6.0.md
46
+ - docs/migration_guide_0.7.0.md
46
47
  - lib/llm_gateway.rb
47
48
  - lib/llm_gateway/adapters/adapter.rb
48
49
  - lib/llm_gateway/adapters/anthropic/acts_like_messages.rb
@@ -75,6 +76,15 @@ files:
75
76
  - lib/llm_gateway/adapters/option_mapper.rb
76
77
  - lib/llm_gateway/adapters/stream_mapper.rb
77
78
  - lib/llm_gateway/adapters/structs.rb
79
+ - lib/llm_gateway/agents/event.rb
80
+ - lib/llm_gateway/agents/file_session_manager.rb
81
+ - lib/llm_gateway/agents/harness.rb
82
+ - lib/llm_gateway/agents/in_memory_session_manager.rb
83
+ - lib/llm_gateway/agents/tools/bash_tool.rb
84
+ - lib/llm_gateway/agents/tools/edit_tool.rb
85
+ - lib/llm_gateway/agents/tools/read_tool.rb
86
+ - lib/llm_gateway/agents/tools/tool_utils.rb
87
+ - lib/llm_gateway/agents/tools/write_tool.rb
78
88
  - lib/llm_gateway/base_client.rb
79
89
  - lib/llm_gateway/client.rb
80
90
  - lib/llm_gateway/clients/anthropic.rb