ruby_llm_mesh 0.1.0
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/CHANGELOG.md +20 -0
- data/CODE_OF_CONDUCT.md +63 -0
- data/CONTRIBUTING.md +39 -0
- data/Gemfile +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +177 -0
- data/Rakefile +13 -0
- data/assets/logo.png +0 -0
- data/lib/ruby_llm_mesh/active_record/acts_as_ai_agent.rb +126 -0
- data/lib/ruby_llm_mesh/circuit_breaker.rb +67 -0
- data/lib/ruby_llm_mesh/configuration.rb +48 -0
- data/lib/ruby_llm_mesh/errors.rb +33 -0
- data/lib/ruby_llm_mesh/providers/anthropic.rb +49 -0
- data/lib/ruby_llm_mesh/providers/base.rb +72 -0
- data/lib/ruby_llm_mesh/providers/local_node.rb +66 -0
- data/lib/ruby_llm_mesh/providers/openai.rb +51 -0
- data/lib/ruby_llm_mesh/rag/chunker.rb +53 -0
- data/lib/ruby_llm_mesh/rag/embeddings.rb +68 -0
- data/lib/ruby_llm_mesh/rag/tools.rb +45 -0
- data/lib/ruby_llm_mesh/railtie.rb +12 -0
- data/lib/ruby_llm_mesh/response.rb +36 -0
- data/lib/ruby_llm_mesh/router.rb +93 -0
- data/lib/ruby_llm_mesh/version.rb +5 -0
- data/lib/ruby_llm_mesh.rb +47 -0
- data/ruby_llm_mesh.gemspec +57 -0
- data/sig/ruby_llm_mesh.rbs +82 -0
- metadata +119 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RubyLlmMesh
|
|
8
|
+
module Providers
|
|
9
|
+
class Base
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(config = RubyLlmMesh.configuration)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def complete(prompt:, system: nil, model: nil, **options)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def http_post(url, headers:, body:, timeout: nil)
|
|
27
|
+
uri = URI(url)
|
|
28
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
29
|
+
http.use_ssl = uri.scheme == "https"
|
|
30
|
+
http.open_timeout = timeout || config.timeout
|
|
31
|
+
http.read_timeout = timeout || config.timeout
|
|
32
|
+
|
|
33
|
+
request = Net::HTTP::Post.new(uri)
|
|
34
|
+
headers.each { |k, v| request[k] = v }
|
|
35
|
+
request.body = JSON.generate(body)
|
|
36
|
+
|
|
37
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
response = http.request(request)
|
|
39
|
+
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
40
|
+
|
|
41
|
+
[response, latency_ms]
|
|
42
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
|
|
43
|
+
raise TimeoutError.new(e.message, provider: name)
|
|
44
|
+
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
|
|
45
|
+
raise ProviderError.new(e.message, provider: name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse_json(response)
|
|
49
|
+
JSON.parse(response.body)
|
|
50
|
+
rescue JSON::ParserError
|
|
51
|
+
{ "raw" => response.body }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def raise_for_status!(response)
|
|
55
|
+
code = response.code.to_i
|
|
56
|
+
return if code.between?(200, 299)
|
|
57
|
+
|
|
58
|
+
body = response.body
|
|
59
|
+
message = "#{name} HTTP #{code}: #{body.to_s[0, 300]}"
|
|
60
|
+
|
|
61
|
+
case code
|
|
62
|
+
when 401, 403
|
|
63
|
+
raise AuthenticationError.new(message, provider: name, status: code, body: body)
|
|
64
|
+
when 429
|
|
65
|
+
raise RateLimitError.new(message, provider: name, status: code, body: body)
|
|
66
|
+
else
|
|
67
|
+
raise ProviderError.new(message, provider: name, status: code, body: body)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
module Providers
|
|
5
|
+
# OpenAI-compatible local runtime (Ollama, LM Studio, vLLM, llama.cpp server, etc.)
|
|
6
|
+
class LocalNode < Base
|
|
7
|
+
def name
|
|
8
|
+
:local_node
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def complete(prompt:, system: nil, model: nil, **options)
|
|
12
|
+
model ||= config.local_node_model
|
|
13
|
+
messages = []
|
|
14
|
+
messages << { role: "system", content: system } if system
|
|
15
|
+
messages << { role: "user", content: prompt }
|
|
16
|
+
|
|
17
|
+
body = {
|
|
18
|
+
model: model,
|
|
19
|
+
messages: messages,
|
|
20
|
+
stream: false,
|
|
21
|
+
temperature: options.fetch(:temperature, 0.7)
|
|
22
|
+
}
|
|
23
|
+
body[:options] = { num_predict: options[:max_tokens] } if options[:max_tokens]
|
|
24
|
+
|
|
25
|
+
response, latency_ms = http_post(
|
|
26
|
+
"#{config.local_node_base_url.chomp('/')}/v1/chat/completions",
|
|
27
|
+
headers: { "Content-Type" => "application/json" },
|
|
28
|
+
body: body
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Fallback to Ollama native chat API if OpenAI-compat endpoint is missing
|
|
32
|
+
if response.code.to_i == 404
|
|
33
|
+
response, latency_ms = http_post(
|
|
34
|
+
"#{config.local_node_base_url.chomp('/')}/api/chat",
|
|
35
|
+
headers: { "Content-Type" => "application/json" },
|
|
36
|
+
body: { model: model, messages: messages, stream: false }
|
|
37
|
+
)
|
|
38
|
+
raise_for_status!(response)
|
|
39
|
+
data = parse_json(response)
|
|
40
|
+
content = data.dig("message", "content").to_s
|
|
41
|
+
return Response.new(
|
|
42
|
+
content: content,
|
|
43
|
+
provider: name,
|
|
44
|
+
model: model,
|
|
45
|
+
usage: {},
|
|
46
|
+
raw: data,
|
|
47
|
+
latency_ms: latency_ms
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
raise_for_status!(response)
|
|
52
|
+
data = parse_json(response)
|
|
53
|
+
content = data.dig("choices", 0, "message", "content").to_s
|
|
54
|
+
|
|
55
|
+
Response.new(
|
|
56
|
+
content: content,
|
|
57
|
+
provider: name,
|
|
58
|
+
model: data["model"] || model,
|
|
59
|
+
usage: data["usage"] || {},
|
|
60
|
+
raw: data,
|
|
61
|
+
latency_ms: latency_ms
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
module Providers
|
|
5
|
+
class Openai < Base
|
|
6
|
+
def name
|
|
7
|
+
:openai
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def complete(prompt:, system: nil, model: nil, **options)
|
|
11
|
+
api_key = config.openai_api_key
|
|
12
|
+
raise AuthenticationError.new("OPENAI_API_KEY is not configured", provider: name) if api_key.to_s.empty?
|
|
13
|
+
|
|
14
|
+
model ||= config.openai_model
|
|
15
|
+
messages = []
|
|
16
|
+
messages << { role: "system", content: system } if system
|
|
17
|
+
messages << { role: "user", content: prompt }
|
|
18
|
+
|
|
19
|
+
body = {
|
|
20
|
+
model: model,
|
|
21
|
+
messages: messages,
|
|
22
|
+
temperature: options.fetch(:temperature, 0.7)
|
|
23
|
+
}
|
|
24
|
+
body[:max_tokens] = options[:max_tokens] if options[:max_tokens]
|
|
25
|
+
body[:tools] = options[:tools] if options[:tools]
|
|
26
|
+
|
|
27
|
+
response, latency_ms = http_post(
|
|
28
|
+
"#{config.openai_base_url.chomp('/')}/chat/completions",
|
|
29
|
+
headers: {
|
|
30
|
+
"Authorization" => "Bearer #{api_key}",
|
|
31
|
+
"Content-Type" => "application/json"
|
|
32
|
+
},
|
|
33
|
+
body: body
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
raise_for_status!(response)
|
|
37
|
+
data = parse_json(response)
|
|
38
|
+
choice = data.dig("choices", 0, "message", "content").to_s
|
|
39
|
+
|
|
40
|
+
Response.new(
|
|
41
|
+
content: choice,
|
|
42
|
+
provider: name,
|
|
43
|
+
model: data["model"] || model,
|
|
44
|
+
usage: data["usage"] || {},
|
|
45
|
+
raw: data,
|
|
46
|
+
latency_ms: latency_ms
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
module Rag
|
|
5
|
+
# Lightweight text chunker with optional overlap for RAG pipelines.
|
|
6
|
+
class Chunker
|
|
7
|
+
DEFAULT_SIZE = 800
|
|
8
|
+
DEFAULT_OVERLAP = 100
|
|
9
|
+
|
|
10
|
+
def initialize(size: DEFAULT_SIZE, overlap: DEFAULT_OVERLAP, separator: /\n{2,}|\n|\s+/)
|
|
11
|
+
raise ArgumentError, "overlap must be less than size" if overlap >= size
|
|
12
|
+
|
|
13
|
+
@size = size
|
|
14
|
+
@overlap = overlap
|
|
15
|
+
@separator = separator
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def chunk(text)
|
|
19
|
+
return [] if text.nil? || text.strip.empty?
|
|
20
|
+
|
|
21
|
+
paragraphs = text.to_s.split(@separator).map(&:strip).reject(&:empty?)
|
|
22
|
+
chunks = []
|
|
23
|
+
buffer = +""
|
|
24
|
+
|
|
25
|
+
paragraphs.each do |piece|
|
|
26
|
+
candidate = buffer.empty? ? piece : "#{buffer} #{piece}"
|
|
27
|
+
if candidate.length <= @size
|
|
28
|
+
buffer = candidate
|
|
29
|
+
else
|
|
30
|
+
chunks << buffer unless buffer.empty?
|
|
31
|
+
buffer = overlap_tail(buffer)
|
|
32
|
+
buffer = buffer.empty? ? piece : "#{buffer} #{piece}"
|
|
33
|
+
while buffer.length > @size
|
|
34
|
+
chunks << buffer[0, @size]
|
|
35
|
+
buffer = overlap_tail(buffer[0, @size]) + buffer[@size..]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
chunks << buffer unless buffer.empty?
|
|
41
|
+
chunks
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def overlap_tail(text)
|
|
47
|
+
return "" if @overlap.zero? || text.nil? || text.empty?
|
|
48
|
+
|
|
49
|
+
text[[text.length - @overlap, 0].max..]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
module Rag
|
|
5
|
+
# Zero-dependency bag-of-words style embeddings for local prototyping.
|
|
6
|
+
# Swap for a real embedding provider in production.
|
|
7
|
+
class Embeddings
|
|
8
|
+
def initialize(dimensions: 256)
|
|
9
|
+
@dimensions = dimensions
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def embed(text)
|
|
13
|
+
vector = Array.new(@dimensions, 0.0)
|
|
14
|
+
tokenize(text).each do |token|
|
|
15
|
+
index = stable_hash(token) % @dimensions
|
|
16
|
+
vector[index] += 1.0
|
|
17
|
+
end
|
|
18
|
+
normalize!(vector)
|
|
19
|
+
vector
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def embed_many(texts)
|
|
23
|
+
Array(texts).map { |t| embed(t) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def cosine_similarity(a, b)
|
|
27
|
+
raise ArgumentError, "vectors must match length" unless a.length == b.length
|
|
28
|
+
|
|
29
|
+
dot = 0.0
|
|
30
|
+
a.each_index { |i| dot += a[i] * b[i] }
|
|
31
|
+
dot
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def top_k(query, documents, k: 3)
|
|
35
|
+
query_vec = embed(query)
|
|
36
|
+
scored = documents.map do |doc|
|
|
37
|
+
text = doc.is_a?(Hash) ? doc[:text] || doc["text"] : doc.to_s
|
|
38
|
+
score = cosine_similarity(query_vec, embed(text))
|
|
39
|
+
{ document: doc, score: score }
|
|
40
|
+
end
|
|
41
|
+
scored.sort_by { |row| -row[:score] }.first(k)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def tokenize(text)
|
|
47
|
+
text.to_s.downcase.scan(/[a-z0-9_]+/)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stable_hash(token)
|
|
51
|
+
# FNV-1a 32-bit
|
|
52
|
+
hash = 2166136261
|
|
53
|
+
token.each_byte do |byte|
|
|
54
|
+
hash ^= byte
|
|
55
|
+
hash = (hash * 16777619) & 0xffffffff
|
|
56
|
+
end
|
|
57
|
+
hash
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize!(vector)
|
|
61
|
+
norm = Math.sqrt(vector.sum { |v| v * v })
|
|
62
|
+
return vector if norm.zero?
|
|
63
|
+
|
|
64
|
+
vector.map! { |v| v / norm }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
module Rag
|
|
5
|
+
# Helpers for structuring tool / function-calling schemas across providers.
|
|
6
|
+
module Tools
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def define(name:, description:, parameters: {}, required: [])
|
|
10
|
+
{
|
|
11
|
+
name: name.to_s,
|
|
12
|
+
description: description.to_s,
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: parameters,
|
|
16
|
+
required: Array(required).map(&:to_s)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def for_openai(tools)
|
|
22
|
+
Array(tools).map do |tool|
|
|
23
|
+
{
|
|
24
|
+
type: "function",
|
|
25
|
+
function: {
|
|
26
|
+
name: tool[:name] || tool["name"],
|
|
27
|
+
description: tool[:description] || tool["description"],
|
|
28
|
+
parameters: tool[:parameters] || tool["parameters"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def for_anthropic(tools)
|
|
35
|
+
Array(tools).map do |tool|
|
|
36
|
+
{
|
|
37
|
+
name: tool[:name] || tool["name"],
|
|
38
|
+
description: tool[:description] || tool["description"],
|
|
39
|
+
input_schema: tool[:parameters] || tool["parameters"]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
initializer "ruby_llm_mesh.active_record" do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
require_relative "active_record/acts_as_ai_agent"
|
|
8
|
+
extend RubyLlmMesh::ActiveRecord::ActsAsAiAgent
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :content, :provider, :model, :usage, :raw, :latency_ms, :fallback_used
|
|
6
|
+
|
|
7
|
+
def initialize(content:, provider:, model: nil, usage: {}, raw: nil, latency_ms: nil, fallback_used: false)
|
|
8
|
+
@content = content
|
|
9
|
+
@provider = provider
|
|
10
|
+
@model = model
|
|
11
|
+
@usage = usage || {}
|
|
12
|
+
@raw = raw
|
|
13
|
+
@latency_ms = latency_ms
|
|
14
|
+
@fallback_used = fallback_used
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def text
|
|
18
|
+
content
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
content.to_s
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
{
|
|
27
|
+
content: content,
|
|
28
|
+
provider: provider,
|
|
29
|
+
model: model,
|
|
30
|
+
usage: usage,
|
|
31
|
+
latency_ms: latency_ms,
|
|
32
|
+
fallback_used: fallback_used
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
class Router
|
|
5
|
+
PROVIDER_MAP = {
|
|
6
|
+
openai: Providers::Openai,
|
|
7
|
+
anthropic: Providers::Anthropic,
|
|
8
|
+
local_node: Providers::LocalNode
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def circuit_breaker
|
|
13
|
+
@circuit_breaker ||= CircuitBreaker.new(
|
|
14
|
+
failure_threshold: RubyLlmMesh.configuration.circuit_failure_threshold,
|
|
15
|
+
reset_timeout: RubyLlmMesh.configuration.circuit_reset_timeout
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset_circuit_breaker!
|
|
20
|
+
@circuit_breaker = nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(config: RubyLlmMesh.configuration, circuit_breaker: self.class.circuit_breaker)
|
|
25
|
+
@config = config
|
|
26
|
+
@circuit_breaker = circuit_breaker
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def complete(prompt:, providers: nil, fallback: nil, system: nil, model: nil, **options)
|
|
30
|
+
raise ArgumentError, "prompt is required" if prompt.nil? || prompt.to_s.strip.empty?
|
|
31
|
+
|
|
32
|
+
provider_list = Array(providers || @config.default_providers).map(&:to_sym)
|
|
33
|
+
raise ArgumentError, "providers list cannot be empty" if provider_list.empty?
|
|
34
|
+
|
|
35
|
+
use_fallback = fallback.nil? ? @config.fallback : fallback
|
|
36
|
+
errors = {}
|
|
37
|
+
attempted = 0
|
|
38
|
+
|
|
39
|
+
provider_list.each_with_index do |provider_name, index|
|
|
40
|
+
unless PROVIDER_MAP.key?(provider_name)
|
|
41
|
+
errors[provider_name] = ProviderError.new("Unknown provider: #{provider_name}", provider: provider_name)
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless @circuit_breaker.allow?(provider_name)
|
|
46
|
+
errors[provider_name] = CircuitOpenError.new(
|
|
47
|
+
"Circuit open for #{provider_name}",
|
|
48
|
+
provider: provider_name
|
|
49
|
+
)
|
|
50
|
+
log(:warn, "Skipping #{provider_name} — circuit open")
|
|
51
|
+
next
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
attempted += 1
|
|
56
|
+
log(:info, "Routing to #{provider_name}")
|
|
57
|
+
provider = PROVIDER_MAP[provider_name].new(@config)
|
|
58
|
+
response = provider.complete(prompt: prompt, system: system, model: model, **options)
|
|
59
|
+
@circuit_breaker.record_success(provider_name)
|
|
60
|
+
return Response.new(
|
|
61
|
+
content: response.content,
|
|
62
|
+
provider: response.provider,
|
|
63
|
+
model: response.model,
|
|
64
|
+
usage: response.usage,
|
|
65
|
+
raw: response.raw,
|
|
66
|
+
latency_ms: response.latency_ms,
|
|
67
|
+
fallback_used: index.positive?
|
|
68
|
+
)
|
|
69
|
+
rescue ProviderError => e
|
|
70
|
+
@circuit_breaker.record_failure(provider_name)
|
|
71
|
+
errors[provider_name] = e
|
|
72
|
+
log(:error, "#{provider_name} failed: #{e.message}")
|
|
73
|
+
break unless use_fallback
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
raise AllProvidersFailedError, errors
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def log(level, message)
|
|
83
|
+
logger = @config.logger
|
|
84
|
+
return unless logger
|
|
85
|
+
|
|
86
|
+
if logger.respond_to?(level)
|
|
87
|
+
logger.public_send(level, "[RubyLlmMesh] #{message}")
|
|
88
|
+
elsif logger.respond_to?(:call)
|
|
89
|
+
logger.call(level, message)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ruby_llm_mesh/version"
|
|
4
|
+
require_relative "ruby_llm_mesh/errors"
|
|
5
|
+
require_relative "ruby_llm_mesh/configuration"
|
|
6
|
+
require_relative "ruby_llm_mesh/response"
|
|
7
|
+
require_relative "ruby_llm_mesh/circuit_breaker"
|
|
8
|
+
require_relative "ruby_llm_mesh/providers/base"
|
|
9
|
+
require_relative "ruby_llm_mesh/providers/openai"
|
|
10
|
+
require_relative "ruby_llm_mesh/providers/anthropic"
|
|
11
|
+
require_relative "ruby_llm_mesh/providers/local_node"
|
|
12
|
+
require_relative "ruby_llm_mesh/router"
|
|
13
|
+
require_relative "ruby_llm_mesh/rag/chunker"
|
|
14
|
+
require_relative "ruby_llm_mesh/rag/embeddings"
|
|
15
|
+
require_relative "ruby_llm_mesh/rag/tools"
|
|
16
|
+
|
|
17
|
+
# Optional Rails integration — only load railtie when Rails is already present
|
|
18
|
+
if defined?(Rails::Railtie)
|
|
19
|
+
require_relative "ruby_llm_mesh/railtie"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module RubyLlmMesh
|
|
23
|
+
class << self
|
|
24
|
+
def complete(prompt:, providers: nil, fallback: nil, **options)
|
|
25
|
+
Router.new.complete(prompt: prompt, providers: providers, fallback: fallback, **options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def chat(**)
|
|
29
|
+
complete(**)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Friendly alias matching the public DSL from the project overview
|
|
35
|
+
module AiAgentRouter
|
|
36
|
+
def self.complete(...)
|
|
37
|
+
RubyLlmMesh.complete(...)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.configure(&)
|
|
41
|
+
RubyLlmMesh.configure(&)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.configuration
|
|
45
|
+
RubyLlmMesh.configuration
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/ruby_llm_mesh/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "ruby_llm_mesh"
|
|
7
|
+
spec.version = RubyLlmMesh::VERSION
|
|
8
|
+
spec.authors = ["theworker02"]
|
|
9
|
+
spec.email = ["theworker02@users.noreply.github.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Unified multi-provider AI routing with circuit-breaking and local fallback for Ruby & Rails"
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
ruby_llm_mesh (AiAgentRouter) is a drop-in Ruby gem that routes AI prompts across
|
|
14
|
+
OpenAI, Anthropic, and local node runtimes with automatic circuit-breaking,
|
|
15
|
+
fallback ladders, lightweight RAG helpers, and optional ActiveRecord hooks.
|
|
16
|
+
DESC
|
|
17
|
+
spec.homepage = "https://github.com/theworker02/ruby_llm_mesh"
|
|
18
|
+
spec.license = "MIT"
|
|
19
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
20
|
+
|
|
21
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
22
|
+
spec.metadata["source_code_uri"] = "https://github.com/theworker02/ruby_llm_mesh"
|
|
23
|
+
spec.metadata["changelog_uri"] = "https://github.com/theworker02/ruby_llm_mesh/blob/main/CHANGELOG.md"
|
|
24
|
+
spec.metadata["documentation_uri"] = "https://theworker02.github.io/ruby_llm_mesh/"
|
|
25
|
+
spec.metadata["bug_tracker_uri"] = "https://github.com/theworker02/ruby_llm_mesh/issues"
|
|
26
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
27
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
28
|
+
|
|
29
|
+
spec.files = Dir.chdir(__dir__) do
|
|
30
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
31
|
+
f.start_with?(*%w[test/ spec/ features/ .git .github docs/]) ||
|
|
32
|
+
f.end_with?(".gem")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
# Ensure packaging works before the first commit
|
|
36
|
+
if spec.files.empty?
|
|
37
|
+
spec.files = Dir[
|
|
38
|
+
"lib/**/*",
|
|
39
|
+
"sig/**/*",
|
|
40
|
+
"exe/**/*",
|
|
41
|
+
"assets/**/*",
|
|
42
|
+
"LICENSE*",
|
|
43
|
+
"README*",
|
|
44
|
+
"CHANGELOG*",
|
|
45
|
+
"CODE_OF_CONDUCT*",
|
|
46
|
+
"*.gemspec"
|
|
47
|
+
]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
spec.bindir = "exe"
|
|
51
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
52
|
+
spec.require_paths = ["lib"]
|
|
53
|
+
|
|
54
|
+
spec.add_development_dependency "minitest", "~> 5.25"
|
|
55
|
+
spec.add_development_dependency "rake", "~> 13.2"
|
|
56
|
+
spec.add_development_dependency "webmock", "~> 3.24"
|
|
57
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLlmMesh
|
|
4
|
+
VERSION: String
|
|
5
|
+
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor default_providers: Array[Symbol]
|
|
8
|
+
attr_accessor fallback: bool
|
|
9
|
+
attr_accessor timeout: Integer
|
|
10
|
+
attr_accessor max_retries: Integer
|
|
11
|
+
attr_accessor openai_api_key: String?
|
|
12
|
+
attr_accessor openai_base_url: String
|
|
13
|
+
attr_accessor openai_model: String
|
|
14
|
+
attr_accessor anthropic_api_key: String?
|
|
15
|
+
attr_accessor anthropic_base_url: String
|
|
16
|
+
attr_accessor anthropic_model: String
|
|
17
|
+
attr_accessor local_node_base_url: String
|
|
18
|
+
attr_accessor local_node_model: String
|
|
19
|
+
attr_accessor circuit_failure_threshold: Integer
|
|
20
|
+
attr_accessor circuit_reset_timeout: Integer
|
|
21
|
+
attr_accessor logger: untyped
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.configuration: () -> Configuration
|
|
25
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
26
|
+
def self.reset_configuration!: () -> void
|
|
27
|
+
def self.complete: (
|
|
28
|
+
prompt: String,
|
|
29
|
+
?providers: Array[Symbol]?,
|
|
30
|
+
?fallback: bool?,
|
|
31
|
+
**untyped
|
|
32
|
+
) -> Response
|
|
33
|
+
def self.chat: (**untyped) -> Response
|
|
34
|
+
|
|
35
|
+
class Response
|
|
36
|
+
attr_reader content: String
|
|
37
|
+
attr_reader provider: Symbol
|
|
38
|
+
attr_reader model: String?
|
|
39
|
+
attr_reader usage: Hash[untyped, untyped]
|
|
40
|
+
attr_reader raw: untyped
|
|
41
|
+
attr_reader latency_ms: Integer?
|
|
42
|
+
attr_reader fallback_used: bool
|
|
43
|
+
|
|
44
|
+
def initialize: (
|
|
45
|
+
content: String,
|
|
46
|
+
provider: Symbol,
|
|
47
|
+
?model: String?,
|
|
48
|
+
?usage: Hash[untyped, untyped],
|
|
49
|
+
?raw: untyped,
|
|
50
|
+
?latency_ms: Integer?,
|
|
51
|
+
?fallback_used: bool
|
|
52
|
+
) -> void
|
|
53
|
+
def text: () -> String
|
|
54
|
+
def to_s: () -> String
|
|
55
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Router
|
|
59
|
+
def complete: (
|
|
60
|
+
prompt: String,
|
|
61
|
+
?providers: Array[Symbol]?,
|
|
62
|
+
?fallback: bool?,
|
|
63
|
+
?system: String?,
|
|
64
|
+
?model: String?,
|
|
65
|
+
**untyped
|
|
66
|
+
) -> Response
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class CircuitBreaker
|
|
70
|
+
def allow?: (Symbol) -> bool
|
|
71
|
+
def record_success: (Symbol) -> void
|
|
72
|
+
def record_failure: (Symbol) -> void
|
|
73
|
+
def state_for: (Symbol) -> Symbol
|
|
74
|
+
def reset!: (?Symbol?) -> void
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module AiAgentRouter
|
|
79
|
+
def self.complete: (**untyped) -> RubyLlmMesh::Response
|
|
80
|
+
def self.configure: () { (RubyLlmMesh::Configuration) -> void } -> void
|
|
81
|
+
def self.configuration: () -> RubyLlmMesh::Configuration
|
|
82
|
+
end
|