ai_client 0.2.4 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +50 -5
- data/examples/README.md +1 -0
- data/examples/tools.rb +123 -0
- data/lib/ai_client/chat.rb +13 -1
- data/lib/ai_client/configuration.rb +26 -2
- data/lib/ai_client/function.rb +100 -0
- data/lib/ai_client/llm.rb +15 -0
- data/lib/ai_client/logger_middleware.rb +13 -0
- data/lib/ai_client/middleware.rb +25 -2
- data/lib/ai_client/open_router_extensions.rb +89 -8
- data/lib/ai_client/retry_middleware.rb +15 -0
- data/lib/ai_client/tool.rb +18 -0
- data/lib/ai_client/version.rb +1 -1
- data/lib/ai_client.rb +69 -3
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2453f748447f37e755f0087615c845570fe3ea3c4bd06a687947dceedee3e89b
|
4
|
+
data.tar.gz: 79359bcd209448add248514d9a8ee5dc4e0fa69833666df0e779715b574d450a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 28007804ea1e223b22846cc199c4bd14d7349f5e051bbc7007ced3641e7174c3fac59b7a225bc4926ffc68d5018a35ed96a8809bf959c8007e978bc831770b9a
|
7
|
+
data.tar.gz: 18ce04f9f83d0c12caadab051b81c48f7dbafa73ff0b2a34df28e3ddfb5ff3c088dcec019a15bc2f2dfc909bb6db0a308b512b6711baaab058629705a1448040
|
data/CHANGELOG.md
CHANGED
@@ -1,6 +1,15 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
3
|
## Released
|
4
|
+
|
5
|
+
### [0.3.0] - 2024-10-13
|
6
|
+
- Breaking Change
|
7
|
+
- Added new class AiClient::Function to encapsulate the callback functions used as tools in chats.
|
8
|
+
- Updated the examples/tools.rb file to use the new function class
|
9
|
+
|
10
|
+
### [0.2.5] - 2024-10-11
|
11
|
+
- Added examples/tools.rb to demonstrate use of function callbacks to provide information to the LLM when it needs it.
|
12
|
+
|
4
13
|
### [0.2.4] - 2024-10-10
|
5
14
|
- constrained gem omniai-openai to version 1.8.3+ for access to open_router.ai
|
6
15
|
- caching models database from open_router.ai
|
data/README.md
CHANGED
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
First and foremost a big **THANK YOU** to [Kevin Sylvestre](https://ksylvest.com/) for his gem [OmniAI](https://github.com/ksylvest/omniai) and [Olympia](https://olympia.chat/) for their [open_router gem](https://github.com/OlympiaAI/open_router) upon which this effort depends.
|
4
4
|
|
5
|
+
Version 0.3.0 has a breaking change w/r/t how [Callback Functions (aka Tools)](#callback-functions-aka-tools) are defined and used.
|
6
|
+
|
5
7
|
See the [change log](CHANGELOG.md) for recent modifications.
|
6
8
|
|
7
9
|
|
@@ -33,7 +35,8 @@ See the [change log](CHANGELOG.md) for recent modifications.
|
|
33
35
|
- [transcribe](#transcribe)
|
34
36
|
- [Options](#options)
|
35
37
|
- [Advanced Prompts](#advanced-prompts)
|
36
|
-
- [
|
38
|
+
- [Callback Functions (aka Tools)](#callback-functions-aka-tools)
|
39
|
+
- [Defining a Callback Function](#defining-a-callback-function)
|
37
40
|
- [Best ?? Practices](#best--practices)
|
38
41
|
- [OmniAI and OpenRouter](#omniai-and-openrouter)
|
39
42
|
- [Contributing](#contributing)
|
@@ -278,14 +281,55 @@ completion #=> 'The photos are of a cat, a dog, and a hamster.'
|
|
278
281
|
|
279
282
|
Of course if `client.config.return_raw` is true, the completion value will be the complete response object.
|
280
283
|
|
281
|
-
### Advanced Prompts with Tools
|
282
284
|
|
283
|
-
|
285
|
+
### Callback Functions (aka Tools)
|
286
|
+
|
287
|
+
With the release of version 0.3.0, the way callback functions (also referred to as tools) are defined in the `ai_client` gem has undergone significant changes. This section outlines the new approach in detail. These changes are designed to create a clearer and more robust interface for developers when working with callback functions. If you encounter any issues while updating your functions, please consult the official documentation or raise an issue in the repository.
|
288
|
+
|
289
|
+
##### Defining a Callback Function
|
290
|
+
|
291
|
+
To define a callback function, you need to create a subclass of `AiClient::Function`. In this subclass, both the `call` and `details` methods must be implemented.
|
284
292
|
|
285
|
-
|
293
|
+
**Example**
|
286
294
|
|
295
|
+
Here's an example illustrating how to define a callback function using the new convention:
|
287
296
|
|
288
|
-
|
297
|
+
```ruby
|
298
|
+
class WeatherFunction < AiClient::Function
|
299
|
+
# The call class method returns a String to be used by the LLM
|
300
|
+
def self.call(location:, unit: 'Celsius')
|
301
|
+
"#{rand(20..50)}° #{unit} in #{location}"
|
302
|
+
end
|
303
|
+
|
304
|
+
# The details method must return a hash with metadata about the function.
|
305
|
+
def self.details
|
306
|
+
{
|
307
|
+
name: 'weather',
|
308
|
+
description: "Lookup the weather in a location",
|
309
|
+
parameters: AiClient::Tool::Parameters.new(
|
310
|
+
properties: {
|
311
|
+
location: AiClient::Tool::Property.string(description: 'e.g. Toronto'),
|
312
|
+
unit: AiClient::Tool::Property.string(enum: %w[Celsius Fahrenheit]),
|
313
|
+
},
|
314
|
+
required: [:location]
|
315
|
+
)
|
316
|
+
}
|
317
|
+
end
|
318
|
+
end
|
319
|
+
|
320
|
+
# Register the WeatherFunction for use.
|
321
|
+
WeatherFunction.register
|
322
|
+
|
323
|
+
# Use the *.details[:name] value to reference the tools available for
|
324
|
+
# the LLM to use in processing the prompt.
|
325
|
+
response = AI.chat("what is the weather in London", tools: ['weather'])
|
326
|
+
```
|
327
|
+
|
328
|
+
In this example:
|
329
|
+
- The `call` method is defined to accept named parameters: `location` and `unit`. The default value for `unit` is set to `'Celsius'`.
|
330
|
+
- The `details` method provides metadata about the function, ensuring that the parameters section clearly indicates which parameters are required.
|
331
|
+
|
332
|
+
See the [examples/tools.rb file](examples/tools.rb) for additional examples.
|
289
333
|
|
290
334
|
|
291
335
|
## Best ?? Practices
|
@@ -303,6 +347,7 @@ AI.speak "warning Will Robinson! #{bad_things_happened}"
|
|
303
347
|
|
304
348
|
Using the constant for the instance allows you to reference the same client instance inside any method through out your application. Of course it does not apply to only one instance. You could assign multiple instances for different models/providers. For example you could have `AI` for your primary client and `AIbackup` for a fallback client in case you have a problem on the primary; or, maybe `Vectorizer` as a client name tied to a model specializing in embedding vectorization.
|
305
349
|
|
350
|
+
|
306
351
|
## OmniAI and OpenRouter
|
307
352
|
|
308
353
|
Both OmniAI and OpenRouter have similar goals - to provide a common interface to multiple providers and LLMs. OmniAI is a Ruby gem that supports specific providers directly using a common-ish API. You incur costs directly from those providers for which you have individual API keys (aka access tokens.) OpenRouter, on the other hand, is a web service that also establishes a common API for many providers and models; however, OpenRouter adds a small fee on top of the fee charged by those providers. You trade off cost for flexibility. With OpenRouter you only need one API key (OPEN_ROUTER_API_KEY) to access all of its supported services.
|
data/examples/README.md
CHANGED
@@ -7,6 +7,7 @@
|
|
7
7
|
| embed.rb | Demonstrates using Ollama locally to vectorize text for embeddings|
|
8
8
|
| speak.rb | Demonstrates using OpenAI's text to speech models |
|
9
9
|
| text.rb | Demonstrates text-to-text transformers "chat" |
|
10
|
+
| tools.rb | Demonstrates usage of functional callbacks (i.e. tools) |
|
10
11
|
| transcribe.rb | Uses OpenAI's audio-to-text model |
|
11
12
|
|
12
13
|
Many of these example programs show both the raw response object as well as just the
|
data/examples/tools.rb
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# examples/tools.rb
|
3
|
+
#
|
4
|
+
# Uses the AiClient::Function class to encapsulate the
|
5
|
+
# tools used as callback functions when specified in a
|
6
|
+
# chat prompt.
|
7
|
+
|
8
|
+
|
9
|
+
require_relative 'common'
|
10
|
+
|
11
|
+
AI = AiClient.new('gpt-4o')
|
12
|
+
|
13
|
+
box "Random Weather (temperature) Example"
|
14
|
+
title "Uses two named parameters to the callback function"
|
15
|
+
|
16
|
+
# Example subclass implementation
|
17
|
+
class WeatherFunction < AiClient::Function
|
18
|
+
def self.call(location:, unit: 'Celsius')
|
19
|
+
"#{rand(20..50)}° #{unit} in #{location}"
|
20
|
+
end
|
21
|
+
|
22
|
+
# Encapsulates registration details for the function
|
23
|
+
def self.details
|
24
|
+
# SMELL: reconcile regester_tool and details
|
25
|
+
{
|
26
|
+
name: 'weather', # Must be a String
|
27
|
+
description: "Lookup the weather in a location",
|
28
|
+
parameters: AiClient::Tool::Parameters.new(
|
29
|
+
properties: {
|
30
|
+
location: AiClient::Tool::Property.string(description: 'e.g. Toronto'),
|
31
|
+
unit: AiClient::Tool::Property.string(enum: %w[Celsius Fahrenheit]),
|
32
|
+
},
|
33
|
+
required: [:location]
|
34
|
+
)
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Register the tool for MyFunction
|
40
|
+
WeatherFunction.register
|
41
|
+
|
42
|
+
simple_prompt = <<~TEXT
|
43
|
+
What is the weather in "London" in Celsius and "Paris" in Fahrenheit?
|
44
|
+
Also what are some ideas for activities in both cities given the weather?
|
45
|
+
TEXT
|
46
|
+
|
47
|
+
response = AI.chat(
|
48
|
+
simple_prompt,
|
49
|
+
tools: ['weather'] # must match the details[:name] value
|
50
|
+
)
|
51
|
+
|
52
|
+
puts response
|
53
|
+
|
54
|
+
|
55
|
+
##########################################
|
56
|
+
box "Accessing a database to get information"
|
57
|
+
title "Uses one named parameter to the callback function"
|
58
|
+
|
59
|
+
class LLMDetailedFunction < AiClient::Function
|
60
|
+
def self.call(model_name:)
|
61
|
+
records = AiClient::LLM.where(id: /#{model_name}/i)&.first
|
62
|
+
records.inspect
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.details
|
66
|
+
{
|
67
|
+
name: 'llm_db',
|
68
|
+
description: 'lookup details about an LLM model name',
|
69
|
+
parameters: AiClient::Tool::Parameters.new(
|
70
|
+
properties: {
|
71
|
+
model_name: AiClient::Tool::Property.string
|
72
|
+
},
|
73
|
+
required: %i[model_name]
|
74
|
+
)
|
75
|
+
}
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Registering the LLM detail function
|
80
|
+
LLMDetailedFunction.register
|
81
|
+
|
82
|
+
simple_prompt = <<~PROMPT
|
83
|
+
Get the details on an LLM model named 'bison' from the models database
|
84
|
+
of #{AiClient::LLM.count} models. Format the details for the model
|
85
|
+
using markdown. Format pricing information in terms of number of
|
86
|
+
tokens per US dollar.
|
87
|
+
PROMPT
|
88
|
+
|
89
|
+
response = AI.chat(simple_prompt, tools: ['llm_db'])
|
90
|
+
puts response
|
91
|
+
|
92
|
+
|
93
|
+
|
94
|
+
##########################################
|
95
|
+
|
96
|
+
box "Using a function class and multiple tools"
|
97
|
+
title "Callback function has no parameters; but uses two functions"
|
98
|
+
|
99
|
+
class PerfectDateFunction < AiClient::Function
|
100
|
+
def self.call
|
101
|
+
"April 25th, it's not too hot nor too cold."
|
102
|
+
end
|
103
|
+
|
104
|
+
def self.details
|
105
|
+
{
|
106
|
+
name: 'perfect_date',
|
107
|
+
description: 'what is the perfect date'
|
108
|
+
}
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Registering the perfect date function
|
113
|
+
PerfectDateFunction.register
|
114
|
+
|
115
|
+
response = AI.chat("what is the perfect date for current weather in Paris?",
|
116
|
+
tools: %w[weather perfect_date])
|
117
|
+
puts response
|
118
|
+
puts
|
119
|
+
|
120
|
+
debug_me{[
|
121
|
+
#'AiClient::Function.registry',
|
122
|
+
'AiClient::Function.functions'
|
123
|
+
]}
|
data/lib/ai_client/chat.rb
CHANGED
@@ -10,7 +10,19 @@ class AiClient
|
|
10
10
|
# tools: @tools [Array<OmniAI::Tool>] optional
|
11
11
|
# temperature: @temperature [Float, nil] optional
|
12
12
|
|
13
|
-
def chat(messages, **params)
|
13
|
+
def chat(messages, **params)
|
14
|
+
if params.has_key? :tools
|
15
|
+
tools = params[:tools]
|
16
|
+
if tools.is_a? Array
|
17
|
+
tools.map!{|function_name| AiClient::Function.registry[function_name]}
|
18
|
+
elsif true == tools
|
19
|
+
tools = AiClient::Function.registry.values
|
20
|
+
else
|
21
|
+
raise 'what is this'
|
22
|
+
end
|
23
|
+
params[:tools] = tools
|
24
|
+
end
|
25
|
+
|
14
26
|
result = call_with_middlewares(:chat_without_middlewares, messages, **params)
|
15
27
|
@last_response = result
|
16
28
|
raw? ? result : content
|
@@ -92,7 +92,11 @@ class AiClient
|
|
92
92
|
include Hashie::Extensions::Mash::SymbolizeKeys
|
93
93
|
include Hashie::Extensions::Mash::DefineAccessors
|
94
94
|
|
95
|
-
|
95
|
+
# Saves the current configuration to the specified file.
|
96
|
+
#
|
97
|
+
# @param filepath [String] The path to the file where the configuration will be saved.
|
98
|
+
# Defaults to '~/aiclient_config.yml' if not provided.
|
99
|
+
#
|
96
100
|
def save(filepath=ENV['HOME']+'/aiclient_config.yml')
|
97
101
|
filepath = Pathname.new(filepath) unless filepath.is_a? Pathname
|
98
102
|
|
@@ -101,6 +105,13 @@ class AiClient
|
|
101
105
|
|
102
106
|
|
103
107
|
class << self
|
108
|
+
# Loads configuration from the specified YAML file.
|
109
|
+
#
|
110
|
+
# @param filepath [String] The path to the configuration file.
|
111
|
+
# Defaults to 'config.yml' if not provided.
|
112
|
+
# @return [AiClient::Config] The loaded configuration.
|
113
|
+
# @raise [ArgumentError] If the specified file does not exist.
|
114
|
+
#
|
104
115
|
def load(filepath=DEFAULT_CONFIG_FILEPATH)
|
105
116
|
filepath = Pathname.new(filepath) unless Pathname == filepath.class
|
106
117
|
if filepath.exist?
|
@@ -115,17 +126,30 @@ class AiClient
|
|
115
126
|
class << self
|
116
127
|
attr_accessor :class_config, :default_config
|
117
128
|
|
129
|
+
# Configures the AiClient with a given block.
|
130
|
+
#
|
131
|
+
# @yieldparam config [AiClient::Config] The configuration instance.
|
132
|
+
# @return [void]
|
133
|
+
#
|
118
134
|
def configure(&block)
|
119
135
|
yield(class_config)
|
120
136
|
end
|
121
137
|
|
138
|
+
# Resets the default configuration to the value defined in the class.
|
139
|
+
#
|
140
|
+
# @return [void]
|
141
|
+
#
|
122
142
|
def reset_default_config
|
123
143
|
initialize_defaults
|
124
144
|
.save(Config::DEFAULT_CONFIG_FILEPATH)
|
125
145
|
end
|
126
146
|
|
127
147
|
private
|
128
|
-
|
148
|
+
|
149
|
+
# Initializes the default configuration.
|
150
|
+
#
|
151
|
+
# @return [void]
|
152
|
+
#
|
129
153
|
def initialize_defaults
|
130
154
|
@default_config = Config.new(
|
131
155
|
logger: Logger.new(STDOUT),
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# lib/ai_client/function.rb
|
2
|
+
|
3
|
+
class AiClient
|
4
|
+
|
5
|
+
# The Function class serves as a base class for creating functions
|
6
|
+
# that can be registered and managed within the AiClient.
|
7
|
+
#
|
8
|
+
# Subclasses must implement the `call` and `details` methods
|
9
|
+
# to define their specific behavior and properties.
|
10
|
+
#
|
11
|
+
class Function
|
12
|
+
@@registry = {} # key is known by name (from details) and value is the AiClient::Tool
|
13
|
+
|
14
|
+
class << self
|
15
|
+
|
16
|
+
# Calls the function with the provided parameters.
|
17
|
+
#
|
18
|
+
# @param params [Hash] Named parameters required by the function.
|
19
|
+
# @raise [NotImplementedError] if not implemented in the subclass.
|
20
|
+
#
|
21
|
+
def call(**params)
|
22
|
+
raise NotImplementedError, "You must implement the call method"
|
23
|
+
end
|
24
|
+
|
25
|
+
# Provides the details about the function including its metadata.
|
26
|
+
#
|
27
|
+
# @return [Hash] Metadata containing details about the function.
|
28
|
+
# @raise [NotImplementedError] if not implemented in the subclass.
|
29
|
+
#
|
30
|
+
def details
|
31
|
+
raise NotImplementedError, "You must implement the details method"
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
# Registers a tool with the specified properties and parameters.
|
36
|
+
#
|
37
|
+
# This method creates an instance of AiClient::Tool with the
|
38
|
+
# function class and its details and adds it to the registry.
|
39
|
+
#
|
40
|
+
def register
|
41
|
+
this_tool = AiClient::Tool.new(
|
42
|
+
self, # This is the sub-class
|
43
|
+
**details
|
44
|
+
)
|
45
|
+
|
46
|
+
registry[known_by] = this_tool
|
47
|
+
end
|
48
|
+
alias_method :enable, :register
|
49
|
+
|
50
|
+
|
51
|
+
# Disables the function by removing its name from the registry.
|
52
|
+
#
|
53
|
+
# @return [void]
|
54
|
+
#
|
55
|
+
def disable
|
56
|
+
registry.delete(known_by)
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
# Returns a list of enabled functions.
|
61
|
+
#
|
62
|
+
# @return [Array<Symbol>] Sorted list of function names.
|
63
|
+
#
|
64
|
+
def functions
|
65
|
+
registry.keys.sort
|
66
|
+
end
|
67
|
+
|
68
|
+
|
69
|
+
# Returns the registry of currently registered functions.
|
70
|
+
# This method is private to limit access to the registry's state.
|
71
|
+
#
|
72
|
+
# @return [Hash] The registry of registered functions.
|
73
|
+
#
|
74
|
+
def registry
|
75
|
+
@@registry
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
# Returns the name under which the function is known.
|
80
|
+
#
|
81
|
+
# @return [Symbol] The symbol representation of the function's name.
|
82
|
+
#
|
83
|
+
def known_by
|
84
|
+
details[:name]
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
# Converts the class name to a symbol (e.g., MyFunction -> :my_function).
|
91
|
+
#
|
92
|
+
# @return [Symbol] The function name derived from the class name.
|
93
|
+
#
|
94
|
+
def function_name
|
95
|
+
name.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
data/lib/ai_client/llm.rb
CHANGED
@@ -9,11 +9,26 @@ class AiClient
|
|
9
9
|
DATA_PATH = Pathname.new( __dir__ + '/models.yml')
|
10
10
|
self.data = YAML.parse(DATA_PATH.read).to_ruby
|
11
11
|
|
12
|
+
# Extracts the model name from the LLM ID.
|
13
|
+
#
|
14
|
+
# @return [String] the model name.
|
15
|
+
#
|
12
16
|
def model = id.split('/')[1]
|
17
|
+
|
18
|
+
# Extracts the provider name from the LLM ID.
|
19
|
+
#
|
20
|
+
# @return [String] the provider name.
|
21
|
+
#
|
13
22
|
def provider = id.split('/')[0]
|
14
23
|
end
|
15
24
|
|
16
25
|
class << self
|
26
|
+
|
27
|
+
# Resets the LLM data by fetching models from the Orc client
|
28
|
+
# and writing it to the models.yml file.
|
29
|
+
#
|
30
|
+
# @return [void]
|
31
|
+
#
|
17
32
|
def reset_llm_data
|
18
33
|
orc_models = AiClient.orc_client.models
|
19
34
|
AiClient::LLM.data = orc_models
|
@@ -16,10 +16,23 @@ class AiClient
|
|
16
16
|
# )
|
17
17
|
|
18
18
|
class LoggingMiddleware
|
19
|
+
|
20
|
+
# Initializes the LoggingMiddleware with a logger.
|
21
|
+
#
|
22
|
+
# @param logger [Logger] The logger used for logging middleware actions.
|
23
|
+
#
|
19
24
|
def initialize(logger)
|
20
25
|
@logger = logger
|
21
26
|
end
|
22
27
|
|
28
|
+
# Calls the next middleware in the stack while logging the start and finish times.
|
29
|
+
#
|
30
|
+
# @param client [Object] The client instance.
|
31
|
+
# @param next_middleware [Proc] The next middleware to call.
|
32
|
+
# @param args [Array] The arguments passed to the middleware call, with the first being the method name.
|
33
|
+
#
|
34
|
+
# @return [Object] The result of the next middleware call.
|
35
|
+
#
|
23
36
|
def call(client, next_middleware, *args)
|
24
37
|
method_name = args.first.is_a?(Symbol) ? args.first : 'unknown method'
|
25
38
|
@logger.info("Starting #{method_name} call")
|
data/lib/ai_client/middleware.rb
CHANGED
@@ -8,9 +8,19 @@
|
|
8
8
|
# Change this so that middleware can be added
|
9
9
|
# and removed from an existing client.
|
10
10
|
|
11
|
-
|
11
|
+
# AiClient class that handles middleware functionality
|
12
|
+
# for API calls.
|
12
13
|
class AiClient
|
13
14
|
|
15
|
+
# Calls the specified method with middlewares applied.
|
16
|
+
#
|
17
|
+
# @param method [Symbol] the name of the method to be called
|
18
|
+
# @param args [Array] additional arguments for the method
|
19
|
+
# @param kwargs [Hash] named parameters for the method
|
20
|
+
# @param block [Proc] optional block to be passed to the method
|
21
|
+
#
|
22
|
+
# @return [Object] result of the method call after applying middlewares
|
23
|
+
#
|
14
24
|
def call_with_middlewares(method, *args, **kwargs, &block)
|
15
25
|
stack = self.class.middlewares.reverse.reduce(-> { send(method, *args, **kwargs, &block) }) do |next_middleware, middleware|
|
16
26
|
-> { middleware.call(self, next_middleware, *args, **kwargs) }
|
@@ -21,17 +31,30 @@ class AiClient
|
|
21
31
|
|
22
32
|
class << self
|
23
33
|
|
34
|
+
# Returns the list of middlewares applied to the client.
|
35
|
+
#
|
36
|
+
# @return [Array] list of middlewares
|
37
|
+
#
|
24
38
|
def middlewares
|
25
39
|
@middlewares ||= []
|
26
40
|
end
|
27
41
|
|
42
|
+
# Adds a middleware to the stack.
|
43
|
+
#
|
44
|
+
# @param middleware [Proc] the middleware to be added
|
45
|
+
#
|
46
|
+
# @return [void]
|
47
|
+
#
|
28
48
|
def use(middleware)
|
29
49
|
middlewares << middleware
|
30
50
|
end
|
31
51
|
|
52
|
+
# Clears all middlewares from the client.
|
53
|
+
#
|
54
|
+
# @return [void]
|
55
|
+
#
|
32
56
|
def clear_middlewares
|
33
57
|
@middlewares = []
|
34
58
|
end
|
35
59
|
end
|
36
|
-
|
37
60
|
end
|
@@ -8,14 +8,53 @@ require 'open_router'
|
|
8
8
|
require 'yaml'
|
9
9
|
|
10
10
|
class AiClient
|
11
|
+
|
12
|
+
# Retrieves the available models.
|
13
|
+
#
|
14
|
+
# @return [Array<String>] List of model IDs.
|
15
|
+
#
|
16
|
+
def models
|
17
|
+
self.class.models
|
18
|
+
end
|
19
|
+
|
20
|
+
# Retrieves the available providers.
|
21
|
+
#
|
22
|
+
# @return [Array<String>] List of provider names.
|
23
|
+
def providers
|
24
|
+
self.class.providers
|
25
|
+
end
|
26
|
+
|
27
|
+
# Retrieves model names, optionally filtered by provider.
|
28
|
+
#
|
29
|
+
# @param provider [String, nil] The provider to filter models by.
|
30
|
+
# @return [Array<String>] List of model names.
|
31
|
+
def model_names(provider = nil)
|
32
|
+
self.class.model_names(provider)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Retrieves details for a specific model.
|
36
|
+
#
|
37
|
+
# @param a_model [String] The model ID to retrieve details for.
|
38
|
+
# @return [Hash, nil] Details of the model or nil if not found.
|
39
|
+
def model_details(a_model)
|
40
|
+
self.class.model_details(a_model)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Finds models matching a given substring.
|
44
|
+
#
|
45
|
+
# @param a_model_substring [String] The substring to search for.
|
46
|
+
# @return [Array<String>] List of matching model names.
|
47
|
+
def find_model(a_model_substring)
|
48
|
+
self.class.find_model(a_model_substring)
|
49
|
+
end
|
11
50
|
|
12
|
-
def models = self.class.models
|
13
|
-
def providers = self.class.providers
|
14
|
-
def model_names(a_provider=nil) = self.class.model_names(a_provider)
|
15
|
-
def model_details(a_model) = self.class.model_details(a_model)
|
16
|
-
def find_model(a_model_substring) = self.class.find_model(a_model_substring)
|
17
51
|
|
18
52
|
class << self
|
53
|
+
|
54
|
+
# Adds OpenRouter extensions to AiClient.
|
55
|
+
#
|
56
|
+
# @return [void]
|
57
|
+
#
|
19
58
|
def add_open_router_extensions
|
20
59
|
access_token = fetch_access_token
|
21
60
|
|
@@ -25,10 +64,18 @@ class AiClient
|
|
25
64
|
initialize_orc_client
|
26
65
|
end
|
27
66
|
|
67
|
+
# Retrieves ORC client instance.
|
68
|
+
#
|
69
|
+
# @return [OpenRouter::Client] Instance of the OpenRouter client.
|
70
|
+
#
|
28
71
|
def orc_client
|
29
72
|
@orc_client ||= add_open_router_extensions || raise("OpenRouter extensions are not available")
|
30
73
|
end
|
31
74
|
|
75
|
+
# Retrieves models from the ORC client.
|
76
|
+
#
|
77
|
+
# @return [Array<Hash>] List of models.
|
78
|
+
#
|
32
79
|
def orc_models
|
33
80
|
@orc_models ||= orc_client.models
|
34
81
|
end
|
@@ -36,6 +83,11 @@ class AiClient
|
|
36
83
|
# TODO: Refactor these DB like methods to take
|
37
84
|
# advantage of AiClient::LLM
|
38
85
|
|
86
|
+
# Retrieves model names associated with a provider.
|
87
|
+
#
|
88
|
+
# @param provider [String, nil] The provider to filter models by.
|
89
|
+
# @return [Array<String>] List of model names.
|
90
|
+
#
|
39
91
|
def model_names(provider=nil)
|
40
92
|
model_ids = models.map { _1['id'] }
|
41
93
|
|
@@ -44,18 +96,36 @@ class AiClient
|
|
44
96
|
model_ids.filter_map { _1.split('/')[1] if _1.start_with?(provider.to_s.downcase) }
|
45
97
|
end
|
46
98
|
|
99
|
+
# Retrieves details of a specific model.
|
100
|
+
#
|
101
|
+
# @param model [String] The model ID to retrieve details for.
|
102
|
+
# @return [Hash, nil] Details of the model or nil if not found.
|
103
|
+
#
|
47
104
|
def model_details(model)
|
48
105
|
orc_models.find { _1['id'].include?(model) }
|
49
106
|
end
|
50
107
|
|
108
|
+
# Retrieves the available providers.
|
109
|
+
#
|
110
|
+
# @return [Array<String>] List of unique provider names.
|
111
|
+
#
|
51
112
|
def providers
|
52
113
|
@providers ||= models.map{ _1['id'].split('/')[0] }.sort.uniq
|
53
114
|
end
|
54
115
|
|
116
|
+
# Finds models matching a given substring.
|
117
|
+
#
|
118
|
+
# @param a_model_substring [String] The substring to search for.
|
119
|
+
# @return [Array<String>] List of matching model names.
|
120
|
+
#
|
55
121
|
def find_model(a_model_substring)
|
56
122
|
model_names.select{ _1.include?(a_model_substring) }
|
57
123
|
end
|
58
124
|
|
125
|
+
# Resets LLM data with the available ORC models.
|
126
|
+
#
|
127
|
+
# @return [void]
|
128
|
+
#
|
59
129
|
def reset_llm_data
|
60
130
|
LLM.data = orc_models
|
61
131
|
LLM::DATA_PATH.write(orc_models.to_yaml)
|
@@ -64,23 +134,34 @@ class AiClient
|
|
64
134
|
|
65
135
|
private
|
66
136
|
|
67
|
-
#
|
137
|
+
# Fetches the access token from environment variables.
|
138
|
+
#
|
139
|
+
# @return [String, nil] The access token or nil if not found.
|
140
|
+
#
|
68
141
|
def fetch_access_token
|
69
142
|
class_config.envar_api_key_names.open_router
|
70
143
|
.map { |key| ENV[key] }
|
71
144
|
.compact
|
72
145
|
.first
|
73
146
|
end
|
74
|
-
|
147
|
+
|
148
|
+
# Configures the OpenRouter client with the access token.
|
149
|
+
#
|
150
|
+
# @param access_token [String] The access token to configure.
|
151
|
+
# @return [void]
|
152
|
+
#
|
75
153
|
def configure_open_router(access_token)
|
76
154
|
OpenRouter.configure { |config| config.access_token = access_token }
|
77
155
|
end
|
78
156
|
|
157
|
+
# Initializes the ORC client.
|
158
|
+
#
|
159
|
+
# @return [void]
|
160
|
+
#
|
79
161
|
def initialize_orc_client
|
80
162
|
@orc_client ||= OpenRouter::Client.new
|
81
163
|
end
|
82
164
|
end
|
83
165
|
end
|
84
166
|
|
85
|
-
|
86
167
|
AiClient.add_open_router_extensions
|
@@ -11,12 +11,27 @@ class AiClient
|
|
11
11
|
# )
|
12
12
|
#
|
13
13
|
class RetryMiddleware
|
14
|
+
|
15
|
+
# Initializes a new instance of RetryMiddleware.
|
16
|
+
#
|
17
|
+
# @param max_retries [Integer] The maximum number of retries to attempt (default: 3).
|
18
|
+
# @param base_delay [Integer] The base delay in seconds before retrying (default: 2).
|
19
|
+
# @param max_delay [Integer] The maximum delay in seconds between retries (default: 16).
|
20
|
+
#
|
14
21
|
def initialize(max_retries: 3, base_delay: 2, max_delay: 16)
|
15
22
|
@max_retries = max_retries
|
16
23
|
@base_delay = base_delay
|
17
24
|
@max_delay = max_delay
|
18
25
|
end
|
19
26
|
|
27
|
+
# Calls the next middleware, retrying on specific errors.
|
28
|
+
#
|
29
|
+
# @param client [AiClient] The client instance that invokes the middleware.
|
30
|
+
# @param next_middleware [Proc] The next middleware in the chain to call.
|
31
|
+
# @param args [Array] Any additional arguments to pass to the next middleware.
|
32
|
+
#
|
33
|
+
# @raise [StandardError] Reraise the error if max retries are exceeded.
|
34
|
+
#
|
20
35
|
def call(client, next_middleware, *args)
|
21
36
|
retries = 0
|
22
37
|
begin
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# lib/ai_client/tool.rb
|
2
|
+
|
3
|
+
# TODO: Turn this into a Function class using the pattern
|
4
|
+
# in examples/tools.rb
|
5
|
+
# put the function names as symbols into a class Array
|
6
|
+
# In the AiClient class transform the tools: []
|
7
|
+
# parameter from an Array of Symbols into an Array
|
8
|
+
# of FUnction instances.
|
9
|
+
|
10
|
+
class AiClient::Tool < OmniAI::Tool
|
11
|
+
|
12
|
+
def xyzzy = self.class.xyzzy
|
13
|
+
|
14
|
+
class << self
|
15
|
+
def xyzzy = puts "Magic"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
data/lib/ai_client/version.rb
CHANGED
data/lib/ai_client.rb
CHANGED
@@ -16,6 +16,8 @@ require 'omniai/openai'
|
|
16
16
|
|
17
17
|
require 'open_router'
|
18
18
|
|
19
|
+
require_relative 'ai_client/version'
|
20
|
+
|
19
21
|
require_relative 'ai_client/chat'
|
20
22
|
require_relative 'ai_client/embed'
|
21
23
|
require_relative 'ai_client/speak'
|
@@ -23,10 +25,11 @@ require_relative 'ai_client/transcribe'
|
|
23
25
|
|
24
26
|
require_relative 'ai_client/configuration'
|
25
27
|
require_relative 'ai_client/middleware'
|
26
|
-
require_relative 'ai_client/version'
|
27
28
|
|
28
29
|
require_relative 'ai_client/open_router_extensions'
|
29
30
|
require_relative 'ai_client/llm' # SMELL: must come after the open router stuff
|
31
|
+
require_relative 'ai_client/tool'
|
32
|
+
require_relative 'ai_client/function'
|
30
33
|
|
31
34
|
# Create a generic client instance using only model name
|
32
35
|
# client = AiClient.new('gpt-3.5-turbo')
|
@@ -76,6 +79,8 @@ class AiClient
|
|
76
79
|
:timeout,
|
77
80
|
:config # Instance configuration
|
78
81
|
|
82
|
+
# Initializes a new AiClient instance.
|
83
|
+
#
|
79
84
|
# You can over-ride the class config by providing a block like this
|
80
85
|
# c = AiClient.new(...) do |config|
|
81
86
|
# config.logger = nil
|
@@ -90,6 +95,14 @@ class AiClient
|
|
90
95
|
# The options object is basically those things that the
|
91
96
|
# OmniAI clients want to see.
|
92
97
|
#
|
98
|
+
# @param model [String] The model name to use for the client.
|
99
|
+
# @param options [Hash] Optional named parameters:
|
100
|
+
# - :provider [Symbol] Specify the provider.
|
101
|
+
# - :config [String] Path to a YAML configuration file.
|
102
|
+
# - :logger [Logger] Logger instance for the client.
|
103
|
+
# - :timeout [Integer] Timeout value for requests.
|
104
|
+
# @yield [config] An optional block to configure the instance.
|
105
|
+
#
|
93
106
|
def initialize(model, **options, &block)
|
94
107
|
# Assign the instance variable @config from the class variable @@config
|
95
108
|
@config = self.class.class_config.dup
|
@@ -121,13 +134,34 @@ class AiClient
|
|
121
134
|
@last_response = nil
|
122
135
|
end
|
123
136
|
|
137
|
+
# TODO: Review these raw-ish methods are they really needed?
|
138
|
+
# raw? should be a private method ??
|
139
|
+
|
140
|
+
# Returns the last response received from the client.
|
141
|
+
#
|
142
|
+
# @return [OmniAI::Response] The last response.
|
143
|
+
#
|
124
144
|
def response = last_response
|
145
|
+
|
146
|
+
# Checks if the client is set to return raw responses.
|
147
|
+
#
|
148
|
+
# @return [Boolean] True if raw responses are to be returned.
|
125
149
|
def raw? = config.return_raw
|
126
150
|
|
151
|
+
|
152
|
+
# Sets whether to return raw responses.
|
153
|
+
#
|
154
|
+
# @param value [Boolean] The value to set for raw responses return.
|
155
|
+
#
|
127
156
|
def raw=(value)
|
128
157
|
config.return_raw = value
|
129
158
|
end
|
130
159
|
|
160
|
+
# Extracts the content from the last response based on the provider.
|
161
|
+
#
|
162
|
+
# @return [String] The extracted content.
|
163
|
+
# @raise [NotImplementedError] If content extraction is not implemented for the provider.
|
164
|
+
#
|
131
165
|
def content
|
132
166
|
case @provider
|
133
167
|
when :localai, :mistral, :ollama, :open_router, :openai
|
@@ -142,6 +176,13 @@ class AiClient
|
|
142
176
|
end
|
143
177
|
alias_method :text, :content
|
144
178
|
|
179
|
+
# Handles calls to methods that are missing on the AiClient instance.
|
180
|
+
#
|
181
|
+
# @param method_name [Symbol] The name of the method called.
|
182
|
+
# @param args [Array] Arguments passed to the method.
|
183
|
+
# @param block [Proc] Optional block associated with the method call.
|
184
|
+
# @return [Object] The result from the underlying client or raises NoMethodError.
|
185
|
+
#
|
145
186
|
def method_missing(method_name, *args, &block)
|
146
187
|
if @client.respond_to?(method_name)
|
147
188
|
result = @client.send(method_name, *args, &block)
|
@@ -152,6 +193,12 @@ class AiClient
|
|
152
193
|
end
|
153
194
|
end
|
154
195
|
|
196
|
+
# Checks if the instance responds to the missing method.
|
197
|
+
#
|
198
|
+
# @param method_name [Symbol] The name of the method to check.
|
199
|
+
# @param include_private [Boolean] Whether to include private methods in the check.
|
200
|
+
# @return [Boolean] True if the method is supported by the client, false otherwise.
|
201
|
+
#
|
155
202
|
def respond_to_missing?(method_name, include_private = false)
|
156
203
|
@client.respond_to?(method_name) || super
|
157
204
|
end
|
@@ -160,6 +207,12 @@ class AiClient
|
|
160
207
|
##############################################
|
161
208
|
private
|
162
209
|
|
210
|
+
# Validates the specified provider.
|
211
|
+
#
|
212
|
+
# @param provider [Symbol] The provider to validate.
|
213
|
+
# @return [Symbol, nil] Returns the validated provider or nil.
|
214
|
+
# @raise [ArgumentError] If the provider is unsupported.
|
215
|
+
#
|
163
216
|
def validate_provider(provider)
|
164
217
|
return nil if provider.nil?
|
165
218
|
|
@@ -171,7 +224,11 @@ class AiClient
|
|
171
224
|
provider
|
172
225
|
end
|
173
226
|
|
174
|
-
|
227
|
+
# Creates an instance of the appropriate OmniAI client based on the provider.
|
228
|
+
#
|
229
|
+
# @return [OmniAI::Client] An instance of the configured OmniAI client.
|
230
|
+
# @raise [ArgumentError] If the provider is unsupported.
|
231
|
+
#
|
175
232
|
def create_client
|
176
233
|
client_options = {
|
177
234
|
api_key: fetch_api_key,
|
@@ -209,7 +266,10 @@ class AiClient
|
|
209
266
|
end
|
210
267
|
|
211
268
|
|
212
|
-
# Similar to
|
269
|
+
# Similar to fetch_access_token but for the instance config
|
270
|
+
#
|
271
|
+
# @return [String, nil] The retrieved API key or nil if not found.
|
272
|
+
#
|
213
273
|
def fetch_api_key
|
214
274
|
config.envar_api_key_names[@provider]
|
215
275
|
&.map { |key| ENV[key] }
|
@@ -217,6 +277,12 @@ class AiClient
|
|
217
277
|
&.first
|
218
278
|
end
|
219
279
|
|
280
|
+
# Determines the provider based on the provided model.
|
281
|
+
#
|
282
|
+
# @param model [String] The model name.
|
283
|
+
# @return [Symbol] The corresponding provider.
|
284
|
+
# @raise [ArgumentError] If the model is unsupported.
|
285
|
+
#
|
220
286
|
def determine_provider(model)
|
221
287
|
config.provider_patterns.find { |provider, pattern| model.match?(pattern) }&.first ||
|
222
288
|
raise(ArgumentError, "Unsupported model: #{model}")
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ai_client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Dewayne VanHoozer
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-10-
|
11
|
+
date: 2024-10-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: active_hash
|
@@ -219,12 +219,14 @@ files:
|
|
219
219
|
- examples/embed.rb
|
220
220
|
- examples/speak.rb
|
221
221
|
- examples/text.rb
|
222
|
+
- examples/tools.rb
|
222
223
|
- examples/transcribe.rb
|
223
224
|
- lib/ai_client.rb
|
224
225
|
- lib/ai_client/chat.rb
|
225
226
|
- lib/ai_client/config.yml
|
226
227
|
- lib/ai_client/configuration.rb
|
227
228
|
- lib/ai_client/embed.rb
|
229
|
+
- lib/ai_client/function.rb
|
228
230
|
- lib/ai_client/llm.rb
|
229
231
|
- lib/ai_client/logger_middleware.rb
|
230
232
|
- lib/ai_client/middleware.rb
|
@@ -232,6 +234,7 @@ files:
|
|
232
234
|
- lib/ai_client/open_router_extensions.rb
|
233
235
|
- lib/ai_client/retry_middleware.rb
|
234
236
|
- lib/ai_client/speak.rb
|
237
|
+
- lib/ai_client/tool.rb
|
235
238
|
- lib/ai_client/transcribe.rb
|
236
239
|
- lib/ai_client/version.rb
|
237
240
|
- sig/ai_client.rbs
|