ruby_llm_mesh 0.1.0 → 2.2.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.
@@ -7,13 +7,23 @@ module RubyLlmMesh
7
7
  :anthropic_api_key, :anthropic_base_url, :anthropic_model,
8
8
  :local_node_base_url, :local_node_model,
9
9
  :circuit_failure_threshold, :circuit_reset_timeout,
10
- :logger
10
+ :logger,
11
+ :mesh_port, :fallback_providers, :auto_boot_mesh,
12
+ :semantic_cache_enabled, :semantic_cache_threshold,
13
+ :semantic_cache_ttl, :semantic_cache_dimensions,
14
+ :redis_url, :semantic_cache_backend,
15
+ :peer_discovery_enabled, :peer_urls,
16
+ :peer_health_interval, :peer_health_timeout,
17
+ :peer_health_path,
18
+ :budget_enabled, :budget_max_tokens, :budget_max_usd, :budget_prices,
19
+ :retry_backoff
11
20
 
12
21
  def initialize
13
22
  @default_providers = %i[openai anthropic local_node]
14
23
  @fallback = true
15
24
  @timeout = 30
16
25
  @max_retries = 1
26
+ @retry_backoff = Float(ENV.fetch("RUBY_LLM_MESH_RETRY_BACKOFF", "0.1"))
17
27
 
18
28
  @openai_api_key = ENV.fetch("OPENAI_API_KEY", nil)
19
29
  @openai_base_url = ENV.fetch("OPENAI_BASE_URL", "https://api.openai.com/v1")
@@ -29,7 +39,48 @@ module RubyLlmMesh
29
39
  @circuit_failure_threshold = 3
30
40
  @circuit_reset_timeout = 60
31
41
  @logger = nil
42
+
43
+ # Sovereign mesh / native core
44
+ @mesh_port = Integer(ENV.fetch("RUBY_LLM_MESH_PORT", "4233"))
45
+ @auto_boot_mesh = ENV.fetch("RUBY_LLM_MESH_AUTO_BOOT", "true") == "true"
46
+ @fallback_providers = %i[openai anthropic local_node]
47
+
48
+ # Semantic cache — opt-in
49
+ @semantic_cache_enabled = ENV.fetch("RUBY_LLM_MESH_SEMANTIC_CACHE", "false") == "true"
50
+ @semantic_cache_threshold = Float(ENV.fetch("RUBY_LLM_MESH_CACHE_THRESHOLD", "0.92"))
51
+ @semantic_cache_ttl = Integer(ENV.fetch("RUBY_LLM_MESH_CACHE_TTL", "3600"))
52
+ @semantic_cache_dimensions = 256
53
+ @redis_url = ENV.fetch("REDIS_URL", nil)
54
+ @semantic_cache_backend = nil
55
+
56
+ # Local peer mesh discovery / health
57
+ @peer_discovery_enabled = ENV.fetch("RUBY_LLM_MESH_PEER_DISCOVERY", "false") == "true"
58
+ peer_urls_env = ENV.fetch("RUBY_LLM_MESH_PEER_URLS", "")
59
+ @peer_urls = peer_urls_env.empty? ? [] : peer_urls_env.split(",").map(&:strip).reject(&:empty?)
60
+ @peer_health_interval = Integer(ENV.fetch("RUBY_LLM_MESH_PEER_HEALTH_INTERVAL", "30"))
61
+ @peer_health_timeout = Integer(ENV.fetch("RUBY_LLM_MESH_PEER_HEALTH_TIMEOUT", "2"))
62
+ @peer_health_path = ENV.fetch("RUBY_LLM_MESH_PEER_HEALTH_PATH", "/api/tags")
63
+
64
+ # Token/cost budget guard — opt-in
65
+ @budget_enabled = ENV.fetch("RUBY_LLM_MESH_BUDGET_ENABLED", "false") == "true"
66
+ @budget_max_tokens = integer_env("RUBY_LLM_MESH_BUDGET_MAX_TOKENS")
67
+ @budget_max_usd = float_env("RUBY_LLM_MESH_BUDGET_MAX_USD")
68
+ @budget_prices = {}
69
+ end
70
+
71
+ private
72
+
73
+ def integer_env(key)
74
+ value = ENV.fetch(key, nil)
75
+ value.nil? || value.empty? ? nil : Integer(value)
32
76
  end
