raix-openai-eight 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require "active_support/core_ext/object/blank"
5
+ require "active_support/core_ext/string/filters"
6
+ require "open_router"
7
+ require "openai"
8
+
9
+ require_relative "message_adapters/base"
10
+
11
+ module Raix
12
+ class UndeclaredToolError < StandardError; end
13
+
14
+ # The `ChatCompletion` module is a Rails concern that provides a way to interact
15
+ # with the OpenRouter Chat Completion API via its client. The module includes a few
16
+ # methods that allow you to build a transcript of messages and then send them to
17
+ # the API for completion. The API will return a response that you can use however
18
+ # you see fit.
19
+ #
20
+ # When the AI responds with tool function calls instead of a text message, this
21
+ # module automatically:
22
+ # 1. Executes the requested tool functions
23
+ # 2. Adds the function results to the conversation transcript
24
+ # 3. Sends the updated transcript back to the AI for another completion
25
+ # 4. Repeats this process until the AI responds with a regular text message
26
+ #
27
+ # This automatic continuation ensures that tool calls are seamlessly integrated
28
+ # into the conversation flow. The AI can use tool results to formulate its final
29
+ # response to the user. You can limit the number of tool calls using the
30
+ # `max_tool_calls` parameter to prevent excessive function invocations.
31
+ #
32
+ # Tool functions must be defined on the class that includes this module. The
33
+ # `FunctionDispatch` module provides a Rails-like DSL for declaring these
34
+ # functions at the class level, which is cleaner than implementing them as
35
+ # instance methods.
36
+ #
37
+ # Note that some AI models can make multiple tool function calls in a single
38
+ # response. When that happens, the module executes all requested functions
39
+ # before continuing the conversation.
40
+ module ChatCompletion
41
+ extend ActiveSupport::Concern
42
+
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
47
+
48
+ class_methods do
49
+ # Returns the current configuration of this class. Falls back to global configuration for unset values.
50
+ def configuration
51
+ @configuration ||= Configuration.new(fallback: Raix.configuration)
52
+ end
53
+
54
+ # Let's you configure the class-level configuration using a block.
55
+ def configure
56
+ yield(configuration)
57
+ end
58
+ end
59
+
60
+ # Instance level access to the class-level configuration.
61
+ def configuration
62
+ self.class.configuration
63
+ end
64
+
65
+ # This method performs chat completion based on the provided transcript and parameters.
66
+ #
67
+ # @param params [Hash] The parameters for chat completion.
68
+ # @option loop [Boolean] :loop (false) DEPRECATED - The system now automatically continues after tool calls.
69
+ # @option params [Boolean] :json (false) Whether to return the parse the response as a JSON object. Will search for <json> tags in the response first, then fall back to the default JSON parsing of the entire response.
70
+ # @option params [String] :openai (nil) If non-nil, use OpenAI with the model specified in this param.
71
+ # @option params [Boolean] :raw (false) Whether to return the raw response or dig the text content.
72
+ # @option params [Array] :messages (nil) An array of messages to use instead of the transcript.
73
+ # @option tools [Array|false] :available_tools (nil) Tools to pass to the LLM. Ignored if nil (default). If false, no tools are passed. If an array, only declared tools in the array are passed.
74
+ # @option max_tool_calls [Integer] :max_tool_calls Maximum number of tool calls before forcing a text response. Defaults to the configured value.
75
+ # @return [String|Hash] The completed chat response.
76
+ def chat_completion(params: {}, loop: false, json: false, raw: false, openai: nil, save_response: true, messages: nil, available_tools: nil, max_tool_calls: nil)
77
+ # set params to default values if not provided
78
+ params[:cache_at] ||= cache_at.presence
79
+ params[:frequency_penalty] ||= frequency_penalty.presence
80
+ params[:logit_bias] ||= logit_bias.presence
81
+ params[:logprobs] ||= logprobs.presence
82
+ params[:max_completion_tokens] ||= max_completion_tokens.presence || configuration.max_completion_tokens
83
+ params[:max_tokens] ||= max_tokens.presence || configuration.max_tokens
84
+ params[:min_p] ||= min_p.presence
85
+ params[:prediction] = { type: "content", content: params[:prediction] || prediction } if params[:prediction] || prediction.present?
86
+ params[:presence_penalty] ||= presence_penalty.presence
87
+ params[:provider] ||= provider.presence
88
+ params[:repetition_penalty] ||= repetition_penalty.presence
89
+ params[:response_format] ||= response_format.presence
90
+ params[:seed] ||= seed.presence
91
+ params[:stop] ||= stop.presence
92
+ params[:temperature] ||= temperature.presence || configuration.temperature
93
+ params[:tool_choice] ||= tool_choice.presence
94
+ params[:tools] = if available_tools == false
95
+ nil
96
+ elsif available_tools.is_a?(Array)
97
+ filtered_tools(available_tools)
98
+ else
99
+ tools.presence
100
+ end
101
+ params[:top_a] ||= top_a.presence
102
+ params[:top_k] ||= top_k.presence
103
+ params[:top_logprobs] ||= top_logprobs.presence
104
+ params[:top_p] ||= top_p.presence
105
+
106
+ json = true if params[:response_format].is_a?(Raix::ResponseFormat)
107
+
108
+ if json
109
+ unless openai
110
+ params[:provider] ||= {}
111
+ params[:provider][:require_parameters] = true
112
+ end
113
+ if params[:response_format].blank?
114
+ params[:response_format] ||= {}
115
+ params[:response_format][:type] = "json_object"
116
+ end
117
+ end
118
+
119
+ # Deprecation warning for loop parameter
120
+ if loop
121
+ warn "\n\nWARNING: The 'loop' parameter is DEPRECATED and will be ignored.\nChat completions now automatically continue after tool calls until the AI provides a text response.\nUse 'max_tool_calls' to limit the number of tool calls (default: #{configuration.max_tool_calls}).\n\n"
122
+ end
123
+
124
+ # Set max_tool_calls from parameter or configuration default
125
+ self.max_tool_calls = max_tool_calls || configuration.max_tool_calls
126
+
127
+ # Reset stop_tool_calls_and_respond flag
128
+ @stop_tool_calls_and_respond = false
129
+
130
+ # Track tool call count
131
+ tool_call_count = 0
132
+
133
+ # set the model to the default if not provided
134
+ self.model ||= configuration.model
135
+
136
+ adapter = MessageAdapters::Base.new(self)
137
+
138
+ # duplicate the transcript to avoid race conditions in situations where
139
+ # chat_completion is called multiple times in parallel
140
+ # TODO: Defensive programming, ensure messages is an array
141
+ messages ||= transcript.flatten.compact
142
+ messages = messages.map { |msg| adapter.transform(msg) }.dup
143
+ raise "Can't complete an empty transcript" if messages.blank?
144
+
145
+ begin
146
+ response = if openai
147
+ openai_request(params:, model: openai, messages:)
148
+ else
149
+ openrouter_request(params:, model:, messages:)
150
+ end
151
+ retry_count = 0
152
+ content = nil
153
+
154
+ # no need for additional processing if streaming
155
+ return if stream && response.blank?
156
+
157
+ # tuck the full response into a thread local in case needed
158
+ Thread.current[:chat_completion_response] = response.with_indifferent_access
159
+
160
+ # TODO: add a standardized callback hook for usage events
161
+ # broadcast(:usage_event, usage_subject, self.class.name.to_s, response, premium?)
162
+
163
+ tool_calls = response.dig("choices", 0, "message", "tool_calls") || []
164
+ if tool_calls.any?
165
+ tool_call_count += tool_calls.size
166
+
167
+ # Check if we've exceeded max_tool_calls
168
+ if tool_call_count > self.max_tool_calls
169
+ # Add system message about hitting the limit
170
+ messages << { role: "system", content: "Maximum tool calls (#{self.max_tool_calls}) exceeded. Please provide a final response to the user without calling any more tools." }
171
+
172
+ # Force a final response without tools
173
+ params[:tools] = nil
174
+ response = if openai
175
+ openai_request(params:, model: openai, messages:)
176
+ else
177
+ openrouter_request(params:, model:, messages:)
178
+ end
179
+
180
+ # Process the final response
181
+ content = response.dig("choices", 0, "message", "content")
182
+ transcript << { assistant: content } if save_response
183
+ return raw ? response : content.strip
184
+ end
185
+
186
+ # Dispatch tool calls
187
+ tool_calls.each do |tool_call| # TODO: parallelize this?
188
+ # dispatch the called function
189
+ function_name = tool_call["function"]["name"]
190
+ arguments = JSON.parse(tool_call["function"]["arguments"].presence || "{}")
191
+ raise "Unauthorized function call: #{function_name}" unless self.class.functions.map { |f| f[:name].to_sym }.include?(function_name.to_sym)
192
+
193
+ dispatch_tool_function(function_name, arguments.with_indifferent_access)
194
+ end
195
+
196
+ # After executing tool calls, we need to continue the conversation
197
+ # to let the AI process the results and provide a text response.
198
+ # We continue until the AI responds with a regular assistant message
199
+ # (not another tool call request), unless stop_tool_calls_and_respond! was called.
200
+
201
+ # Use the updated transcript for the next call, not the original messages
202
+ updated_messages = transcript.flatten.compact
203
+ last_message = updated_messages.last
204
+
205
+ if !@stop_tool_calls_and_respond && (last_message[:role] != "assistant" || last_message[:tool_calls].present?)
206
+ # Send the updated transcript back to the AI
207
+ return chat_completion(
208
+ params:,
209
+ json:,
210
+ raw:,
211
+ openai:,
212
+ save_response:,
213
+ messages: nil, # Use transcript instead
214
+ available_tools:,
215
+ max_tool_calls: self.max_tool_calls - tool_call_count
216
+ )
217
+ elsif @stop_tool_calls_and_respond
218
+ # If stop_tool_calls_and_respond was set, force a final response without tools
219
+ params[:tools] = nil
220
+ response = if openai
221
+ openai_request(params:, model: openai, messages:)
222
+ else
223
+ openrouter_request(params:, model:, messages:)
224
+ end
225
+
226
+ content = response.dig("choices", 0, "message", "content")
227
+ transcript << { assistant: content } if save_response
228
+ return raw ? response : content.strip
229
+ end
230
+ end
231
+
232
+ response.tap do |res|
233
+ content = res.dig("choices", 0, "message", "content")
234
+
235
+ transcript << { assistant: content } if save_response
236
+ content = content.strip
237
+
238
+ if json
239
+ # Make automatic JSON parsing available to non-OpenAI providers that don't support the response_format parameter
240
+ content = content.match(%r{<json>(.*?)</json>}m)[1] if content.include?("<json>")
241
+
242
+ return JSON.parse(content)
243
+ end
244
+
245
+ return content unless raw
246
+ end
247
+ rescue JSON::ParserError => e
248
+ if e.message.include?("not a valid") # blank JSON
249
+ warn "Retrying blank JSON response... (#{retry_count} attempts) #{e.message}"
250
+ retry_count += 1
251
+ sleep 1 * retry_count # backoff
252
+ retry if retry_count < 3
253
+
254
+ raise e # just fail if we can't get content after 3 attempts
255
+ end
256
+
257
+ warn "Bad JSON received!!!!!!: #{content}"
258
+ raise e
259
+ rescue Faraday::BadRequestError => e
260
+ # make sure we see the actual error message on console or Honeybadger
261
+ warn "Chat completion failed!!!!!!!!!!!!!!!!: #{e.response[:body]}"
262
+ raise e
263
+ end
264
+ end
265
+
266
+ # This method returns the transcript array.
267
+ # Manually add your messages to it in the following abbreviated format
268
+ # before calling `chat_completion`.
269
+ #
270
+ # { system: "You are a pumpkin" },
271
+ # { user: "Hey what time is it?" },
272
+ # { assistant: "Sorry, pumpkins do not wear watches" }
273
+ #
274
+ # to add a function call use the following format:
275
+ # { function: { name: 'fancy_pants_function', arguments: { param: 'value' } } }
276
+ #
277
+ # to add a function result use the following format:
278
+ # { function: result, name: 'fancy_pants_function' }
279
+ #
280
+ # @return [Array] The transcript array.
281
+ def transcript
282
+ @transcript ||= []
283
+ end
284
+
285
+ # Dispatches a tool function call with the given function name and arguments.
286
+ # This method can be overridden in subclasses to customize how function calls are handled.
287
+ #
288
+ # @param function_name [String] The name of the function to call
289
+ # @param arguments [Hash] The arguments to pass to the function
290
+ # @param cache [ActiveSupport::Cache] Optional cache object
291
+ # @return [Object] The result of the function call
292
+ def dispatch_tool_function(function_name, arguments, cache: nil)
293
+ public_send(function_name, arguments, cache)
294
+ end
295
+
296
+ private
297
+
298
+ def filtered_tools(tool_names)
299
+ return nil if tool_names.blank?
300
+
301
+ requested_tools = tool_names.map(&:to_sym)
302
+ available_tool_names = tools.map { |tool| tool.dig(:function, :name).to_sym }
303
+
304
+ undeclared_tools = requested_tools - available_tool_names
305
+ raise UndeclaredToolError, "Undeclared tools: #{undeclared_tools.join(", ")}" if undeclared_tools.any?
306
+
307
+ tools.select { |tool| requested_tools.include?(tool.dig(:function, :name).to_sym) }
308
+ end
309
+
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
317
+
318
+ params[:stream] ||= stream.presence
319
+ params[:stream_options] = { include_usage: true } if params[:stream]
320
+
321
+ params.delete(:temperature) if model.start_with?("o")
322
+
323
+ configuration.openai_client.chat(parameters: params.compact.merge(model:, messages:))
324
+ end
325
+
326
+ def openrouter_request(params:, model:, messages:)
327
+ # max_completion_tokens is not supported by OpenRouter
328
+ params.delete(:max_completion_tokens)
329
+
330
+ retry_count = 0
331
+
332
+ begin
333
+ configuration.openrouter_client.complete(messages, model:, extras: params.compact, stream:)
334
+ rescue OpenRouter::ServerError => e
335
+ if e.message.include?("retry")
336
+ warn "Retrying OpenRouter request... (#{retry_count} attempts) #{e.message}"
337
+ retry_count += 1
338
+ sleep 1 * retry_count # backoff
339
+ retry if retry_count < 5
340
+ end
341
+
342
+ raise e
343
+ end
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raix
4
+ # The Configuration class holds the configuration options for the Raix gem.
5
+ class Configuration
6
+ def self.attr_accessor_with_fallback(method_name)
7
+ define_method(method_name) do
8
+ value = instance_variable_get("@#{method_name}")
9
+ return value if value
10
+ return unless fallback
11
+
12
+ fallback.public_send(method_name)
13
+ end
14
+ define_method("#{method_name}=") do |value|
15
+ instance_variable_set("@#{method_name}", value)
16
+ end
17
+ end
18
+
19
+ # The temperature option determines the randomness of the generated text.
20
+ # Higher values result in more random output.
21
+ attr_accessor_with_fallback :temperature
22
+
23
+ # The max_tokens option determines the maximum number of tokens to generate.
24
+ attr_accessor_with_fallback :max_tokens
25
+
26
+ # The max_completion_tokens option determines the maximum number of tokens to generate.
27
+ attr_accessor_with_fallback :max_completion_tokens
28
+
29
+ # The model option determines the model to use for text generation. This option
30
+ # is normally set in each class that includes the ChatCompletion module.
31
+ attr_accessor_with_fallback :model
32
+
33
+ # The openrouter_client option determines the default client to use for communication.
34
+ attr_accessor_with_fallback :openrouter_client
35
+
36
+ # The openai_client option determines the OpenAI client to use for communication.
37
+ attr_accessor_with_fallback :openai_client
38
+
39
+ # The max_tool_calls option determines the maximum number of tool calls
40
+ # before forcing a text response to prevent excessive function invocations.
41
+ attr_accessor_with_fallback :max_tool_calls
42
+
43
+ DEFAULT_MAX_TOKENS = 1000
44
+ DEFAULT_MAX_COMPLETION_TOKENS = 16_384
45
+ DEFAULT_MODEL = "meta-llama/llama-3.3-8b-instruct:free"
46
+ DEFAULT_TEMPERATURE = 0.0
47
+ DEFAULT_MAX_TOOL_CALLS = 25
48
+
49
+ # Initializes a new instance of the Configuration class with default values.
50
+ def initialize(fallback: nil)
51
+ self.temperature = DEFAULT_TEMPERATURE
52
+ self.max_completion_tokens = DEFAULT_MAX_COMPLETION_TOKENS
53
+ self.max_tokens = DEFAULT_MAX_TOKENS
54
+ self.model = DEFAULT_MODEL
55
+ self.max_tool_calls = DEFAULT_MAX_TOOL_CALLS
56
+ self.fallback = fallback
57
+ end
58
+
59
+ private
60
+
61
+ attr_accessor :fallback
62
+
63
+ def get_with_fallback(method)
64
+ value = instance_variable_get("@#{method}")
65
+ return value if value
66
+ return unless fallback
67
+
68
+ fallback.public_send(method)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ module Raix
5
+ # Provides declarative function definition for ChatCompletion classes.
6
+ #
7
+ # Example:
8
+ #
9
+ # class MeaningOfLife
10
+ # include Raix::ChatCompletion
11
+ # include Raix::FunctionDispatch
12
+ #
13
+ # function :ask_deep_thought do
14
+ # wait 236_682_000_000_000
15
+ # "The meaning of life is 42"
16
+ # end
17
+ #
18
+ # def initialize
19
+ # transcript << { user: "What is the meaning of life?" }
20
+ # chat_completion
21
+ # end
22
+ # end
23
+ module FunctionDispatch
24
+ extend ActiveSupport::Concern
25
+
26
+ class_methods do
27
+ attr_reader :functions
28
+
29
+ # Defines a function that can be dispatched by the ChatCompletion module while
30
+ # processing the response from an AI model.
31
+ #
32
+ # Declaring a function here will automatically add it (in JSON Schema format) to
33
+ # the list of tools provided to the OpenRouter Chat Completion API. The function
34
+ # will be dispatched by name, so make sure the name is unique. The function's block
35
+ # argument will be executed in the instance context of the class that includes this module.
36
+ #
37
+ # Example:
38
+ # function :google_search, "Search Google for something", query: { type: "string" } do |arguments|
39
+ # GoogleSearch.new(arguments[:query]).search
40
+ # end
41
+ #
42
+ # @param name [Symbol] The name of the function.
43
+ # @param description [String] An optional description of the function.
44
+ # @param parameters [Hash] The parameters that the function accepts.
45
+ # @param block [Proc] The block of code to execute when the function is called.
46
+ def function(name, description = nil, **parameters, &block)
47
+ @functions ||= []
48
+ @functions << begin
49
+ {
50
+ name:,
51
+ parameters: { type: "object", properties: {}, required: [] }
52
+ }.tap do |definition|
53
+ definition[:description] = description if description.present?
54
+ parameters.each do |key, value|
55
+ value = value.dup
56
+ required = value.delete(:required)
57
+ optional = value.delete(:optional)
58
+ definition[:parameters][:properties][key] = value
59
+ if required || optional == false
60
+ definition[:parameters][:required] << key
61
+ end
62
+ end
63
+ definition[:parameters].delete(:required) if definition[:parameters][:required].empty?
64
+ end
65
+ end
66
+
67
+ define_method(name) do |arguments, cache|
68
+ id = SecureRandom.uuid[0, 23]
69
+
70
+ content = if cache.present?
71
+ cache.fetch([name, arguments]) do
72
+ instance_exec(arguments, &block)
73
+ end
74
+ else
75
+ instance_exec(arguments, &block)
76
+ end
77
+
78
+ # add in one operation to prevent race condition and potential wrong
79
+ # interleaving of tool calls in multi-threaded environments
80
+ transcript << [
81
+ {
82
+ role: "assistant",
83
+ content: nil,
84
+ tool_calls: [
85
+ {
86
+ id:,
87
+ type: "function",
88
+ function: {
89
+ name:,
90
+ arguments: arguments.to_json
91
+ }
92
+ }
93
+ ]
94
+ },
95
+ {
96
+ role: "tool",
97
+ tool_call_id: id,
98
+ name:,
99
+ content: content.to_s
100
+ }
101
+ ]
102
+
103
+ # Return the content - ChatCompletion will automatically continue
104
+ # the conversation after tool execution to get a final response
105
+ content
106
+ end
107
+ end
108
+ end
109
+
110
+ included do
111
+ attr_accessor :chat_completion_args
112
+ end
113
+
114
+ def chat_completion(**chat_completion_args)
115
+ self.chat_completion_args = chat_completion_args
116
+ super
117
+ end
118
+
119
+ # Stops the automatic continuation of chat completions after this function call.
120
+ # Useful when you want to halt processing within a function and force the AI
121
+ # to provide a text response without making additional tool calls.
122
+ def stop_tool_calls_and_respond!
123
+ @stop_tool_calls_and_respond = true
124
+ end
125
+
126
+ def tools
127
+ return [] unless self.class.functions
128
+
129
+ self.class.functions.map { |function| { type: "function", function: } }
130
+ end
131
+ end
132
+ end