raix 1.0.3 → 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cdc9196b3303997d22de645ce00fe6484d0fedf9d06e2956f2e7177b59d11401
4
- data.tar.gz: ab99e48389368036196b6e3c338ce09df5ada2a25baa4b32b1d841477ba260eb
3
+ metadata.gz: a72cfc7cfe3564db566d644088ca64b8076898a87517da3d2a124123027717a8
4
+ data.tar.gz: e956d819cc90487a26abc5d1005ef4311d010f95fe101cc45047af331129b747
5
5
  SHA512:
6
- metadata.gz: 244865e9128cc0984221413957a3471789ff8bfbadbb70dd9639474176ccbc4789b8abdde361f24734b3ec9fea40b388aa3f93f299f96f5ff32002c14d0a290b
7
- data.tar.gz: a3023e275d0afef0260327e94f7600a9a85c85bc646875a2f822a03aaff0262354fff315f1d6a9c4572e6d0a62577d90623c7315e69556620732d1adb21c2a79
6
+ metadata.gz: 144cc757f49905ba32b5928a31a60449d8013390d365b9e16b6d88ef0e8c4eb30188a5c96fe3099890e957abae087aef00bc35a578f3ffa5d4b009103219f3cf
7
+ data.tar.gz: eca2ca58a34345fc785066482f0b00871cb105e02ff3edae3e3471b5e813fd9e15737bf5e6676374d78d961be64f170c0adc9e62cc285713aff4c90dd5f148ab
data/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## [2.0.0] - 2025-12-17
2
+
3
+ ### Breaking Changes
4
+ - **Migrated from OpenRouter/OpenAI gems to RubyLLM** - Raix now uses [RubyLLM](https://github.com/crmne/ruby_llm) as its unified backend for all LLM providers. This provides better multi-provider support and a more consistent API.
5
+ - **Configuration changes** - API keys are now configured through RubyLLM's configuration system instead of separate client instances.
6
+ - **Removed direct client dependencies** - `openrouter` and `ruby-openai` gems are no longer direct dependencies; RubyLLM handles provider connections.
7
+
8
+ ### Added
9
+ - **`before_completion` hook** - New hook system for intercepting and modifying chat completion requests before they're sent to the AI provider.
10
+ - Configure at global, class, or instance levels
11
+ - Hooks receive a `CompletionContext` with access to messages, params, and the chat completion instance
12
+ - Messages are mutable for content filtering, PII redaction, adding system prompts, etc.
13
+ - Params can be modified for dynamic model selection, A/B testing, and more
14
+ - Supports any callable object (Proc, Lambda, or object responding to `#call`)
15
+ - Use cases: database-backed configuration, logging, PII redaction, content filtering, cost tracking
16
+ - **`FunctionToolAdapter`** - New adapter for converting Raix function declarations to RubyLLM tool format
17
+ - **`TranscriptAdapter`** - New adapter for bridging Raix's abbreviated message format with standard OpenAI format
18
+
19
+ ### Changed
20
+ - Chat completions now use RubyLLM's unified API for all providers (OpenAI, Anthropic, Google, etc.)
21
+ - Improved provider detection based on model name patterns
22
+ - Streamlined internal architecture with dedicated adapters
23
+
24
+ ### Migration Guide
25
+ Update your configuration from:
26
+ ```ruby
27
+ Raix.configure do |config|
28
+ config.openrouter_client = OpenRouter::Client.new(access_token: "...")
29
+ config.openai_client = OpenAI::Client.new(access_token: "...")
30
+ end
31
+ ```
32
+
33
+ To:
34
+ ```ruby
35
+ RubyLLM.configure do |config|
36
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
37
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
38
+ # Also supports: anthropic_api_key, gemini_api_key
39
+ end
40
+ ```
41
+
1
42
  ## [1.0.2] - 2025-07-16
2
43
  ### Added
3
44
  - Added method to check for API client availability in Configuration
data/Gemfile.lock CHANGED
@@ -1,12 +1,11 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- raix (1.0.2)
4
+ raix (2.0.0)
5
5
  activesupport (>= 6.0)
6
6
  faraday-retry (~> 2.0)
7
- open_router (~> 0.2)
8
7
  ostruct
9
- ruby-openai (~> 8.1)
8
+ ruby_llm (~> 1.9)
10
9
 
11
10
  GEM
12
11
  remote: https://rubygems.org/
@@ -78,6 +77,7 @@ GEM
78
77
  rb-fsevent (~> 0.10, >= 0.10.3)
79
78
  rb-inotify (~> 0.9, >= 0.9.10)
80
79
  lumberjack (1.2.10)
80
+ marcel (1.1.0)
81
81
  method_source (1.1.0)
82
82
  minitest (5.24.0)
83
83
  multipart-post (2.4.1)
@@ -93,11 +93,6 @@ GEM
93
93
  notiffany (0.1.3)
94
94
  nenv (~> 0.1)
95
95
  shellany (~> 0.0)
96
- open_router (0.3.3)
97
- activesupport (>= 6.0)
98
- dotenv (>= 2)
99
- faraday (>= 1)
100
- faraday-multipart (>= 1)
101
96
  ostruct (0.6.1)
102
97
  parallel (1.24.0)
103
98
  parser (3.3.0.5)
@@ -148,11 +143,18 @@ GEM
148
143
  unicode-display_width (>= 2.4.0, < 3.0)
149
144
  rubocop-ast (1.31.2)
150
145
  parser (>= 3.3.0.4)
151
- ruby-openai (8.1.0)
152
- event_stream_parser (>= 0.3.0, < 2.0.0)
153
- faraday (>= 1)
154
- faraday-multipart (>= 1)
155
146
  ruby-progressbar (1.13.0)
147
+ ruby_llm (1.9.1)
148
+ base64
149
+ event_stream_parser (~> 1)
150
+ faraday (>= 1.10.0)
151
+ faraday-multipart (>= 1)
152
+ faraday-net_http (>= 1)
153
+ faraday-retry (>= 1)
154
+ marcel (~> 1.0)
155
+ ruby_llm-schema (~> 0.2.1)
156
+ zeitwerk (~> 2)
157
+ ruby_llm-schema (0.2.5)
156
158
  shellany (0.0.1)
157
159
  solargraph (0.50.0)
158
160
  backport (~> 1.2)
@@ -210,6 +212,7 @@ GEM
210
212
  yard-sorbet (0.8.1)
211
213
  sorbet-runtime (>= 0.5)
212
214
  yard (>= 0.9)
215
+ zeitwerk (2.7.3)
213
216
 
214
217
  PLATFORMS
215
218
  arm64-darwin-21
data/README.md CHANGED
@@ -6,7 +6,7 @@ Raix (pronounced "ray" because the x is silent) is a library that gives you ever
6
6
 
7
7
  Understanding how to use discrete AI components in otherwise normal code is key to productively leveraging Raix, and the subject of a book written by Raix's author Obie Fernandez, titled [Patterns of Application Development Using AI](https://leanpub.com/patterns-of-application-development-using-ai). You can easily support the ongoing development of this project by buying the book at Leanpub.
8
8
 
9
- At the moment, Raix natively supports use of either OpenAI or OpenRouter as its underlying AI provider. Eventually you will be able to specify your AI provider via an adapter, kind of like ActiveRecord maps to databases. Note that you can also use Raix to add AI capabilities to non-Rails applications as long as you include ActiveSupport as a dependency. Extracting the base code to its own standalone library without Rails dependencies is on the roadmap, but not a high priority.
9
+ Raix 2.0 is powered by [RubyLLM](https://github.com/crmne/ruby_llm), giving you unified access to OpenAI, Anthropic, Google Gemini, and dozens of other providers through OpenRouter. Note that you can use Raix to add AI capabilities to non-Rails applications as long as you include ActiveSupport as a dependency.
10
10
 
11
11
  ### Chat Completions
12
12
 
@@ -105,6 +105,148 @@ When using JSON mode with non-OpenAI providers, Raix automatically sets the `req
105
105
  => { "key": "value" }
106
106
  ```
107
107
 
108
+ ### before_completion Hook
109
+
110
+ The `before_completion` hook lets you intercept and modify chat completion requests before they're sent to the AI provider. This is useful for dynamic parameter resolution, logging, content filtering, PII redaction, and more.
111
+
112
+ #### Configuration Levels
113
+
114
+ Hooks can be configured at three levels, with later levels overriding earlier ones:
115
+
116
+ ```ruby
117
+ # Global level - applies to all chat completions
118
+ Raix.configure do |config|
119
+ config.before_completion = ->(context) {
120
+ # Return a hash of params to merge, or modify context.messages directly
121
+ { temperature: 0.7 }
122
+ }
123
+ end
124
+
125
+ # Class level - applies to all instances of a class
126
+ class MyAssistant
127
+ include Raix::ChatCompletion
128
+
129
+ configure do |config|
130
+ config.before_completion = ->(context) { { model: "gpt-4o" } }
131
+ end
132
+ end
133
+
134
+ # Instance level - applies to a single instance
135
+ assistant = MyAssistant.new
136
+ assistant.before_completion = ->(context) { { max_tokens: 500 } }
137
+ ```
138
+
139
+ When hooks exist at multiple levels, they're called in order (global → class → instance), with returned params merged together. Later hooks override earlier ones for the same parameter.
140
+
141
+ #### The CompletionContext Object
142
+
143
+ Hooks receive a `CompletionContext` object with access to:
144
+
145
+ ```ruby
146
+ context.chat_completion # The ChatCompletion instance
147
+ context.messages # Array of messages (mutable, in OpenAI format)
148
+ context.params # Hash of params (mutable)
149
+ context.transcript # The instance's transcript
150
+ context.current_model # Currently configured model
151
+ context.chat_completion_class # The class including ChatCompletion
152
+ context.configuration # The instance's configuration
153
+ ```
154
+
155
+ #### Use Cases
156
+
157
+ **Dynamic model selection from database:**
158
+
159
+ ```ruby
160
+ Raix.configure do |config|
161
+ config.before_completion = ->(context) {
162
+ settings = TenantSettings.find_by(tenant: Current.tenant)
163
+ {
164
+ model: settings.preferred_model,
165
+ temperature: settings.temperature,
166
+ max_tokens: settings.max_tokens
167
+ }
168
+ }
169
+ end
170
+ ```
171
+
172
+ **PII redaction:**
173
+
174
+ ```ruby
175
+ class SecureAssistant
176
+ include Raix::ChatCompletion
177
+
178
+ before_completion = ->(context) {
179
+ context.messages.each do |msg|
180
+ next unless msg[:content].is_a?(String)
181
+ # Redact SSN patterns
182
+ msg[:content] = msg[:content].gsub(/\d{3}-\d{2}-\d{4}/, "[SSN REDACTED]")
183
+ # Redact email addresses
184
+ msg[:content] = msg[:content].gsub(/[\w.-]+@[\w.-]+\.\w+/, "[EMAIL REDACTED]")
185
+ end
186
+ {} # Return empty hash if not modifying params
187
+ }
188
+ end
189
+ ```
190
+
191
+ **Request logging:**
192
+
193
+ ```ruby
194
+ Raix.configure do |config|
195
+ config.before_completion = ->(context) {
196
+ Rails.logger.info({
197
+ event: "chat_completion_request",
198
+ model: context.current_model,
199
+ message_count: context.messages.length,
200
+ params: context.params.except(:messages)
201
+ }.to_json)
202
+ {} # Return empty hash, just logging
203
+ }
204
+ end
205
+ ```
206
+
207
+ **Adding system prompts:**
208
+
209
+ ```ruby
210
+ assistant.before_completion = ->(context) {
211
+ context.messages.unshift({
212
+ role: "system",
213
+ content: "Always be helpful and respectful."
214
+ })
215
+ {}
216
+ }
217
+ ```
218
+
219
+ **A/B testing models:**
220
+
221
+ ```ruby
222
+ Raix.configure do |config|
223
+ config.before_completion = ->(context) {
224
+ if Flipper.enabled?(:new_model, Current.user)
225
+ { model: "gpt-4o" }
226
+ else
227
+ { model: "gpt-4o-mini" }
228
+ end
229
+ }
230
+ end
231
+ ```
232
+
233
+ Hooks can also be any object that responds to `#call`:
234
+
235
+ ```ruby
236
+ class CostTracker
237
+ def call(context)
238
+ # Track estimated cost based on message length
239
+ estimated_tokens = context.messages.sum { |m| m[:content].to_s.length / 4 }
240
+ StatsD.gauge("ai.estimated_input_tokens", estimated_tokens)
241
+ {}
242
+ end
243
+ end
244
+
245
+ Raix.configure do |config|
246
+ config.before_completion = CostTracker.new
247
+ end
248
+ ```
249
+
108
250
  ### Use of Tools/Functions
109
251
 
110
252
  The second (optional) module that you can add to your Ruby classes after `ChatCompletion` is `FunctionDispatch`. It lets you declare and implement functions to be called at the AI's discretion in a declarative, Rails-like "DSL" fashion.
@@ -711,49 +853,63 @@ If bundler is not being used to manage dependencies, install the gem by executin
711
853
 
712
854
  $ gem install raix
713
855
 
714
- If you are using the default OpenRouter API, Raix expects `Raix.configuration.openrouter_client` to initialized with the OpenRouter API client instance.
856
+ ### Configuration
715
857
 
716
- You can add an initializer to your application's `config/initializers` directory that looks like this example (setting up both providers, OpenRouter and OpenAI):
858
+ Raix 2.0 uses [RubyLLM](https://github.com/crmne/ruby_llm) as its backend for LLM provider connections. Configure your API keys through RubyLLM:
717
859
 
718
860
  ```ruby
719
- # config/initializers/raix.rb
720
- OpenRouter.configure do |config|
721
- config.faraday do |f|
722
- f.request :retry, retry_options
723
- f.response :logger, Logger.new($stdout), { headers: true, bodies: true, errors: true } do |logger|
724
- logger.filter(/(Bearer) (\S+)/, '\1[REDACTED]')
725
- end
726
- end
727
- end
728
-
729
- Raix.configure do |config|
730
- config.openrouter_client = OpenRouter::Client.new(access_token: ENV.fetch("OR_ACCESS_TOKEN", nil))
731
- config.openai_client = OpenAI::Client.new(access_token: ENV.fetch("OAI_ACCESS_TOKEN", nil)) do |f|
732
- f.request :retry, retry_options
733
- f.response :logger, Logger.new($stdout), { headers: true, bodies: true, errors: true } do |logger|
734
- logger.filter(/(Bearer) (\S+)/, '\1[REDACTED]')
735
- end
736
- end
737
- end
861
+ # config/initializers/raix.rb
862
+ RubyLLM.configure do |config|
863
+ config.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
864
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
865
+ # Optional: configure other providers
866
+ # config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
867
+ # config.gemini_api_key = ENV["GEMINI_API_KEY"]
868
+ end
738
869
  ```
739
870
 
740
- You will also need to configure the OpenRouter API access token as per the instructions here: https://github.com/OlympiaAI/open_router?tab=readme-ov-file#quickstart
871
+ Raix will automatically use the appropriate provider based on the model name:
872
+ - Models starting with `gpt-` or `o1` use OpenAI directly
873
+ - All other models route through OpenRouter
741
874
 
742
- ### Global vs class level configuration
875
+ ### Global vs Class-Level Configuration
743
876
 
744
- You can either configure Raix globally or at the class level. The global configuration is set in the initializer as shown above. You can however also override all configuration options of the `Configuration` class on the class level with the
745
- same syntax:
877
+ You can configure Raix options globally or at the class level:
746
878
 
747
879
  ```ruby
748
- class MyClass
880
+ # Global configuration
881
+ Raix.configure do |config|
882
+ config.temperature = 0.7
883
+ config.max_tokens = 1000
884
+ config.model = "gpt-4o"
885
+ config.max_tool_calls = 25
886
+ end
887
+
888
+ # Class-level configuration (overrides global)
889
+ class MyAssistant
749
890
  include Raix::ChatCompletion
750
891
 
751
892
  configure do |config|
752
- config.openrouter_client = OpenRouter::Client.new # with my special options
893
+ config.model = "anthropic/claude-3-opus"
894
+ config.temperature = 0.5
753
895
  end
754
896
  end
755
897
  ```
756
898
 
899
+ ### Upgrading from Raix 1.x
900
+
901
+ If upgrading from Raix 1.x, update your configuration from:
902
+
903
+ ```ruby
904
+ # Old 1.x configuration
905
+ Raix.configure do |config|
906
+ config.openrouter_client = OpenRouter::Client.new(access_token: "...")
907
+ config.openai_client = OpenAI::Client.new(access_token: "...")
908
+ end
909
+ ```
910
+
911
+ To the new RubyLLM-based configuration shown above.
912
+
757
913
  ## Development
758
914
 
759
915
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -138,7 +138,7 @@ module Raix
138
138
  # Process SSE buffer for complete events
139
139
  def process_sse_buffer
140
140
  while (idx = @buffer.index("\n\n"))
141
- event_text = @buffer.slice!(0..idx + 1)
141
+ event_text = @buffer.slice!(0..(idx + 1))
142
142
  event_type, event_data = parse_sse_fields(event_text)
143
143
 
144
144
  case event_type
@@ -3,10 +3,12 @@
3
3
  require "active_support/concern"
4
4
  require "active_support/core_ext/object/blank"
5
5
  require "active_support/core_ext/string/filters"
6
- require "open_router"
7
- require "openai"
6
+ require "active_support/core_ext/hash/indifferent_access"
7
+ require "ruby_llm"
8
8
 
9
9
  require_relative "message_adapters/base"
10
+ require_relative "transcript_adapter"
11
+ require_relative "function_tool_adapter"
10
12
 
11
13
  module Raix
12
14
  class UndeclaredToolError < StandardError; end
@@ -40,10 +42,10 @@ module Raix
40
42
  module ChatCompletion
41
43
  extend ActiveSupport::Concern
42
44
 
43
- attr_accessor :cache_at, :frequency_penalty, :logit_bias, :logprobs, :loop, :min_p, :model, :presence_penalty,
44
- :prediction, :repetition_penalty, :response_format, :stream, :temperature, :max_completion_tokens,
45
- :max_tokens, :seed, :stop, :top_a, :top_k, :top_logprobs, :top_p, :tools, :available_tools, :tool_choice, :provider,
46
- :max_tool_calls, :stop_tool_calls_and_respond
45
+ attr_accessor :before_completion, :cache_at, :frequency_penalty, :logit_bias, :logprobs, :loop, :min_p, :model,
46
+ :presence_penalty, :prediction, :repetition_penalty, :response_format, :stream, :temperature,
47
+ :max_completion_tokens, :max_tokens, :seed, :stop, :top_a, :top_k, :top_logprobs, :top_p, :tools,
48
+ :available_tools, :tool_choice, :provider, :max_tool_calls, :stop_tool_calls_and_respond
47
49
 
48
50
  class_methods do
49
51
  # Returns the current configuration of this class. Falls back to global configuration for unset values.
@@ -142,12 +144,12 @@ module Raix
142
144
  messages = messages.map { |msg| adapter.transform(msg) }.dup
143
145
  raise "Can't complete an empty transcript" if messages.blank?
144
146
 
147
+ # Run before_completion hooks (global -> class -> instance)
148
+ # Hooks can modify params and messages for logging, filtering, PII redaction, etc.
149
+ run_before_completion_hooks(params, messages)
150
+
145
151
  begin
146
- response = if openai
147
- openai_request(params:, model: openai, messages:)
148
- else
149
- openrouter_request(params:, model:, messages:)
150
- end
152
+ response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
151
153
  retry_count = 0
152
154
  content = nil
153
155
 
@@ -155,7 +157,7 @@ module Raix
155
157
  return if stream && response.blank?
156
158
 
157
159
  # tuck the full response into a thread local in case needed
158
- Thread.current[:chat_completion_response] = response.with_indifferent_access
160
+ Thread.current[:chat_completion_response] = response.is_a?(Hash) ? response.with_indifferent_access : response
159
161
 
160
162
  # TODO: add a standardized callback hook for usage events
161
163
  # broadcast(:usage_event, usage_subject, self.class.name.to_s, response, premium?)
@@ -171,11 +173,7 @@ module Raix
171
173
 
172
174
  # Force a final response without tools
173
175
  params[:tools] = nil
174
- response = if openai
175
- openai_request(params:, model: openai, messages:)
176
- else
177
- openrouter_request(params:, model:, messages:)
178
- end
176
+ response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
179
177
 
180
178
  # Process the final response
181
179
  content = response.dig("choices", 0, "message", "content")
@@ -217,11 +215,7 @@ module Raix
217
215
  elsif @stop_tool_calls_and_respond
218
216
  # If stop_tool_calls_and_respond was set, force a final response without tools
219
217
  params[:tools] = nil
220
- response = if openai
221
- openai_request(params:, model: openai, messages:)
222
- else
223
- openrouter_request(params:, model:, messages:)
224
- end
218
+ response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
225
219
 
226
220
  content = response.dig("choices", 0, "message", "content")
227
221
  transcript << { assistant: content } if save_response
@@ -279,7 +273,23 @@ module Raix
279
273
  #
280
274
  # @return [Array] The transcript array.
281
275
  def transcript
282
- @transcript ||= []
276
+ @transcript ||= TranscriptAdapter.new(ruby_llm_chat)
277
+ end
278
+
279
+ # Returns the RubyLLM::Chat instance for this conversation
280
+ def ruby_llm_chat
281
+ @ruby_llm_chat ||= begin
282
+ model_id = model || configuration.model
283
+
284
+ # Determine provider based on model format or explicit openai flag
285
+ provider = if model_id.to_s.start_with?("openai/") || model_id.to_s.match?(/^gpt-/)
286
+ :openai
287
+ else
288
+ :openrouter
289
+ end
290
+
291
+ RubyLLM.chat(model: model_id, provider:, assume_model_exists: true)
292
+ end
283
293
  end
284
294
 
285
295
  # Dispatches a tool function call with the given function name and arguments.
@@ -307,42 +317,121 @@ module Raix
307
317
  tools.select { |tool| requested_tools.include?(tool.dig(:function, :name).to_sym) }
308
318
  end
309
319
 
310
- def openai_request(params:, model:, messages:)
311
- if params[:prediction]
312
- params.delete(:max_completion_tokens)
313
- else
314
- params[:max_completion_tokens] ||= params[:max_tokens]
315
- params.delete(:max_tokens)
316
- end
320
+ def run_before_completion_hooks(params, messages)
321
+ hooks = [
322
+ Raix.configuration.before_completion,
323
+ self.class.configuration.before_completion,
324
+ before_completion
325
+ ].compact
317
326
 
318
- params[:stream] ||= stream.presence
319
- params[:stream_options] = { include_usage: true } if params[:stream]
327
+ return if hooks.empty?
320
328
 
321
- params.delete(:temperature) if model.start_with?("o") || model.include?("gpt-5")
329
+ context = CompletionContext.new(
330
+ chat_completion: self,
331
+ messages:,
332
+ params:
333
+ )
322
334
 
323
- configuration.openai_client.chat(parameters: params.compact.merge(model:, messages:))
335
+ hooks.each do |hook|
336
+ result = hook.call(context) if hook.respond_to?(:call)
337
+ next unless result.is_a?(Hash)
338
+
339
+ # Handle model separately since it's passed as a keyword arg to ruby_llm_request
340
+ self.model = result[:model] if result.key?(:model)
341
+ params.merge!(result.compact)
342
+ end
324
343
  end
325
344
 
326
- def openrouter_request(params:, model:, messages:)
327
- # max_completion_tokens is not supported by OpenRouter
328
- params.delete(:max_completion_tokens)
345
+ def ruby_llm_request(params:, model:, messages:, openai_override: nil)
346
+ # Create a temporary chat instance for this request
347
+ provider = determine_provider(model, openai_override)
348
+ chat = RubyLLM.chat(model:, provider:, assume_model_exists: true)
349
+
350
+ # Apply messages to the chat
351
+ # Track if we have a user message to determine how to call ask
352
+ has_user_message = false
353
+
354
+ messages.each do |msg|
355
+ role = msg[:role] || msg["role"]
356
+ content = msg[:content] || msg["content"]
357
+
358
+ case role.to_s
359
+ when "system"
360
+ chat.with_instructions(content)
361
+ when "user"
362
+ has_user_message = true
363
+ chat.add_message(role: :user, content:)
364
+ when "assistant"
365
+ if msg[:tool_calls] || msg["tool_calls"]
366
+ chat.add_message(role: :assistant, content:, tool_calls: msg[:tool_calls] || msg["tool_calls"])
367
+ else
368
+ chat.add_message(role: :assistant, content:)
369
+ end
370
+ when "tool"
371
+ chat.add_message(
372
+ role: :tool,
373
+ content:,
374
+ tool_call_id: msg[:tool_call_id] || msg["tool_call_id"]
375
+ )
376
+ end
377
+ end
329
378
 
330
- retry_count = 0
379
+ # Apply configuration parameters
380
+ chat.with_temperature(params[:temperature]) if params[:temperature]
331
381
 
332
- params.delete(:temperature) if model.start_with?("openai/o") || model.include?("gpt-5")
382
+ # Apply additional params (RubyLLM with_params expects keyword args)
383
+ additional_params = params.compact.except(:temperature, :tools, :max_tokens, :max_completion_tokens)
384
+ chat.with_params(**additional_params) if additional_params.any?
333
385
 
334
- begin
335
- configuration.openrouter_client.complete(messages, model:, extras: params.compact, stream:)
336
- rescue OpenRouter::ServerError => e
337
- if e.message.include?("retry")
338
- warn "Retrying OpenRouter request... (#{retry_count} attempts) #{e.message}"
339
- retry_count += 1
340
- sleep 1 * retry_count # backoff
341
- retry if retry_count < 5
342
- end
386
+ # Handle tools - convert Raix function declarations to RubyLLM tools
387
+ if params[:tools].present? && respond_to?(:class) && self.class.respond_to?(:functions)
388
+ ruby_llm_tools = FunctionToolAdapter.convert_tools_for_ruby_llm(self)
389
+ ruby_llm_tools.each { |tool| chat.with_tool(tool) }
390
+ end
343
391
 
344
- raise e
392
+ # Execute the completion
393
+ if stream.present?
394
+ # Streaming mode
395
+ if has_user_message
396
+ chat.complete(&stream)
397
+ else
398
+ chat.ask(&stream)
399
+ end
400
+ nil # Return nil for streaming as per original behavior
401
+ else
402
+ # Non-streaming mode - return OpenAI-compatible response format
403
+ response_message = has_user_message ? chat.complete : chat.ask
404
+
405
+ # Convert RubyLLM response to OpenAI format for compatibility
406
+ {
407
+ "choices" => [
408
+ {
409
+ "message" => {
410
+ "role" => "assistant",
411
+ "content" => response_message.content,
412
+ "tool_calls" => response_message.tool_calls
413
+ },
414
+ "finish_reason" => response_message.tool_call? ? "tool_calls" : "stop"
415
+ }
416
+ ],
417
+ "usage" => {
418
+ "prompt_tokens" => response_message.input_tokens,
419
+ "completion_tokens" => response_message.output_tokens,
420
+ "total_tokens" => (response_message.input_tokens || 0) + (response_message.output_tokens || 0)
421
+ }
422
+ }
345
423
  end
424
+ rescue StandardError => e
425
+ warn "RubyLLM request failed: #{e.message}"
426
+ raise e
427
+ end
428
+
429
+ def determine_provider(model, openai_override)
430
+ return :openai if openai_override
431
+ return :openai if model.to_s.match?(/^gpt-/) || model.to_s.match?(/^o\d/)
432
+
433
+ # Default to openrouter for model IDs with provider prefix
434
+ :openrouter
346
435
  end
347
436
  end
348
437
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raix
4
+ # Context object passed to before_completion hooks.
5
+ # Provides access to the chat completion instance, messages, and request parameters.
6
+ # Messages can be mutated for content filtering, PII redaction, etc.
7
+ class CompletionContext
8
+ attr_reader :chat_completion, :messages, :params
9
+
10
+ def initialize(chat_completion:, messages:, params:)
11
+ @chat_completion = chat_completion
12
+ @messages = messages # mutable - hooks can modify for filtering, redaction, etc.
13
+ @params = params # mutable - hooks can modify parameters
14
+ end
15
+
16
+ # Convenience accessor for the transcript
17
+ def transcript
18
+ chat_completion.transcript
19
+ end
20
+
21
+ # Get the currently configured model
22
+ def current_model
23
+ chat_completion.model || chat_completion.configuration.model
24
+ end
25
+
26
+ # Get the class that includes ChatCompletion
27
+ def chat_completion_class
28
+ chat_completion.class
29
+ end
30
+
31
+ # Get the current configuration
32
+ def configuration
33
+ chat_completion.configuration
34
+ end
35
+ end
36
+ end
@@ -30,16 +30,24 @@ module Raix
30
30
  # is normally set in each class that includes the ChatCompletion module.
31
31
  attr_accessor_with_fallback :model
32
32
 
33
- # The openrouter_client option determines the default client to use for communication.
33
+ # DEPRECATED: Use ruby_llm_config.openrouter_api_key instead
34
34
  attr_accessor_with_fallback :openrouter_client
35
35
 
36
- # The openai_client option determines the OpenAI client to use for communication.
36
+ # DEPRECATED: Use ruby_llm_config.openai_api_key instead
37
37
  attr_accessor_with_fallback :openai_client
38
38
 
39
39
  # The max_tool_calls option determines the maximum number of tool calls
40
40
  # before forcing a text response to prevent excessive function invocations.
41
41
  attr_accessor_with_fallback :max_tool_calls
42
42
 
43
+ # Access to RubyLLM configuration
44
+ attr_accessor_with_fallback :ruby_llm_config
45
+
46
+ # A callable hook that runs before each chat completion request.
47
+ # Receives a CompletionContext and can modify params and messages.
48
+ # Use for: dynamic parameter resolution, logging, content filtering, PII redaction, etc.
49
+ attr_accessor_with_fallback :before_completion
50
+
43
51
  DEFAULT_MAX_TOKENS = 1000
44
52
  DEFAULT_MAX_COMPLETION_TOKENS = 16_384
45
53
  DEFAULT_MODEL = "meta-llama/llama-3.3-8b-instruct:free"
@@ -53,11 +61,18 @@ module Raix
53
61
  self.max_tokens = DEFAULT_MAX_TOKENS
54
62
  self.model = DEFAULT_MODEL
55
63
  self.max_tool_calls = DEFAULT_MAX_TOOL_CALLS
64
+ self.ruby_llm_config = RubyLLM.config
56
65
  self.fallback = fallback
57
66
  end
58
67
 
59
68
  def client?
60
- !!(openrouter_client || openai_client)
69
+ # Support legacy openrouter_client/openai_client or new RubyLLM config
70
+ !!(openrouter_client || openai_client || ruby_llm_configured?)
71
+ end
72
+
73
+ def ruby_llm_configured?
74
+ ruby_llm_config&.openai_api_key || ruby_llm_config&.openrouter_api_key ||
75
+ ruby_llm_config&.anthropic_api_key || ruby_llm_config&.gemini_api_key
61
76
  end
62
77
 
63
78
  private
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raix
4
+ # Adapter to convert Raix function declarations to RubyLLM::Tool instances
5
+ class FunctionToolAdapter
6
+ def self.create_tool_from_function(function_def, instance)
7
+ tool_class = Class.new(RubyLLM::Tool) do
8
+ description function_def[:description] if function_def[:description]
9
+
10
+ # Define parameters based on function definition
11
+ function_def[:parameters][:properties]&.each do |param_name, param_def|
12
+ required = function_def[:parameters][:required]&.include?(param_name)
13
+ param param_name.to_sym, type: param_def[:type], desc: param_def[:description], required:
14
+ end
15
+
16
+ # Store reference to the instance and function name
17
+ define_method(:raix_instance) { instance }
18
+ define_method(:raix_function_name) { function_def[:name] }
19
+
20
+ # Override execute to call the Raix function
21
+ define_method(:execute) do |**args|
22
+ raix_instance.public_send(raix_function_name, args.with_indifferent_access, nil)
23
+ end
24
+ end
25
+
26
+ # Set a meaningful name for the tool class
27
+ tool_class.define_singleton_method(:name) do
28
+ "Raix::GeneratedTool::#{function_def[:name].to_s.camelize}"
29
+ end
30
+
31
+ tool_instance = tool_class.new
32
+
33
+ # Override the name method to return the original function name
34
+ # This ensures RubyLLM can match the tool call from the AI
35
+ tool_instance.define_singleton_method(:name) do
36
+ function_def[:name].to_s
37
+ end
38
+
39
+ tool_instance
40
+ end
41
+
42
+ def self.convert_tools_for_ruby_llm(raix_instance)
43
+ return [] unless raix_instance.class.respond_to?(:functions)
44
+ return [] if raix_instance.class.functions.blank?
45
+
46
+ raix_instance.class.functions.map do |function_def|
47
+ create_tool_from_function(function_def, raix_instance)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raix
4
+ # Adapter to convert between Raix's transcript array format and RubyLLM's Message objects
5
+ class TranscriptAdapter
6
+ attr_reader :ruby_llm_chat
7
+
8
+ def initialize(ruby_llm_chat)
9
+ @ruby_llm_chat = ruby_llm_chat
10
+ @pending_messages = []
11
+ end
12
+
13
+ # Add a message in Raix format (hash) to the transcript
14
+ def <<(message_hash)
15
+ case message_hash
16
+ when Array
17
+ # Handle nested arrays (from function dispatch)
18
+ message_hash.each { |msg| self << msg }
19
+ when Hash
20
+ add_message_from_hash(message_hash)
21
+ end
22
+ self
23
+ end
24
+
25
+ # Return all messages in Raix-compatible format
26
+ def flatten
27
+ ruby_llm_messages = @ruby_llm_chat.messages.map { |msg| message_to_raix_format(msg) }
28
+ pending = @pending_messages.map { |msg| normalize_message_format(msg) }
29
+ (ruby_llm_messages + pending).flatten
30
+ end
31
+
32
+ # Get all messages including pending ones
33
+ def to_a
34
+ flatten
35
+ end
36
+
37
+ # Allow iteration
38
+ def compact
39
+ flatten.compact
40
+ end
41
+
42
+ # Clear all messages
43
+ def clear
44
+ @ruby_llm_chat.reset_messages!
45
+ @pending_messages.clear
46
+ self
47
+ end
48
+
49
+ # Get last message
50
+ def last
51
+ flatten.last
52
+ end
53
+
54
+ # Get size of transcript
55
+ def size
56
+ flatten.size
57
+ end
58
+
59
+ alias length size
60
+
61
+ private
62
+
63
+ def add_message_from_hash(hash)
64
+ # Raix abbreviated format: { system: "text" }, { user: "text" }, { assistant: "text" }
65
+ if hash.key?(:system) || hash.key?("system")
66
+ content = hash[:system] || hash["system"]
67
+ @ruby_llm_chat.with_instructions(content)
68
+ @pending_messages << { role: "system", content: }
69
+ elsif hash.key?(:user) || hash.key?("user")
70
+ content = hash[:user] || hash["user"]
71
+ # Don't add to ruby_llm_chat yet - wait for chat_completion call
72
+ @pending_messages << { role: "user", content: }
73
+ elsif hash.key?(:assistant) || hash.key?("assistant")
74
+ content = hash[:assistant] || hash["assistant"]
75
+ @pending_messages << { role: "assistant", content: }
76
+ elsif hash[:role] || hash["role"]
77
+ # Standard OpenAI format (tool messages, assistant with tool_calls, etc.)
78
+ @pending_messages << hash.with_indifferent_access
79
+ end
80
+ end
81
+
82
+ def message_to_raix_format(message)
83
+ # Return in Raix abbreviated format { system: "...", user: "...", assistant: "..." }
84
+ # unless it's a tool message which needs full format
85
+ if message.tool_call? || message.tool_result?
86
+ result = {
87
+ role: message.role.to_s,
88
+ content: message.content
89
+ }
90
+ result[:tool_calls] = message.tool_calls if message.tool_call?
91
+ result[:tool_call_id] = message.tool_call_id if message.tool_result?
92
+ result
93
+ else
94
+ # Use abbreviated format
95
+ { message.role.to_sym => message.content }
96
+ end
97
+ end
98
+
99
+ def normalize_message_format(msg)
100
+ # If already in abbreviated format, return as-is
101
+ return msg if msg.key?(:system) || msg.key?(:user) || msg.key?(:assistant)
102
+ return msg if msg["system"] || msg["user"] || msg["assistant"]
103
+
104
+ # If in standard format with role/content, convert to abbreviated
105
+ if msg[:role] || msg["role"]
106
+ role = (msg[:role] || msg["role"]).to_sym
107
+ content = msg[:content] || msg["content"]
108
+
109
+ # Tool messages stay in full format
110
+ if msg[:tool_calls] || msg["tool_calls"] || msg[:tool_call_id] || msg["tool_call_id"]
111
+ return msg
112
+ end
113
+
114
+ # Convert to abbreviated format
115
+ { role => content }
116
+ else
117
+ msg
118
+ end
119
+ end
120
+ end
121
+ end
data/lib/raix/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raix
4
- VERSION = "1.0.3"
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/raix.rb CHANGED
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "raix/version"
3
+ require "ruby_llm"
4
+
5
+ require_relative "raix/completion_context"
4
6
  require_relative "raix/configuration"
7
+ require_relative "raix/version"
8
+ require_relative "raix/transcript_adapter"
9
+ require_relative "raix/function_tool_adapter"
5
10
  require_relative "raix/chat_completion"
6
11
  require_relative "raix/function_dispatch"
7
12
  require_relative "raix/prompt_declarations"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raix
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-08-08 00:00:00.000000000 Z
10
+ date: 2025-12-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -37,20 +37,6 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
- - !ruby/object:Gem::Dependency
41
- name: open_router
42
- requirement: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: '0.2'
47
- type: :runtime
48
- prerelease: false
49
- version_requirements: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '0.2'
54
40
  - !ruby/object:Gem::Dependency
55
41
  name: ostruct
56
42
  requirement: !ruby/object:Gem::Requirement
@@ -66,19 +52,19 @@ dependencies:
66
52
  - !ruby/object:Gem::Version
67
53
  version: '0'
68
54
  - !ruby/object:Gem::Dependency
69
- name: ruby-openai
55
+ name: ruby_llm
70
56
  requirement: !ruby/object:Gem::Requirement
71
57
  requirements:
72
58
  - - "~>"
73
59
  - !ruby/object:Gem::Version
74
- version: '8.1'
60
+ version: '1.9'
75
61
  type: :runtime
76
62
  prerelease: false
77
63
  version_requirements: !ruby/object:Gem::Requirement
78
64
  requirements:
79
65
  - - "~>"
80
66
  - !ruby/object:Gem::Version
81
- version: '8.1'
67
+ version: '1.9'
82
68
  email:
83
69
  - obiefernandez@gmail.com
84
70
  executables: []
@@ -103,15 +89,17 @@ files:
103
89
  - lib/mcp/tool.rb
104
90
  - lib/raix.rb
105
91
  - lib/raix/chat_completion.rb
92
+ - lib/raix/completion_context.rb
106
93
  - lib/raix/configuration.rb
107
94
  - lib/raix/function_dispatch.rb
95
+ - lib/raix/function_tool_adapter.rb
108
96
  - lib/raix/mcp.rb
109
97
  - lib/raix/message_adapters/base.rb
110
98
  - lib/raix/predicate.rb
111
99
  - lib/raix/prompt_declarations.rb
112
100
  - lib/raix/response_format.rb
101
+ - lib/raix/transcript_adapter.rb
113
102
  - lib/raix/version.rb
114
- - raix.gemspec
115
103
  - sig/raix.rbs
116
104
  homepage: https://github.com/OlympiaAI/raix
117
105
  licenses:
data/raix.gemspec DELETED
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/raix/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "raix"
7
- spec.version = Raix::VERSION
8
- spec.authors = ["Obie Fernandez"]
9
- spec.email = ["obiefernandez@gmail.com"]
10
-
11
- spec.summary = "Ruby AI eXtensions"
12
- spec.homepage = "https://github.com/OlympiaAI/raix"
13
- spec.license = "MIT"
14
- spec.required_ruby_version = ">= 3.2.2"
15
-
16
- spec.metadata["homepage_uri"] = spec.homepage
17
- spec.metadata["source_code_uri"] = "https://github.com/OlympiaAI/raix"
18
- spec.metadata["changelog_uri"] = "https://github.com/OlympiaAI/raix/blob/main/CHANGELOG.md"
19
-
20
- # Specify which files should be added to the gem when it is released.
21
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
- spec.files = Dir.chdir(__dir__) do
23
- `git ls-files -z`.split("\x0").reject do |f|
24
- (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor])
25
- end
26
- end
27
- spec.bindir = "exe"
28
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
- spec.require_paths = ["lib"]
30
-
31
- spec.add_dependency "activesupport", ">= 6.0"
32
- spec.add_dependency "faraday-retry", "~> 2.0"
33
- spec.add_dependency "open_router", "~> 0.2"
34
- spec.add_dependency "ostruct"
35
- spec.add_dependency "ruby-openai", "~> 8.1"
36
- end