77
+
78
+ def float_env(key)
79
+ value = ENV.fetch(key, nil)
80
+ value.nil? || value.empty? ? nil : Float(value)
81
+ end
82
+
83
+ public
33
84
  end
34
85
 
35
86
  class << self
@@ -42,7 +93,12 @@ module RubyLlmMesh
42
93
  end
43
94
 
44
95
  def reset_configuration!
96
+ NativeCore.reset! if defined?(NativeCore)
45
97
  @configuration = Configuration.new
98
+ Cache::SemanticCache.reset! if defined?(Cache::SemanticCache)
99
+ Mesh::PeerRegistry.reset! if defined?(Mesh::PeerRegistry)
100
+ Mesh::HealthMonitor.reset! if defined?(Mesh::HealthMonitor)
101
+ Budget.reset! if defined?(Budget)
46
102
  end
47
103
  end
48
104
  end
@@ -21,6 +21,17 @@ module RubyLlmMesh
21
21
  class AuthenticationError < ProviderError; end
22
22
  class CircuitOpenError < ProviderError; end
23
23
 
24
+ class BudgetExceededError < Error
25
+ attr_reader :dimension, :consumed, :limit
26
+
27
+ def initialize(message, dimension: nil, consumed: nil, limit: nil)
28
+ @dimension = dimension
29
+ @consumed = consumed
30
+ @limit = limit
31
+ super(message)
32
+ end
33
+ end
34
+
24
35
  class AllProvidersFailedError < Error
25
36
  attr_reader :errors
