llm_gateway 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +350 -43
  4. data/docs/migration_guide_0.6.0.md +386 -0
  5. data/docs/migration_guide_0.7.0.md +193 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +8 -11
  7. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  8. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +61 -11
  9. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  11. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  12. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +132 -39
  13. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  14. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +40 -16
  15. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  16. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  17. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +173 -24
  18. data/lib/llm_gateway/adapters/stream_mapper.rb +9 -2
  19. data/lib/llm_gateway/adapters/structs.rb +140 -55
  20. data/lib/llm_gateway/agents/event.rb +105 -0
  21. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  22. data/lib/llm_gateway/agents/harness.rb +176 -0
  23. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  24. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  25. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  26. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  27. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  28. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  29. data/lib/llm_gateway/base_client.rb +5 -7
  30. data/lib/llm_gateway/clients/anthropic.rb +10 -9
  31. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  32. data/lib/llm_gateway/clients/groq.rb +8 -6
  33. data/lib/llm_gateway/clients/openai.rb +22 -20
  34. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  35. data/lib/llm_gateway/prompt.rb +107 -52
  36. data/lib/llm_gateway/utils.rb +116 -13
  37. data/lib/llm_gateway/version.rb +1 -1
  38. data/lib/llm_gateway.rb +7 -21
  39. metadata +13 -2
@@ -2,94 +2,149 @@
2
2
 
3
3
  module LlmGateway
4
4
  class Prompt
5
- attr_reader :model
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
9
  def self.before_execute(*methods, &block)
8
- before_execute_callbacks.concat(methods)
9
- before_execute_callbacks << block if block_given?
10
+ self.before_execute_callbacks += methods
11
+ self.before_execute_callbacks += [ block ] if block_given?
10
12
  end
11
13
 
12
14
  def self.after_execute(*methods, &block)
13
- after_execute_callbacks.concat(methods)
14
- after_execute_callbacks << block if block_given?
15
+ self.after_execute_callbacks += methods
16
+ self.after_execute_callbacks += [ block ] if block_given?
15
17
  end
16
18
 
17
- def self.before_execute_callbacks
18
- @before_execute_callbacks ||= []
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
19
25
  end
20
26
 
21
- def self.after_execute_callbacks
22
- @after_execute_callbacks ||= []
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
31
+
32
+ run_callbacks(:before_execute, input)
33
+
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
23
39
  end
24
40
 
25
- def self.inherited(subclass)
26
- super
27
- subclass.instance_variable_set(:@before_execute_callbacks, before_execute_callbacks.dup)
28
- subclass.instance_variable_set(:@after_execute_callbacks, after_execute_callbacks.dup)
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)
46
+ end
47
+
48
+ def self.tools
49
+ const_defined?(:TOOLS, false) ? self::TOOLS : []
29
50
  end
30
51
 
31
- def initialize(model)
32
- @model = model
33
- @connection = if model.is_a?(String)
34
- LlmGateway.configured_clients.values.find do |client|
35
- client.client.model_key == model
52
+ def self.find_tool(name)
53
+ tools.find { |tool| tool.tool_name == name }
54
+ end
55
+
56
+ def tools
57
+ self.class.tools.map(&:definition)
58
+ end
59
+
60
+ def system_prompt
61
+ nil
62
+ end
63
+
64
+ private
65
+
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)
70
+
71
+ result = begin
72
+ if tool_class
73
+ execute_tool(tool_class, tool_input)
74
+ else
75
+ "Unknown tool: #{tool_name}"
36
76
  end
37
- else
38
- model
77
+ rescue StandardError => e
78
+ "Error executing tool: #{e.message}"
39
79
  end
80
+ ToolResult.new(
81
+ type: "tool_result",
82
+ tool_use_id: tool_request.id,
83
+ content: result,
84
+ )
40
85
  end
41
86
 
42
- def run
43
- run_callbacks(:before_execute, prompt)
87
+ def execute_tool(tool_class, tool_input)
88
+ tool_class.new.execute(tool_input)
89
+ end
44
90
 
45
- response = post
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)
46
93
 
47
- response_content = if respond_to?(:extract_response)
48
- extract_response(response)
49
- else
50
- response[:choices][0][: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)
51
97
  end
52
98
 
53
- result = if respond_to?(:parse_response)
54
- parse_response(response_content)
55
- else
56
- response_content
57
- end
99
+ response
100
+ end
58
101
 
