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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/CODE_OF_CONDUCT.md +77 -3
- data/CONTRIBUTING.md +7 -1
- data/PRIVACY.md +56 -0
- data/README.md +104 -79
- data/Rakefile +28 -0
- data/ext/chimera_core/Cargo.toml +14 -0
- data/ext/chimera_core/src/lib.rs +194 -0
- data/lib/ruby_llm_mesh/budget.rb +150 -0
- data/lib/ruby_llm_mesh/cache/memory_store.rb +47 -0
- data/lib/ruby_llm_mesh/cache/redis_store.rb +110 -0
- data/lib/ruby_llm_mesh/cache/semantic_cache.rb +126 -0
- data/lib/ruby_llm_mesh/circuit_breaker.rb +11 -1
- data/lib/ruby_llm_mesh/configuration.rb +57 -1
- data/lib/ruby_llm_mesh/errors.rb +11 -0
- data/lib/ruby_llm_mesh/mesh/health_monitor.rb +104 -0
- data/lib/ruby_llm_mesh/mesh/peer_registry.rb +112 -0
- data/lib/ruby_llm_mesh/native_core.rb +199 -0
- data/lib/ruby_llm_mesh/providers/base.rb +5 -1
- data/lib/ruby_llm_mesh/providers/local_node.rb +37 -5
- data/lib/ruby_llm_mesh/response.rb +6 -3
- data/lib/ruby_llm_mesh/router.rb +133 -8
- data/lib/ruby_llm_mesh/sovereign_mesh.rb +138 -0
- data/lib/ruby_llm_mesh/version.rb +1 -1
- data/lib/ruby_llm_mesh.rb +42 -1
- data/ruby_llm_mesh.gemspec +21 -8
- data/sig/ruby_llm_mesh.rbs +30 -1
- metadata +35 -6
|
@@ -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
|
data/lib/ruby_llm_mesh/errors.rb
CHANGED
|
@@ -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,
|
|
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
|
-
"#{
|
|
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
|
-
"#{
|
|
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,
|
|
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
|