26
37
 
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RubyLlmMesh
8
+ module Mesh
9
+ # Pings local LLM peers and marks them healthy/unhealthy for routing.
10
+ class HealthMonitor
11
+ class << self
12
+ def instance(config: RubyLlmMesh.configuration, registry: nil)
13
+ @instance ||= new(config: config, registry: registry)
14
+ end
15
+
16
+ def reset!
17
+ @instance&.stop!
18
+ @instance = nil
19
+ end
20
+ end
21
+
22
+ def initialize(config: RubyLlmMesh.configuration, registry: nil)
23
+ @config = config
24
+ @registry = registry || PeerRegistry.instance(config: config)
25
+ @mutex = Mutex.new
26
+ @thread = nil
27
+ @stopped = true
28
+ end
29
+
30
+ def check_all!
31
+ @registry.all_urls.each { |url| check!(url) }
32
+ end
33
+
34
+ def check!(url)
35
+ healthy = healthy?(url)
36
+ if healthy
37
+ @registry.mark_healthy(url)
38
+ else
39
+ @registry.mark_unhealthy(url, error: "health check failed")
40
+ end
41
+ healthy
42
+ rescue StandardError => e
43
+ @registry.mark_unhealthy(url, error: e.message)
44
+ false
45
+ end
46
+
47
+ def healthy?(url)
48
+ base = url.to_s.chomp("/")
49
+ paths = [
50
+ @config.peer_health_path,
51
+ "/v1/models",
52
+ "/api/tags"
53
+ ].uniq
54
+
55
+ paths.any? { |path| ping("#{base}#{path}") }
56
+ end
57
+
58
+ def start!
59
+ return unless @config.peer_discovery_enabled
60
+ return if @thread&.alive?
61
+
62
+ @stopped = false
63
+ interval = [@config.peer_health_interval, 1].max
64
+ @thread = Thread.new do
65
+ until @stopped
66
+ begin
67
+ check_all!
68
+ rescue StandardError
69
+ # keep looping
70
+ end
71
+ sleep interval
72
+ end
73
+ end
74
+ @thread.abort_on_exception = false
75
+ @thread
76
+ end
77
+
78
+ def stop!
79
+ @stopped = true
80
+ @thread&.kill
81
+ @thread = nil
82
+ end
83
+
84
+ def running?
85
+ !@stopped && @thread&.alive?
86
+ end
87
+
88
+ private
89
+
90
+ def ping(url)
91
+ uri = URI(url)
92
+ http = Net::HTTP.new(uri.host, uri.port)
93
+ http.use_ssl = uri.scheme == "https"
94
+ http.open_timeout = @config.peer_health_timeout
95
+ http.read_timeout = @config.peer_health_timeout
96
+ request = Net::HTTP::Get.new(uri)
97
+ response = http.request(request)
98
+ response.code.to_i.between?(200, 299)
99
+ rescue StandardError
100
+ false
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ module Mesh
5
+ # Registry of local LLM peer base URLs (Ollama, LM Studio, etc.).
6
+ class PeerRegistry
7
+ Peer = Struct.new(:url, :healthy, :last_checked_at, :last_error, keyword_init: true)
8
+
9
+ class << self
10
+ def instance(config: RubyLlmMesh.configuration)
11
+ @instance ||= new(config: config)
12
+ end
13
+
14
+ def reset!
15
+ @instance = nil
16
+ end
17
+ end
18
+
19
+ def initialize(config: RubyLlmMesh.configuration)
20
+ @config = config
21
+ @mutex = Mutex.new
22
+ @peers = {}
23
+ seed_from_config!
24
+ end
25
+
26
+ def enabled?
27
+ !!@config.peer_discovery_enabled || !peer_urls.empty?
28
+ end
29
+
30
+ def register(url)
31
+ normalized = normalize_url(url)
32
+ return if normalized.empty?
33
+
34
+ @mutex.synchronize do
35
+ @peers[normalized] ||= Peer.new(url: normalized, healthy: true, last_checked_at: nil, last_error: nil)
36
+ end
37
+ end
38
+
39
+ def unregister(url)
40
+ @mutex.synchronize { @peers.delete(normalize_url(url)) }
41
+ end
42
+
43
+ def mark_healthy(url)
44
+ update_peer(url) do |peer|
45
+ peer.healthy = true
46
+ peer.last_error = nil
47
+ peer.last_checked_at = Time.now
48
+ end
49
+ end
50
+
51
+ def mark_unhealthy(url, error: nil)
52
+ update_peer(url) do |peer|
53
+ peer.healthy = false
54
+ peer.last_error = error&.to_s
55
+ peer.last_checked_at = Time.now
56
+ end
57
+ end
58
+
59
+ def healthy_urls
60
+ seed_from_config!
61
+ @mutex.synchronize do
62
+ # Only return peers currently marked healthy. When every peer is
63
+ # unhealthy, return [] so callers (e.g. LocalNode) can apply their
64
+ # own last-resort fallback — never reintroduce unhealthy URLs.
65
+ @peers.values.select(&:healthy).map(&:url)
66
+ end
67
+ end
68
+
69
+ def all_urls
70
+ seed_from_config!
71
+ @mutex.synchronize do
72
+ keys = @peers.keys.dup
73
+ keys.empty? ? peer_urls : keys
74
+ end
75
+ end
76
+
77
+ def peer_urls
78
+ urls = Array(@config.peer_urls).map { |u| normalize_url(u) }.reject(&:empty?)
79
+ primary = normalize_url(@config.local_node_base_url)
80
+ ([primary] + urls).uniq
81
+ end
82
+
83
+ def each_peer
84
+ seed_from_config!
85
+ @mutex.synchronize { @peers.values.map(&:dup) }.each { |peer| yield peer }
86
+ end
87
+
88
+ def clear!
89
+ @mutex.synchronize { @peers.clear }
90
+ end
91
+
92
+ private
93
+
94
+ def seed_from_config!
95
+ peer_urls.each { |url| register(url) }
96
+ end
97
+
98
+ def update_peer(url)
99
+ normalized = normalize_url(url)
100
+ @mutex.synchronize do
101
+ peer = @peers[normalized] || Peer.new(url: normalized, healthy: true)
102
+ yield peer
103
+ @peers[normalized] = peer
104
+ end
105
+ end
106
+
107
+ def normalize_url(url)
108
+ url.to_s.strip.chomp("/")
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLlmMesh
6
+ # Soft-loaded FFI bridge to the `chimera_core` native library.
7
+ # When the shared library is missing or `ffi` is unavailable, methods
8
+ # degrade to pure-Ruby fallbacks instead of crashing on require.
9
+ module NativeCore
10
+ class << self
11
+ def available?
12
+ !!@native_loaded
13
+ end
14
+
15
+ def start_node(port = RubyLlmMesh.configuration.mesh_port)
16
+ ensure_boot_attempted!
17
+ if available?
18
+ !!@lib.start_node(Integer(port))
19
+ else
20
+ Fallback.start_node(port)
21
+ end
22
+ end
23
+
24
+ def stop_node
25
+ ensure_boot_attempted!
26
+ if available?
27
+ !!@lib.stop_node
28
+ else
29
+ Fallback.stop_node
30
+ end
31
+ end
32
+
33
+ def node_alive?
34
+ ensure_boot_attempted!
35
+ if available?
36
+ !!@lib.node_alive
37
+ else
38
+ Fallback.node_alive?
39
+ end
40
+ end
41
+
42
+ def execute_wasm_payload(intent)
43
+ ensure_boot_attempted!
44
+ intent = intent.to_s
45
+ raw = if available?
46
+ @lib.execute_wasm_payload_string(intent)
47
+ else
48
+ Fallback.execute_wasm_payload(intent)
49
+ end
50
+ parse_payload(raw)
51
+ end
52
+
53
+ def version
54
+ ensure_boot_attempted!
55
+ return @lib.chimera_core_version_string if available?
56
+
57
+ "fallback-#{RubyLlmMesh::VERSION}"
58
+ end
59
+
60
+ def reset!
61
+ stop_node if node_alive?
62
+ @boot_attempted = false
63
+ @native_loaded = false
64
+ @lib = nil
65
+ Fallback.reset!
66
+ end
67
+
68
+ private
69
+
70
+ def ensure_boot_attempted!
71
+ return if @boot_attempted
72
+
73
+ @boot_attempted = true
74
+ @native_loaded = try_load_native!
75
+ end
76
+
77
+ def try_load_native!
78
+ begin
79
+ require "ffi"
80
+ rescue LoadError
81
+ return false
82
+ end
83
+
84
+ path = shared_library_path
85
+ return false unless path && File.exist?(path)
86
+
87
+ lib = Module.new
88
+ lib.extend(FFI::Library)
89
+ lib.ffi_lib path
90
+ lib.attach_function :start_node, [:uint16], :bool
91
+ lib.attach_function :stop_node, [], :bool
92
+ lib.attach_function :node_alive, [], :bool
93
+ lib.attach_function :execute_wasm_payload, [:string], :pointer
94
+ lib.attach_function :chimera_free_string, [:pointer], :void
95
+ lib.attach_function :chimera_core_version, [], :pointer
96
+
97
+ def lib.execute_wasm_payload_string(intent)
98
+ ptr = execute_wasm_payload(intent)
99
+ return "{\"ok\":false,\"error\":\"null pointer\"}" if ptr.null?
100
+
101
+ str = ptr.read_string
102
+ chimera_free_string(ptr)
103
+ str
104
+ end
105
+
106
+ def lib.chimera_core_version_string
107
+ ptr = chimera_core_version
108
+ return "unknown" if ptr.null?
109
+
110
+ str = ptr.read_string
111
+ chimera_free_string(ptr)
112
+ str
113
+ end
114
+
115
+ @lib = lib
116
+ true
117
+ rescue StandardError
118
+ false
119
+ end
120
+
121
+ def shared_library_path
122
+ root = File.expand_path("../..", __dir__)
123
+ crate = File.join(root, "ext", "chimera_core")
124
+ candidates = [
125
+ ENV["CHIMERA_CORE_LIB"],
126
+ File.join(crate, "target", "release", library_basename),
127
+ File.join(crate, "target", "debug", library_basename),
128
+ File.join(root, "lib", "ruby_llm_mesh", "native", library_basename)
129
+ ].compact
130
+ candidates.find { |p| File.exist?(p) }
131
+ end
132
+
133
+ def library_basename
134
+ case RbConfig::CONFIG["host_os"]
135
+ when /mswin|mingw|cygwin/i then "chimera_core.dll"
136
+ when /darwin/i then "libchimera_core.dylib"
137
+ else "libchimera_core.so"
138
+ end
139
+ end
140
+
141
+ def parse_payload(raw)
142
+ data = JSON.parse(raw.to_s)
143
+ data.is_a?(Hash) ? data : { "ok" => true, "output" => raw.to_s, "raw" => data }
144
+ rescue JSON::ParserError
145
+ { "ok" => true, "output" => raw.to_s, "engine" => available? ? "chimera_core" : "ruby_fallback" }
146
+ end
147
+ end
148
+
149
+ # Pure-Ruby stand-in when the native library is not compiled.
150
+ module Fallback
151
+ @alive = false
152
+ @port = nil
153
+ @mutex = Mutex.new
154
+
155
+ class << self
156
+ def start_node(port)
157
+ @mutex.synchronize do
158
+ @port = Integer(port)
159
+ @alive = true
160
+ end
161
+ true
162
+ end
163
+
164
+ def stop_node
165
+ @mutex.synchronize do
166
+ @alive = false
167
+ @port = nil
168
+ end
169
+ true
170
+ end
171
+
172
+ def node_alive?
173
+ @mutex.synchronize { @alive }
174
+ end
175
+
176
+ def execute_wasm_payload(intent)
177
+ port = @mutex.synchronize { @port }
178
+ alive = @mutex.synchronize { @alive }
179
+ JSON.generate(
180
+ ok: true,
181
+ engine: "ruby_fallback",
182
+ mode: "pure_ruby",
183
+ alive: alive,
184
+ port: port,
185
+ intent: intent.to_s,
186
+ output: "Fallback mesh executed intent (#{intent.to_s[0, 64]})"
187
+ )
188
+ end
189
+
190
+ def reset!
191
+ @mutex.synchronize do
192
+ @alive = false
193
+ @port = nil
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "openssl"
5
6
  require "uri"
