ai_client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b12bef21b23a46a47cc409042afd5eaae36d6cf1cdd2fae47f12fd0b958ed961
4
+ data.tar.gz: 0c70c7f7e562da50876ee65c1f1de81f6ee7d37e97a2a7c114bb692ce5d6155d
5
+ SHA512:
6
+ metadata.gz: 56d14adae8ab29719083dc7bb63edf33fa625237274edef043b3738f39757fa0c15983232c9aab0a2afbf8db33cbcafc89ed0cb39d0180d045e3c2f6f467a5fe
7
+ data.tar.gz: 5c3d314a10a6aa6f10a51b6f8eaf574dea6d108feb5b5ec64f47fb0e494cc1ebe865a7b2fbc9401aada23c692cae2346a48dbcf062e84a202e080f25f0790d9d
data/.envrc ADDED
@@ -0,0 +1,3 @@
1
+ # ai_client/,envrc
2
+
3
+ export RR=`pwd`
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-10-02
4
+
5
+ - Initial working release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # AiClient
2
+
3
+ First and foremost a big **THANK YOU** to [Kevin Sylvestre](https://ksylvest.com/) for his gem [OmniAI](https://github.com/ksylvest/omniai) upon which this effort depends.
4
+
5
+ **This is a work in progress** Its implemented as a class rather than the typical module for most gems. The `AiClient::Configuration` class is a little first draft-ish. I'm looking to bulk it up a lot. At this point I think some of the current tests are failing; but, over all `AiClien` is working. I've used early versions of it in several projects.
6
+
7
+ ## Summary
8
+
9
+ `ai_client` is a versatile Ruby gem that serves as a generic client for interacting with various AI service providers through a unified API provided by Kevin's gem `OmniAI`. The `AiClient` class is designed to simplify the integration of large language models (LLMs) into applications. `AiClient` allows developers to create instances using just the model name, greatly reducing configuration overhead.
10
+
11
+ With built-in support for popular AI providers—including OpenAI, Anthropic, Google, Mistral, LocalAI and Ollama—the gem abstracts the complexities of API interactions, offering methods for tasks such as chatting, transcription, speech synthesis, and embedding.
12
+
13
+ The middleware architecture enables customizable processing of requests and responses, making it easy to implement features like logging and retry logic. Seamlessly integrated with the `OmniAI` framework, `ai_client` empowers developers to leverage cutting-edge AI capabilities without vendor lock-in, making it an essential tool for modern AI-driven applications.
14
+
15
+ ## Installation
16
+
17
+ If you are using a Gemfile and bundler in your project just install the gem by executing:
18
+
19
+ ```bash
20
+ bundle add ai_client
21
+ ```
22
+
23
+ If bundler is not being used to manage dependencies, install the gem by executing:
24
+
25
+ ```bash
26
+ gem install ai_client
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Basic usage:
32
+
33
+ ```ruby
34
+ AI = AiClient.new('gpt-4o')
35
+ ```
36
+
37
+ That's it. Just provide the model name that you want to use. If you application is using more than one model, no worries, just create multiple AiClient instances.
38
+
39
+ ```ruby
40
+ c1 = AiClient.new('nomic-embeddings-text')
41
+ c2 = AiClient.new('gpt-4o-mini')
42
+ ```
43
+
44
+ ### What Now?
45
+
46
+ TODO: Document the methods and their options.
47
+
48
+ ```ruby
49
+ AI.chat(...)
50
+ AI.transcribe(...)
51
+ AI.speak(...)
52
+ AI.embed(...)
53
+ AI.batch_embed(...)
54
+ ```
55
+
56
+ TODO: see the [examples] directory.
57
+
58
+ ### System Environment Variables
59
+
60
+ The API keys used with each LLM provider have the pattern `XXX_API_KEY` where XXX is the name of the provided. For example `OPENAI_API_KEY1` and `ANTROPIC_API_KEY` etc.
61
+
62
+ TODO: list all providers supported and their envar
63
+
64
+ ### Options
65
+
66
+ TODO: document the options like `provider: :ollama`
67
+
68
+ ## Contributing
69
+
70
+ I can sure use your help. This industry is moving faster than I can keep up with. If you have a bug fix or new feature idea then have at it. Send me a pull request so we all can benefit from your efforts.
71
+
72
+ If you only have time to report a bug, that's fine. Just create an issue in this repo.
73
+
74
+ ## License
75
+
76
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
Binary file
@@ -0,0 +1,24 @@
1
+ # examples/common.rb
2
+ #
3
+ # Stuff common to all the examples
4
+
5
+ require_relative '../lib/ai_client'
6
+
7
+ require 'amazing_print'
8
+
9
+ require 'debug_me'
10
+ include DebugMe
11
+
12
+ def title(a_string='Something Different', chr='=')
13
+ puts
14
+ puts a_string
15
+ puts chr*a_string.size
16
+ end
17
+
18
+
19
+ def box(a_string='Something Different', chr='=')
20
+ a_string = "#{chr*2} #{a_string} #{chr*2}"
21
+ a_line = "\n" + (chr*a_string.size) + "\n"
22
+ puts "#{a_line}#{a_string}#{a_line}"
23
+ puts
24
+ end
data/examples/embed.rb ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # examples/embed.rb
3
+
4
+ require_relative 'common'
5
+ require 'matrix'
6
+ require 'kmeans-clusterer'
7
+
8
+ # We'll use only one model for this example
9
+ model = 'nomic-embed-text'
10
+ client = AiClient.new(model, provider: :ollama)
11
+
12
+ # More meaningful text samples
13
+ texts = [
14
+ "The quick brown fox jumps over the lazy dog.",
15
+ "Machine learning is a subset of artificial intelligence.",
16
+ "Natural language processing is crucial for chatbots.",
17
+ "Deep learning models require large amounts of data.",
18
+ "Quantum computing may revolutionize cryptography.",
19
+ "Renewable energy sources include solar and wind power.",
20
+ "Climate change is affecting global weather patterns.",
21
+ "Sustainable agriculture practices can help protect the environment.",
22
+ "The human genome project mapped our DNA sequence.",
23
+ "CRISPR technology allows for precise gene editing.",
24
+ "Artificial neural networks are inspired by biological brains.",
25
+ "The Internet of Things connects everyday devices to the web.",
26
+ ]
27
+
28
+ title "Generating Embeddings"
29
+
30
+ embeddings = client.batch_embed(texts, batch_size: 1)
31
+
32
+ debug_me{[
33
+ :embeddings,
34
+ 'embeddings.methods.sort'
35
+ ]}
36
+
37
+ # Helper function to compute cosine similarity
38
+ def cosine_similarity(a, b)
39
+ dot_product = a.zip(b).map { |x, y| x * y }.sum
40
+ magnitude_a = Math.sqrt(a.map { |x| x**2 }.sum)
41
+ magnitude_b = Math.sqrt(b.map { |x| x**2 }.sum)
42
+ dot_product / (magnitude_a * magnitude_b)
43
+ end
44
+
45
+ title "Clustering Embeddings"
46
+
47
+ # Convert embeddings to a format suitable for KMeans
48
+ data = embeddings.map(&:embedding)
49
+
50
+ debug_me{[
51
+ 'data.class',
52
+ 'data.size',
53
+ 'data.first.size'
54
+ ]}
55
+
56
+ # Perform K-means clustering
57
+ k = 3 # Number of clusters
58
+ kmeans = KMeansClusterer.run(k, data, labels: texts, runs: 5)
59
+
60
+ puts "Clusters:"
61
+ kmeans.clusters.each_with_index do |cluster, i|
62
+ puts "Cluster #{i + 1}:"
63
+ cluster.points.each { |p| puts " - #{p.label}" }
64
+ puts
65
+ end
66
+
67
+ title "Finding Similar Texts"
68
+ sleep 1 # Rate Limit gets exceeded without this
69
+
70
+ query_text = "Artificial intelligence and machine learning"
71
+ query_embedding = client.embed(query_text)
72
+
73
+ debug_me{[
74
+ :query_embedding,
75
+ 'query_embedding.methods.sort'
76
+ ]}
77
+
78
+ similarities = texts.zip(embeddings).map do |text, embedding|
79
+ similarity = cosine_similarity(query_embedding.embedding, embedding.embedding)
80
+ [text, similarity]
81
+ end
82
+
83
+ puts "Top 3 texts similar to '#{query_text}':"
84
+ similarities.sort_by { |_, sim| -sim }.first(3).each do |text, sim|
85
+ puts "#{text} (Similarity: #{sim.round(4)})"
86
+ end
87
+
88
+ title "Simple Classification"
89
+
90
+ # Define some categories and their representative texts
91
+ categories = {
92
+ "Technology" => "Computers, software, and digital innovations",
93
+ "Science" => "Scientific research, experiments, and discoveries",
94
+ "Environment" => "Nature, ecology, and environmental issues"
95
+ }
96
+
97
+ # Generate embeddings for category descriptions
98
+ category_embeddings = client.batch_embed(categories.values, batch_size: 1)
99
+
100
+ # Classify each text
101
+ puts "Classification results:"
102
+ texts.each do |text|
103
+ sleep 1 # DEBUG: Rate Limited
104
+ text_embedding = client.embed(text)
105
+
106
+ # Find the category with the highest similarity
107
+ best_category = categories.keys.max_by do |category|
108
+ category_index = categories.keys.index(category)
109
+ cosine_similarity(text_embedding.embedding, category_embeddings[category_index].embedding)
110
+ end
111
+
112
+ puts "#{text} => #{best_category}"
113
+ end
data/examples/speak.rb ADDED
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ # examples/speak.rb
3
+
4
+ $player = "afplay" # For MacOS
5
+
6
+ require_relative 'common'
7
+
8
+ def play(audio_file)
9
+ `#{$player} #{audio_file}`
10
+ end
11
+
12
+
13
+ models = [
14
+ 'tts-1', # OpenAI
15
+ # 'google-tts-1', # Google (placeholder, adjust as needed)
16
+ # 'elevenlabs-v1' # ElevenLabs (if supported)
17
+ ]
18
+ clients = []
19
+
20
+ models.each do |model|
21
+ clients << AiClient.new(model)
22
+ end
23
+
24
+ title "Default Configuration Text-to-Speech"
25
+
26
+ clients.each do |c|
27
+ puts "\nModel: #{c.model} (#{c.model_type}) Provider: #{c.provider}"
28
+ text = "Text to speach example using the #{c.model} by provider #{c.provider} with the default voice."
29
+ result = c.speak(text)
30
+ puts "Audio generated. Tempfile: #{result.path}"
31
+ play result.path
32
+ end
data/examples/text.rb ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env ruby
2
+ # examples/text.rb
3
+
4
+ require_relative 'common'
5
+
6
+
7
+ ###################################
8
+ ## Working with Ollama
9
+
10
+ # This is the default configuration which returns
11
+ # text content from the client.
12
+ #
13
+ AiClient.configure do |o|
14
+ o.return_raw = false
15
+ end
16
+
17
+ title "Using Mistral model with Ollama locally"
18
+
19
+ ollama_client = AiClient.new('mistral', provider: :ollama)
20
+
21
+ puts "\nModel: mistral Provider: Ollama (local)"
22
+ result = ollama_client.chat('Hello, how are you?')
23
+ puts result
24
+
25
+ puts "\nRaw response:"
26
+ puts ollama_client.response.pretty_inspect
27
+ puts
28
+
29
+
30
+
31
+ ###############################################################
32
+ ## Lets look an generic configurations based upon model name ##
33
+ ###############################################################
34
+
35
+ models = [
36
+ 'gpt-3.5-turbo', # OpenAI
37
+ 'claude-2.1', # Anthropic
38
+ 'gemini-1.5-flash', # Google
39
+ 'mistral-large-latest', # Mistral - La Platform
40
+ ]
41
+ clients = []
42
+
43
+ models.each do |model|
44
+ clients << AiClient.new(model)
45
+ end
46
+
47
+
48
+ title "Default Configuration Response to 'hello'"
49
+
50
+ clients.each do |c|
51
+ puts "\nModel: #{c.model} (#{c.model_type}) Provider: #{c.provider}"
52
+ begin
53
+ response = c.chat('hello')
54
+ puts response
55
+ rescue => e
56
+ puts e
57
+ end
58
+ end
59
+
60
+ ###################################
61
+
62
+ AiClient.configure do |o|
63
+ o.return_raw = true
64
+ end
65
+
66
+ raw_clients = []
67
+
68
+ models.each do |model|
69
+ raw_clients << AiClient.new(model)
70
+ end
71
+
72
+ puts
73
+ title "Raw Configuration Response to 'hello'"
74
+
75
+ raw_clients.each do |c|
76
+ puts "\nModel: #{c.model} (#{c.model_type}) Provider: #{c.provider}"
77
+ begin
78
+ result = c.chat('hello')
79
+ puts result.pretty_inspect
80
+ rescue => e
81
+ puts e
82
+ end
83
+ end
84
+
85
+ puts
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # examples/transcribe.rb
3
+
4
+ require_relative 'common'
5
+
6
+ box "Bethany Hamilton on Facing Fear"
7
+
8
+ audio_file = 'Bethany Hamilton.m4a' # Poor volume level
9
+
10
+ models = [
11
+ 'whisper-1', # OpenAI
12
+ # 'deepgram-nova-2' # Deepgram (if supported)
13
+ ]
14
+ clients = []
15
+
16
+ models.each do |model|
17
+ clients << AiClient.new(model)
18
+ end
19
+
20
+ title "Default Configuration Speech-to-Text"
21
+
22
+
23
+ clients.each do |c|
24
+ puts "\nModel: #{c.model} (#{c.model_type}) Provider: #{c.provider}"
25
+ result = c.transcribe(audio_file)
26
+ puts "Transcription: #{result.pretty_inspect}"
27
+ end
28
+
29
+ __END__
30
+
31
+ Tucker Carlson: How do you deal with fear?
32
+
33
+ Bethany Hamilton: Okay, so I deal with fear maybe more naturally and
34
+ better than your average human, but I would say It's not like a really
35
+ thoughtful process for me. It's truly just facing my fears and not letting my
36
+ fears like over take me so much that I get paralyzed. So I think maybe since
37
+ I, you know, when I lost my arm when I was 13 years old. I had such a deep
38
+ passion for surfing that my decision to get back in the ocean was based off
39
+ of like getting back to my passion and my love for riding waves and not just
40
+ facing my fears, you know, I had like a deeper reason like I just love doing
41
+ what I did. And so I wanted to see if it was possible with one arm. So I
42
+ truly just faced my fears and over time, I think facing them over and over
43
+ and over again. I eventually became less fearful of sharks, so to say. And
44
+ it's funny, I've heard that sharks and motivational speaking are like
45
+ people's two greatest fears. That's like the two things that I do I surf with
46
+ sharks in the ocean, or like, you know, overcome my like incident with the
47
+ shark and then I do motivational speaking, which I would say I didn't like
48
+ that at first. But eventually I overcame that like that dislike or that fear
49
+ or that uncomfortability and I think so often in life where we naturally want
50
+ to like run from discomfort, you know, we want to make things as easy and
51
+ comfortable as possible. And so if you can learn to recognize that sometimes
52
+ you can't do that and sometimes you have to like walk into uncomfortable, you
53
+ know, I find them like relationships, for example, sometimes you have to have
54
+ the uncomfortable conversations to make that relationship more beautiful. But
55
+ a lot of us just want to like avoid that instead. And in the long run, that
56
+ just makes the relationship less beautiful and less meaningful and less
57
+ filled with depth, and then eventually that relationship may dissipate.
58
+
59
+ Tucker Carlson: Absolutely right.
@@ -0,0 +1,84 @@
1
+ # ai_client/configuration.rb
2
+
3
+ require 'logger'
4
+
5
+ class AiClient
6
+ # TODO: Need a centralized service where
7
+ # metadata about LLMs are available
8
+ # via and API call. Would hope that
9
+ # the providers would add a "list"
10
+ # endpoint to their API which would
11
+ # return the metadata for all of their
12
+ # models.
13
+
14
+ PROVIDER_PATTERNS = {
15
+ anthropic: /^claude/i,
16
+ openai: /^(gpt|davinci|curie|babbage|ada|whisper|tts|dall-e)/i,
17
+ google: /^(gemini|palm)/i,
18
+ mistral: /^(mistral|codestral)/i,
19
+ localai: /^local-/i,
20
+ ollama: /(llama-|nomic)/i
21
+ }
22
+
23
+ MODEL_TYPES = {
24
+ text_to_text: /^(nomic|gpt|davinci|curie|babbage|ada|claude|gemini|palm|command|generate|j2-|mistral|codestral)/i,
25
+ speech_to_text: /^whisper/i,
26
+ text_to_speech: /^tts/i,
27
+ text_to_image: /^dall-e/i
28
+ }
29
+
30
+ class << self
31
+
32
+ def configure
33
+ yield(configuration)
34
+ end
35
+
36
+ def configuration
37
+ @configuration ||= Configuration.new
38
+ end
39
+
40
+ end
41
+
42
+
43
+
44
+
45
+ # Usage example:
46
+ # Configure general settings
47
+ # AiClient.configure do |config|
48
+ # config.logger = Logger.new('ai_client.log')
49
+ # config.return_raw = true
50
+ # end
51
+ #
52
+ # Configure provider-specific settings
53
+ # AiClient.configure do |config|
54
+ # config.configure_provider(:openai) do
55
+ # {
56
+ # organization: 'org-123',
57
+ # api_version: 'v1'
58
+ # }
59
+ # end
60
+ # end
61
+ #
62
+
63
+ class Configuration
64
+ attr_accessor :logger, :timeout, :return_raw
65
+ attr_reader :providers, :provider_patterns, :model_types
66
+
67
+ def initialize
68
+ @logger = Logger.new(STDOUT)
69
+ @timeout = nil
70
+ @return_raw = false
71
+ @providers = {}
72
+ @provider_patterns = AiClient::PROVIDER_PATTERNS.dup
73
+ @model_types = AiClient::MODEL_TYPES.dup
74
+ end
75
+
76
+ def provider(name, &block)
77
+ if block_given?
78
+ @providers[name] = block
79
+ else
80
+ @providers[name]&.call || {}
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,37 @@
1
+ # ai_client/logger_middleware.rb
2
+
3
+ class AiClient
4
+
5
+ # logger = Logger.new(STDOUT)
6
+ #
7
+ # AiClient.use(
8
+ # AiClient::LoggingMiddleware.new(logger)
9
+ # )
10
+ #
11
+ # Or, if you want to use the same logger as the AiClient:
12
+ # AiClient.use(
13
+ # AiClient::LoggingMiddleware.new(
14
+ # AiClient.configuration.logger
15
+ # )
16
+ # )
17
+
18
+ class LoggingMiddleware
19
+ def initialize(logger)
20
+ @logger = logger
21
+ end
22
+
23
+ def call(client, next_middleware, *args)
24
+ method_name = args.first.is_a?(Symbol) ? args.first : 'unknown method'
25
+ @logger.info("Starting #{method_name} call")
26
+ start_time = Time.now
27
+
28
+ result = next_middleware.call(*args)
29
+
30
+ end_time = Time.now
31
+ duration = end_time - start_time
32
+ @logger.info("Finished #{method_name} call (took #{duration.round(3)} seconds)")
33
+
34
+ result
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,37 @@
1
+ # ai_client/retry_middleware.rb
2
+
3
+ class AiClient
4
+
5
+ # AiClient.use(
6
+ # AiClient::RetryMiddleware.new(
7
+ # max_retries: 5,
8
+ # base_delay: 2,
9
+ # max_delay: 30
10
+ # )
11
+ # )
12
+ #
13
+ class RetryMiddleware
14
+ def initialize(max_retries: 3, base_delay: 2, max_delay: 16)
15
+ @max_retries = max_retries
16
+ @base_delay = base_delay
17
+ @max_delay = max_delay
18
+ end
19
+
20
+ def call(client, next_middleware, *args)
21
+ retries = 0
22
+ begin
23
+ next_middleware.call
24
+ rescue OmniAI::RateLimitError, OmniAI::NetworkError => e
25
+ if retries < @max_retries
26
+ retries += 1
27
+ delay = [@base_delay * (2 ** (retries - 1)), @max_delay].min
28
+ client.logger.warn("Retrying in #{delay} seconds due to error: #{e.message}")
29
+ sleep(delay)
30
+ retry
31
+ else
32
+ raise
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AiClient
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ai_client.rb ADDED
@@ -0,0 +1,244 @@
1
+ # ai_client.rb
2
+ # WIP: a generic client to access LLM providers
3
+ # kinda like the SaaS "open router"
4
+ #
5
+
6
+ unless defined?(DebugMe)
7
+ require 'debug_me'
8
+ include DebugMe
9
+ end
10
+
11
+ require 'omniai'
12
+ require 'omniai/anthropic'
13
+ require 'omniai/google'
14
+ require 'omniai/mistral'
15
+ require 'omniai/openai'
16
+ require_relative 'extensions/omniai-ollama'
17
+ require_relative 'extensions/omniai-localai'
18
+
19
+ require_relative 'ai_client/configuration'
20
+ require_relative 'ai_client/version'
21
+
22
+ # Create a generic client instance using only model name
23
+ # client = AiClient.new('gpt-3.5-turbo')
24
+ #
25
+ # Add middlewares
26
+ # AiClient.use(RetryMiddleware.new(max_retries: 5, base_delay: 2, max_delay: 30))
27
+ # AiClient.use(LoggingMiddleware.new(AiClient.configuration.logger))
28
+ #
29
+ # TODO: As concurrently designed the middleware must
30
+ # be set before an instance of AiClient is created.
31
+ # Any `use` commands for middleware made after
32
+ # the instance is created will not be available
33
+ # to that instance.
34
+ # Change this so that middleware can be added
35
+ # and removed from an existing client.
36
+
37
+ class AiClient
38
+
39
+ attr_reader :client, :provider, :model, :model_type, :logger, :last_response, :config
40
+
41
+ def initialize(model, config: Configuration.new, **options)
42
+ @model = model
43
+ @config = config
44
+ @provider = validate_provider(options[:provider]) || determine_provider(model)
45
+ @model_type = determine_model_type(model)
46
+
47
+ provider_config = @config.provider(@provider)
48
+
49
+ @logger = options[:logger] || @config.logger
50
+ @timeout = options[:timeout] || @config.timeout
51
+ @base_url = options[:base_url] || provider_config[:base_url]
52
+ @options = options.merge(provider_config)
53
+
54
+ # @client is an instance of an OmniAI::* class
55
+ @client = create_client
56
+
57
+ @last_response = nil
58
+ end
59
+
60
+
61
+ def response = last_response
62
+ def raw? = config.return_raw
63
+
64
+ def raw=(value)
65
+ config.return_raw = value
66
+ end
67
+
68
+
69
+
70
+ ######################################
71
+ def chat(messages, **params)
72
+ result = call_with_middlewares(:chat_without_middlewares, messages, **params)
73
+ @last_response = result
74
+ # debug_me print " (raw: #{raw?}) "
75
+ raw? ? result : content
76
+ end
77
+
78
+
79
+ def chat_without_middlewares(messages, **params)
80
+ @client.chat(messages, model: @model, **params)
81
+ end
82
+
83
+ ######################################
84
+ def transcribe(audio, format: nil, **params)
85
+ call_with_middlewares(:transcribe_without_middlewares, audio, format: format, **params)
86
+ end
87
+
88
+ def transcribe_without_middlewares(audio, format: nil, **params)
89
+ @client.transcribe(audio, model: @model, format: format, **params)
90
+ end
91
+
92
+ ######################################
93
+ def speak(text, **params)
94
+ call_with_middlewares(:speak_without_middlewares, text, **params)
95
+ end
96
+
97
+ def speak_without_middlewares(text, **params)
98
+ @client.speak(text, model: @model, **params)
99
+ end
100
+
101
+
102
+ ######################################
103
+ def embed(input, **params)
104
+ @client.embed(input, model: @model, **params)
105
+ end
106
+
107
+ def batch_embed(inputs, batch_size: 100, **params)
108
+ inputs.each_slice(batch_size).flat_map do |batch|
109
+ sleep 1 # DEBUG rate limits being exceeded
110
+ embed(batch, **params)
111
+ end
112
+ end
113
+
114
+ ######################################
115
+ ## Utilities
116
+
117
+ def call_with_middlewares(method, *args, **kwargs, &block)
118
+ stack = self.class.middlewares.reverse.reduce(-> { send(method, *args, **kwargs, &block) }) do |next_middleware, middleware|
119
+ -> { middleware.call(self, next_middleware, *args, **kwargs) }
120
+ end
121
+ stack.call
122
+ end
123
+
124
+
125
+ def content
126
+ case @provider
127
+ when :openai, :localai, :ollama
128
+ last_response.data.dig('choices', 0, 'message', 'content')
129
+ when :anthropic
130
+ last_response.data.dig('content',0,'text')
131
+ when :google
132
+ last_response.data.dig('candidates', 0, 'content', 'parts', 0, 'text')
133
+ when :mistral
134
+ last_response.data.dig('choices', 0, 'message', 'content')
135
+ else
136
+ raise NotImplementedError, "Content extraction not implemented for provider: #{@provider}"
137
+ end
138
+ end
139
+ alias_method :text, :content
140
+
141
+ ##############################################
142
+ ## Public Class Methods
143
+
144
+ class << self
145
+
146
+ def middlewares
147
+ @middlewares ||= []
148
+ end
149
+
150
+ def use(middleware)
151
+ middlewares << middleware
152
+ end
153
+
154
+ def clear_middlewares
155
+ @middlewares = []
156
+ end
157
+ end
158
+
159
+ def method_missing(method_name, *args, &block)
160
+ if @client.respond_to?(method_name)
161
+ result = @client.send(method_name, *args, &block)
162
+ @last_response = result if result.is_a?(OmniAI::Response)
163
+ result
164
+ else
165
+ super
166
+ end
167
+ end
168
+
169
+ def respond_to_missing?(method_name, include_private = false)
170
+ @client.respond_to?(method_name) || super
171
+ end
172
+
173
+
174
+ ##############################################
175
+ private
176
+
177
+ def validate_provider(provider)
178
+ return nil if provider.nil?
179
+
180
+ valid_providers = config.provider_patterns.keys
181
+ unless valid_providers.include?(provider)
182
+ raise ArgumentError, "Unsupported provider: #{provider}"
183
+ end
184
+
185
+ provider
186
+ end
187
+
188
+
189
+ def create_client
190
+ api_key = fetch_api_key # Fetching the API key should only happen for valid providers
191
+ client_options = {
192
+ api_key: api_key,
193
+ logger: @logger,
194
+ timeout: @timeout
195
+ }
196
+ client_options[:base_url] = @base_url if @base_url
197
+ client_options.merge!(@options).delete(:provider)
198
+
199
+ case provider
200
+ when :openai
201
+ OmniAI::OpenAI::Client.new(**client_options)
202
+ when :anthropic
203
+ OmniAI::Anthropic::Client.new(**client_options)
204
+ when :google
205
+ OmniAI::Google::Client.new(**client_options)
206
+ when :mistral
207
+ OmniAI::Mistral::Client.new(**client_options)
208
+ when :ollama
209
+ OmniAI::Ollama::Client.new(**client_options)
210
+ when :localai
211
+ OmniAI::LocalAI::Client.new(**client_options)
212
+ else
213
+ raise ArgumentError, "Unsupported provider: #{@provider}"
214
+ end
215
+ end
216
+
217
+
218
+ def fetch_api_key
219
+ env_var_name = "#{@provider.upcase}_API_KEY"
220
+ api_key = ENV[env_var_name]
221
+
222
+ if api_key.nil? || api_key.empty?
223
+ unless [:localai, :ollama].include? provider
224
+ raise ArgumentError, "API key not found in environment variable #{env_var_name}"
225
+ end
226
+ end
227
+
228
+ api_key
229
+ end
230
+
231
+
232
+ def determine_provider(model)
233
+ config.provider_patterns.find { |provider, pattern| model.match?(pattern) }&.first ||
234
+ raise(ArgumentError, "Unsupported model: #{model}")
235
+ end
236
+
237
+
238
+ def determine_model_type(model)
239
+ config.model_types.find { |type, pattern| model.match?(pattern) }&.first ||
240
+ raise(ArgumentError, "Unable to determine model type for: #{model}")
241
+ end
242
+ end
243
+
244
+
@@ -0,0 +1,31 @@
1
+ # extensions/omniai-localai.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'omniai'
5
+ require 'omniai/openai'
6
+
7
+ module OmniAI
8
+
9
+ # Create an alias for OmniAI::OpenAI module
10
+ module LocalAI
11
+ extend OmniAI::OpenAI
12
+
13
+ # Alias classes from OmniAI::OpenAI
14
+ class Client < OmniAI::OpenAI::Client
15
+ def initialize(**options)
16
+ options[:host] = 'http://localhost:8080' unless options.has_key?(:host)
17
+ super(**options)
18
+ end
19
+ end
20
+
21
+
22
+ Config = OmniAI::OpenAI::Config
23
+
24
+ # Alias the Thread class and its nested classes
25
+ Thread = OmniAI::OpenAI::Thread
26
+ Annotation = OmniAI::OpenAI::Thread::Annotation
27
+ Attachment = OmniAI::OpenAI::Thread::Attachment
28
+ Message = OmniAI::OpenAI::Thread::Message
29
+ Run = OmniAI::OpenAI::Thread::Run
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # extensions/omniai-ollama.rb
2
+ # frozen_string_literal: true
3
+
4
+ require 'omniai'
5
+ require 'omniai/openai'
6
+
7
+ module OmniAI
8
+
9
+ # Create an alias for OmniAI::OpenAI module
10
+ module Ollama
11
+ extend OmniAI::OpenAI
12
+
13
+ # Alias classes from OmniAI::OpenAI
14
+ class Client < OmniAI::OpenAI::Client
15
+ def initialize(**options)
16
+ options[:host] = 'http://localhost:11434' unless options.has_key?(:host)
17
+ super(**options)
18
+ end
19
+ end
20
+
21
+ Config = OmniAI::OpenAI::Config
22
+
23
+ # Alias the Thread class and its nested classes
24
+ Thread = OmniAI::OpenAI::Thread
25
+ Annotation = OmniAI::OpenAI::Thread::Annotation
26
+ Attachment = OmniAI::OpenAI::Thread::Attachment
27
+ Message = OmniAI::OpenAI::Thread::Message
28
+ Run = OmniAI::OpenAI::Thread::Run
29
+ end
30
+ end
data/sig/ai_client.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module AiClient
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
@@ -0,0 +1,5 @@
1
+ ## The Ollama Model Problem
2
+
3
+ The ability ot a model-centric configuration where the provider is derived from the model break with those models that can be run locally as well as be accessed through an off-platform provider API. Take for example the `mistral` model family which can be access to the `La Platform` API or downloaded locally and used with `Ollama`.
4
+
5
+ If I specify `mistral-large` I should also in the constructor method also provide a `provider:` parameter to specify where that model is going to be accessed.
metadata ADDED
@@ -0,0 +1,190 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ai_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dewayne VanHoozer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: omniai
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: omniai-anthropic
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: omniai-google
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: omniai-mistral
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: omniai-openai
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: amazing_print
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: debug_me
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: mocha
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description: "`ai_client` is a versatile Ruby gem that serves as a generic client
126
+ \nfor interacting with various AI service providers through a unified \nAPI. Designed
127
+ to simplify the integration of large language models \n(LLMs) into applications,
128
+ `ai_client` allows developers to create \ninstances using just the model name, greatly
129
+ reducing configuration \noverhead. With built-in support for popular AI providers—including
130
+ \nOpenAI, Anthropic, Google, Mistral, LocalAI and Ollama—the gem abstracts the \ncomplexities
131
+ of API interactions, offering methods for tasks such \nas chatting, transcription,
132
+ speech synthesis, and embedding. The \nmiddleware architecture enables customizable
133
+ processing of requests \nand responses, making it easy to implement features like
134
+ logging and \nretry logic. Seamlessly integrated with the `OmniAI` framework, \n`ai_client`
135
+ empowers developers to leverage cutting-edge AI capabilities \nwithout vendor lock-in,
136
+ making it an essential tool for modern AI-driven \napplications.\n"
137
+ email:
138
+ - dvanhoozer@gmail.com
139
+ executables: []
140
+ extensions: []
141
+ extra_rdoc_files: []
142
+ files:
143
+ - ".envrc"
144
+ - CHANGELOG.md
145
+ - LICENSE
146
+ - README.md
147
+ - Rakefile
148
+ - examples/Bethany Hamilton.m4a
149
+ - examples/common.rb
150
+ - examples/embed.rb
151
+ - examples/speak.rb
152
+ - examples/text.rb
153
+ - examples/transcribe.rb
154
+ - lib/ai_client.rb
155
+ - lib/ai_client/configuration.rb
156
+ - lib/ai_client/logger_middleware.rb
157
+ - lib/ai_client/retry_middleware.rb
158
+ - lib/ai_client/version.rb
159
+ - lib/extensions/omniai-localai.rb
160
+ - lib/extensions/omniai-ollama.rb
161
+ - sig/ai_client.rbs
162
+ - the_ollama_model_problem.md
163
+ homepage: https://github.com/MadBomber/ai_client
164
+ licenses:
165
+ - MIT
166
+ metadata:
167
+ allowed_push_host: https://rubygems.org
168
+ homepage_uri: https://github.com/MadBomber/ai_client
169
+ source_code_uri: https://github.com/MadBomber/ai_client
170
+ changelog_uri: https://github.com/MadBomber/ai_client/blob/main/CHANGELOG.md
171
+ post_install_message:
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: 3.0.0
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubygems_version: 3.5.20
187
+ signing_key:
188
+ specification_version: 4
189
+ summary: A generic AI Client for many providers
190
+ test_files: []