59
- run_callbacks(:after_execute, response, response_content)
102
+ def tool_requests(response)
103
+ return [] unless response.respond_to?(:content)
60
104
 
61
- result
105
+ response.content.select { |content| content.respond_to?(:type) && content.type == "tool_use" }
62
106
  end
63
107
 
64
- def post
65
- if @connection
66
- @connection.chat(prompt, tools: tools, system: system_prompt)
67
- else
68
- LlmGateway::Client.chat(model, prompt, tools: tools, system: system_prompt)
69
- end
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
70
116
  end
71
117
 
72
- def tools
73
- nil
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
74
127
  end
75
128
 
76
- def self.find_tool(tool_name)
77
- tools.find { |tool| tool.tool_name == tool_name }
129
+ def resolved_provider(provider)
130
+ provider || self.provider
78
131
  end
79
132
 
80
- def system_prompt
81
- nil
133
+ def resolved_model(model)
134
+ model || self.model
82
135
  end
83
136
 
84
- private
137
+ def resolved_reasoning(reasoning)
138
+ reasoning || self.reasoning
139
+ end
85
140
 
86
141
  def run_callbacks(callback_type, *args)
87
- callbacks = self.class.send("#{callback_type}_callbacks")
88
- callbacks.each do |callback|
89
- if callback.is_a?(Proc)
142
+ self.class.public_send("#{callback_type}_callbacks").each do |callback|
143
+ case callback
144
+ when Proc
90
145
  instance_exec(*args, &callback)
91
- elsif callback.is_a?(Symbol) || callback.is_a?(String)
92
- send(callback, *args)
146
+ when Symbol, String
147
+ public_send(callback, *args)
93
148
  end
94
149
  end
95
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.5.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"
@@ -100,6 +104,9 @@ module LlmGateway
100
104
  def self.build_provider(config)
101
105
  config = config.transform_keys(&:to_sym)
102
106
  provider_name = config.delete(:provider)
107
+ if config.key?(:model_key)
108
+ raise ArgumentError, "model_key is no longer a provider option; pass model: to chat/stream instead"
109
+ end
103
110
  entry = ProviderRegistry.resolve(provider_name)
104
111
 
105
112
  client = entry[:client].new(**config)
@@ -153,25 +160,4 @@ module LlmGateway
153
160
  ProviderRegistry.register("openai_codex",
154
161
  client: Clients::OpenAI,
155
162
  adapter: Adapters::OpenAICodex::ResponsesAdapter)
156
-
157
- # Backward-compatible aliases (deprecated)
158
- ProviderRegistry.register("anthropic_apikey_messages",
159
- client: Clients::Anthropic,
160
- adapter: Adapters::Anthropic::MessagesAdapter)
161
-
162
- ProviderRegistry.register("openai_apikey_completions",
163
- client: Clients::OpenAI,
164
- adapter: Adapters::OpenAI::ChatCompletionsAdapter)
165
-
166
- ProviderRegistry.register("openai_apikey_responses",
167
- client: Clients::OpenAI,
168
- adapter: Adapters::OpenAI::ResponsesAdapter)
169
-
170
- ProviderRegistry.register("groq_apikey_completions",
171
- client: Clients::Groq,
172
- adapter: Adapters::Groq::ChatCompletionsAdapter)
173
-
174
- ProviderRegistry.register("openai_oauth_codex",
175
- client: Clients::OpenAI,
176
- adapter: Adapters::OpenAICodex::ResponsesAdapter)
177
163
  end
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.5.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-20 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
@@ -42,6 +42,8 @@ files:
42
42
  - README.md
43
43
  - Rakefile
44
44
  - docs/migration-guide.md
45
+ - docs/migration_guide_0.6.0.md
46
+ - docs/migration_guide_0.7.0.md
45
47
  - lib/llm_gateway.rb
46
48
  - lib/llm_gateway/adapters/adapter.rb
47
49
  - lib/llm_gateway/adapters/anthropic/acts_like_messages.rb
@@ -74,6 +76,15 @@ files:
74
76
  - lib/llm_gateway/adapters/option_mapper.rb
75
77
  - lib/llm_gateway/adapters/stream_mapper.rb
76
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
77
88
  - lib/llm_gateway/base_client.rb
78
89
  - lib/llm_gateway/client.rb
79
90
  - lib/llm_gateway/clients/anthropic.rb