6
7
 
7
8
  module RubyLlmMesh
@@ -41,7 +42,10 @@ module RubyLlmMesh
41
42
  [response, latency_ms]
42
43
  rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
43
44
  raise TimeoutError.new(e.message, provider: name)
44
- rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
45
+ rescue SocketError, EOFError, IOError, OpenSSL::SSL::SSLError,
46
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
47
+ Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE => e
48
+ # Wrap transport failures so the router can trip circuits / fall back
45
49
  raise ProviderError.new(e.message, provider: name)
46
50
  end
47
51
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  module RubyLlmMesh
4
4
  module Providers
5
- # OpenAI-compatible local runtime (Ollama, LM Studio, vLLM, llama.cpp server, etc.)
5
+ # OpenAI-compatible local runtime (Ollama, LM Studio, vLLM, llama.cpp server, etc.).
6
+ # When peer discovery is enabled, tries healthy peers from Mesh::PeerRegistry.
6
7
  class LocalNode < Base
7
8
  def name
8
9
  :local_node
@@ -22,8 +23,37 @@ module RubyLlmMesh
22
23
  }
23
24
  body[:options] = { num_predict: options[:max_tokens] } if options[:max_tokens]
24
25
 
26
+ errors = []
27
+ peer_bases.each do |base_url|
28
+ begin
29
+ return complete_against(base_url, model: model, body: body, messages: messages)
30
+ rescue RateLimitError, TimeoutError, ProviderError => e
31
+ registry.mark_unhealthy(base_url, error: e.message)
32
+ errors << e
33
+ next
34
+ end
35
+ end
36
+
37
+ raise errors.last || ProviderError.new("No healthy local peers available", provider: name)
38
+ end
39
+
40
+ private
41
+
42
+ def registry
43
+ @registry ||= Mesh::PeerRegistry.instance(config: config)
44
+ end
45
+
46
+ def peer_bases
47
+ urls = registry.healthy_urls
48
+ urls = [config.local_node_base_url.to_s.chomp("/")] if urls.empty?
49
+ urls
50
+ end
51
+
52
+ def complete_against(base_url, model:, body:, messages:)
53
+ base = base_url.to_s.chomp("/")
54
+
25
55
  response, latency_ms = http_post(
26
- "#{config.local_node_base_url.chomp('/')}/v1/chat/completions",
56
+ "#{base}/v1/chat/completions",
27
57
  headers: { "Content-Type" => "application/json" },
28
58
  body: body
29
59
  )
