Skip to content

Commit 86f932f

Browse files
committed
Raix 2.0.0 - RubyLLM backend and before_completion hook
Breaking Changes: - Migrated from OpenRouter/OpenAI gems to RubyLLM for unified multi-provider support - API keys now configured through RubyLLM instead of separate client instances New Features: - Added before_completion hook for intercepting/modifying requests - Supports global, class, and instance-level configuration - Enables dynamic params, logging, PII redaction, content filtering - Added CompletionContext for hook access to messages and params - Added FunctionToolAdapter and TranscriptAdapter for RubyLLM integration Documentation: - Updated README with RubyLLM configuration and before_completion examples - Added migration guide for upgrading from 1.x
1 parent 64aaacd commit 86f932f

File tree

10 files changed

+878
-35
lines changed

10 files changed

+878
-35
lines changed

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
142
## [1.0.2] - 2025-07-16
243
### Added
344
- Added method to check for API client availability in Configuration

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
raix (1.0.3)
4+
raix (2.0.0)
55
activesupport (>= 6.0)
66
faraday-retry (~> 2.0)
77
ostruct

README.md

Lines changed: 184 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Raix (pronounced "ray" because the x is silent) is a library that gives you ever
66

77
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.
88

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.
1010

1111
### Chat Completions
1212

@@ -105,6 +105,148 @@ When using JSON mode with non-OpenAI providers, Raix automatically sets the `req
105105
=> { "key": "value" }
106106
```
107107

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+
108250
### Use of Tools/Functions
109251

110252
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
711853

712854
$ gem install raix
713855

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
715857

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:
717859

718860
```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
738869
```
739870

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
741874

742-
### Global vs class level configuration
875+
### Global vs Class-Level Configuration
743876

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:
746878

747879
```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
749890
include Raix::ChatCompletion
750891

751892
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
753895
end
754896
end
755897
```
756898

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+
757913
## Development
758914

759915
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.

lib/raix.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
require "ruby_llm"
44

5-
require_relative "raix/version"
5+
require_relative "raix/completion_context"
66
require_relative "raix/configuration"
7+
require_relative "raix/version"
78
require_relative "raix/transcript_adapter"
89
require_relative "raix/function_tool_adapter"
910
require_relative "raix/chat_completion"

lib/raix/chat_completion.rb

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ class UndeclaredToolError < StandardError; end
4242
module ChatCompletion
4343
extend ActiveSupport::Concern
4444

45-
attr_accessor :cache_at, :frequency_penalty, :logit_bias, :logprobs, :loop, :min_p, :model, :presence_penalty,
46-
:prediction, :repetition_penalty, :response_format, :stream, :temperature, :max_completion_tokens,
47-
:max_tokens, :seed, :stop, :top_a, :top_k, :top_logprobs, :top_p, :tools, :available_tools, :tool_choice, :provider,
48-
: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
4949

5050
class_methods do
5151
# Returns the current configuration of this class. Falls back to global configuration for unset values.
@@ -144,6 +144,10 @@ def chat_completion(params: {}, loop: false, json: false, raw: false, openai: ni
144144
messages = messages.map { |msg| adapter.transform(msg) }.dup
145145
raise "Can't complete an empty transcript" if messages.blank?
146146

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+
147151
begin
148152
response = ruby_llm_request(params:, model: openai || model, messages:, openai_override: openai)
149153
retry_count = 0
@@ -313,6 +317,31 @@ def filtered_tools(tool_names)
313317
tools.select { |tool| requested_tools.include?(tool.dig(:function, :name).to_sym) }
314318
end
315319

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
326+
327+
return if hooks.empty?
328+
329+
context = CompletionContext.new(
330+
chat_completion: self,
331+
messages:,
332+
params:
333+
)
334+
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
343+
end
344+
316345
def ruby_llm_request(params:, model:, messages:, openai_override: nil)
317346
# Create a temporary chat instance for this request
318347
provider = determine_provider(model, openai_override)

0 commit comments

Comments
 (0)