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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +53 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +168 -0
- data/CLAUDE.md +13 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +24 -0
- data/Gemfile.lock +240 -0
- data/Guardfile +72 -0
- data/LICENSE.txt +21 -0
- data/README.llm +106 -0
- data/README.md +775 -0
- data/Rakefile +18 -0
- data/lib/mcp/sse_client.rb +297 -0
- data/lib/mcp/stdio_client.rb +80 -0
- data/lib/mcp/tool.rb +67 -0
- data/lib/raix/chat_completion.rb +346 -0
- data/lib/raix/configuration.rb +71 -0
- data/lib/raix/function_dispatch.rb +132 -0
- data/lib/raix/mcp.rb +255 -0
- data/lib/raix/message_adapters/base.rb +50 -0
- data/lib/raix/predicate.rb +68 -0
- data/lib/raix/prompt_declarations.rb +166 -0
- data/lib/raix/response_format.rb +81 -0
- data/lib/raix/version.rb +5 -0
- data/lib/raix.rb +27 -0
- data/raix-openai-eight.gemspec +36 -0
- data/sig/raix.rbs +4 -0
- metadata +140 -0
@@ -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
|