@@ -31,19 +61,20 @@ module RubyLlmMesh
31
61
  # Fallback to Ollama native chat API if OpenAI-compat endpoint is missing
32
62
  if response.code.to_i == 404
33
63
  response, latency_ms = http_post(
34
- "#{config.local_node_base_url.chomp('/')}/api/chat",
64
+ "#{base}/api/chat",
35
65
  headers: { "Content-Type" => "application/json" },
36
66
  body: { model: model, messages: messages, stream: false }
37
67
  )
38
68
  raise_for_status!(response)
39
69
  data = parse_json(response)
40
70
  content = data.dig("message", "content").to_s
71
+ registry.mark_healthy(base)
41
72
  return Response.new(
42
73
  content: content,
43
74
  provider: name,
44
75
  model: model,
45
76
  usage: {},
46
- raw: data,
77
+ raw: data.merge("_peer" => base),
47
78
  latency_ms: latency_ms
48
79
  )
49
80
  end
@@ -51,13 +82,14 @@ module RubyLlmMesh
51
82
  raise_for_status!(response)
52
83
  data = parse_json(response)
53
84
  content = data.dig("choices", 0, "message", "content").to_s
85
+ registry.mark_healthy(base)
54
86
 
55
87
  Response.new(
56
88
  content: content,
57
89
  provider: name,
58
90
  model: data["model"] || model,
59
91
  usage: data["usage"] || {},
60
- raw: data,
92
+ raw: data.merge("_peer" => base),
61
93
  latency_ms: latency_ms
62
94
  )
63
95
  end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module RubyLlmMesh
4
4
  class Response
5
- attr_reader :content, :provider, :model, :usage, :raw, :latency_ms, :fallback_used
5
+ attr_reader :content, :provider, :model, :usage, :raw, :latency_ms, :fallback_used, :cache_hit
6
6
 
7
- def initialize(content:, provider:, model: nil, usage: {}, raw: nil, latency_ms: nil, fallback_used: false)
7
+ def initialize(content:, provider:, model: nil, usage: {}, raw: nil, latency_ms: nil,
8
+ fallback_used: false, cache_hit: false)
8
9
  @content = content
9
10
  @provider = provider
10
11
  @model = model
@@ -12,6 +13,7 @@ module RubyLlmMesh
12
13
  @raw = raw
13
14
  @latency_ms = latency_ms
14
15
  @fallback_used = fallback_used
16
+ @cache_hit = cache_hit
15
17
  end
16
18
 
17
19
  def text
@@ -29,7 +31,8 @@ module RubyLlmMesh
29
31
  model: model,
30
32
  usage: usage,
31
33
  latency_ms: latency_ms,
32
- fallback_used: fallback_used
34
+ fallback_used: fallback_used,
35
+ cache_hit: cache_hit
33
36
  }
34
37
  end
35
38
  end