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.
@@ -0,0 +1,194 @@
1
+ //! chimera_core — sovereign mesh node for ruby_llm_mesh.
2
+ //!
3
+ //! Exposes a C ABI for Ruby FFI:
4
+ //! - `start_node(port) -> bool`
5
+ //! - `node_alive() -> bool`
6
+ //! - `execute_wasm_payload(intent) -> *mut c_char` (JSON; caller frees via `chimera_free_string`)
7
+ //! - `stop_node() -> bool`
8
+
9
+ use std::ffi::{CStr, CString};
10
+ use std::io::{Read, Write};
11
+ use std::net::{SocketAddr, TcpListener, TcpStream};
12
+ use std::os::raw::c_char;
13
+ use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
14
+ use std::thread;
15
+ use std::time::Duration;
16
+
17
+ static ALIVE: AtomicBool = AtomicBool::new(false);
18
+ static PORT: AtomicU16 = AtomicU16::new(0);
19
+ static STOP: AtomicBool = AtomicBool::new(false);
20
+
21
+ /// Boot a lightweight mesh listener on `port`. Idempotent if already alive on same port.
22
+ #[no_mangle]
23
+ pub extern "C" fn start_node(port: u16) -> bool {
24
+ if ALIVE.load(Ordering::SeqCst) && PORT.load(Ordering::SeqCst) == port {
25
+ return true;
26
+ }
27
+
28
+ STOP.store(false, Ordering::SeqCst);
29
+ let addr = SocketAddr::from(([127, 0, 0, 1], port));
30
+ let listener = match TcpListener::bind(addr) {
31
+ Ok(l) => l,
32
+ Err(_) => return false,
33
+ };
34
+ if let Err(_) = listener.set_nonblocking(true) {
35
+ return false;
36
+ }
37
+
38
+ PORT.store(port, Ordering::SeqCst);
39
+ ALIVE.store(true, Ordering::SeqCst);
40
+
41
+ thread::spawn(move || {
42
+ while !STOP.load(Ordering::SeqCst) {
43
+ match listener.accept() {
44
+ Ok((stream, _)) => {
45
+ let _ = handle_connection(stream);
46
+ }
47
+ Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
48
+ thread::sleep(Duration::from_millis(25));
49
+ }
50
+ Err(_) => {
51
+ thread::sleep(Duration::from_millis(50));
52
+ }
53
+ }
54
+ }
55
+ ALIVE.store(false, Ordering::SeqCst);
56
+ });
57
+
58
+ true
59
+ }
60
+
61
+ #[no_mangle]
62
+ pub extern "C" fn stop_node() -> bool {
63
+ STOP.store(true, Ordering::SeqCst);
64
+ // Give the accept loop a moment to exit
65
+ thread::sleep(Duration::from_millis(40));
66
+ ALIVE.store(false, Ordering::SeqCst);
67
+ true
68
+ }
69
+
70
+ #[no_mangle]
71
+ pub extern "C" fn node_alive() -> bool {
72
+ ALIVE.load(Ordering::SeqCst)
73
+ }
74
+
75
+ /// Execute an intent payload natively and return a heap-allocated JSON C string.
76
+ /// Caller must free with `chimera_free_string`.
77
+ #[no_mangle]
78
+ pub extern "C" fn execute_wasm_payload(intent: *const c_char) -> *mut c_char {
79
+ if intent.is_null() {
80
+ return to_c_string(error_json("null intent pointer"));
81
+ }
82
+
83
+ let c_str = unsafe { CStr::from_ptr(intent) };
84
+ let intent_str = match c_str.to_str() {
85
+ Ok(s) => s,
86
+ Err(_) => return to_c_string(error_json("invalid UTF-8 intent")),
87
+ };
88
+
89
+ let digest = simple_digest(intent_str);
90
+ let escaped = escape_json(intent_str);
91
+ let json = format!(
92
+ "{{\"ok\":true,\"engine\":\"chimera_core\",\"mode\":\"native_wasm\",\"alive\":{},\"port\":{},\"intent\":\"{}\",\"digest\":\"{:016x}\",\"output\":\"Native mesh executed intent ({})\"}}",
93
+ ALIVE.load(Ordering::SeqCst),
94
+ PORT.load(Ordering::SeqCst),
95
+ escaped,
96
+ digest,
97
+ truncate(&escaped, 64)
98
+ );
99
+ to_c_string(json)
100
+ }
101
+
102
+ #[no_mangle]
103
+ pub extern "C" fn chimera_free_string(ptr: *mut c_char) {
104
+ if ptr.is_null() {
105
+ return;
106
+ }
107
+ unsafe {
108
+ let _ = CString::from_raw(ptr);
109
+ }
110
+ }
111
+
112
+ #[no_mangle]
113
+ pub extern "C" fn chimera_core_version() -> *mut c_char {
114
+ to_c_string("2.0.0".to_string())
115
+ }
116
+
117
+ fn handle_connection(mut stream: TcpStream) {
118
+ let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
119
+ let mut buf = [0u8; 1024];
120
+ let _ = stream.read(&mut buf);
121
+ let body = "{\"status\":\"ok\",\"engine\":\"chimera_core\",\"alive\":true}";
122
+ let response = format!(
123
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
124
+ body.len(),
125
+ body
126
+ );
127
+ let _ = stream.write_all(response.as_bytes());
128
+ }
129
+
130
+ fn to_c_string(s: String) -> *mut c_char {
131
+ match CString::new(s) {
132
+ Ok(c) => c.into_raw(),
133
+ Err(_) => CString::new("{\"ok\":false,\"error\":\"nul in string\"}")
134
+ .unwrap()
135
+ .into_raw(),
136
+ }
137
+ }
138
+
139
+ fn error_json(msg: &str) -> String {
140
+ format!(
141
+ "{{\"ok\":false,\"engine\":\"chimera_core\",\"error\":\"{}\"}}",
142
+ escape_json(msg)
143
+ )
144
+ }
145
+
146
+ fn escape_json(s: &str) -> String {
147
+ let mut out = String::with_capacity(s.len());
148
+ for ch in s.chars() {
149
+ match ch {
150
+ '"' => out.push_str("\\\""),
151
+ '\\' => out.push_str("\\\\"),
152
+ '\n' => out.push_str("\\n"),
153
+ '\r' => out.push_str("\\r"),
154
+ '\t' => out.push_str("\\t"),
155
+ c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
156
+ c => out.push(c),
157
+ }
158
+ }
159
+ out
160
+ }
161
+
162
+ fn simple_digest(s: &str) -> u64 {
163
+ let mut h: u64 = 0xcbf29ce484222325;
164
+ for b in s.as_bytes() {
165
+ h ^= u64::from(*b);
166
+ h = h.wrapping_mul(0x100000001b3);
167
+ }
168
+ h
169
+ }
170
+
171
+ fn truncate(s: &str, max: usize) -> String {
172
+ if s.chars().count() <= max {
173
+ s.to_string()
174
+ } else {
175
+ format!("{}…", s.chars().take(max).collect::<String>())
176
+ }
177
+ }
178
+
179
+ #[cfg(test)]
180
+ mod tests {
181
+ use super::*;
182
+ use std::ffi::CString;
183
+
184
+ #[test]
185
+ fn execute_returns_json() {
186
+ let intent = CString::new("ping mesh").unwrap();
187
+ let ptr = execute_wasm_payload(intent.as_ptr());
188
+ assert!(!ptr.is_null());
189
+ let s = unsafe { CStr::from_ptr(ptr) }.to_str().unwrap();
190
+ assert!(s.contains("chimera_core"));
191
+ assert!(s.contains("ping mesh"));
192
+ chimera_free_string(ptr);
193
+ }
194
+ }
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ class Budget
5
+ DEFAULT_PRICES = {
6
+ input_per_1k: 0.00015,
7
+ output_per_1k: 0.0006
8
+ }.freeze
9
+
10
+ attr_reader :tokens_consumed, :usd_consumed
11
+
12
+ def initialize(config: RubyLlmMesh.configuration)
13
+ @config = config
14
+ @tokens_consumed = 0
15
+ @usd_consumed = 0.0
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def enabled?
20
+ @config.budget_enabled
21
+ end
22
+
23
+ def check!(estimated_tokens: 0, estimated_usd: 0.0)
24
+ return unless enabled?
25
+
26
+ @mutex.synchronize do
27
+ raise_budget_exceeded!("token", @tokens_consumed, @config.budget_max_tokens) if token_limit? && (@tokens_consumed + estimated_tokens) > @config.budget_max_tokens
28
+ raise_budget_exceeded!("usd", @usd_consumed, @config.budget_max_usd) if usd_limit? && (@usd_consumed + estimated_usd) > @config.budget_max_usd
29
+ end
30
+ end
31
+
32
+ def consume!(usage:, provider:, model:)
33
+ return unless enabled?
34
+
35
+ tokens = self.class.extract_tokens(usage)
36
+ usd = self.class.compute_cost(usage, provider: provider, model: model, config: @config)
37
+
38
+ @mutex.synchronize do
39
+ @tokens_consumed += tokens
40
+ @usd_consumed += usd
41
+ raise_budget_exceeded!("token", @tokens_consumed, @config.budget_max_tokens) if token_limit? && @tokens_consumed > @config.budget_max_tokens
42
+ raise_budget_exceeded!("usd", @usd_consumed, @config.budget_max_usd) if usd_limit? && @usd_consumed > @config.budget_max_usd
43
+ end
44
+ end
45
+
46
+ def status
47
+ {
48
+ enabled: enabled?,
49
+ tokens_consumed: @tokens_consumed,
50
+ tokens_max: @config.budget_max_tokens,
51
+ tokens_remaining: tokens_remaining,
52
+ usd_consumed: @usd_consumed.round(6),
53
+ usd_max: @config.budget_max_usd,
54
+ usd_remaining: usd_remaining
55
+ }
56
+ end
57
+
58
+ def reset!
59
+ @mutex.synchronize do
60
+ @tokens_consumed = 0
61
+ @usd_consumed = 0.0
62
+ end
63
+ end
64
+
65
+ class << self
66
+ def instance(config: RubyLlmMesh.configuration)
67
+ @instances ||= {}
68
+ @instances[config.object_id] ||= new(config: config)
69
+ end
70
+
71
+ def reset!
72
+ @instances = {}
73
+ end
74
+
75
+ def estimate_tokens(prompt:, system: nil, max_tokens: nil)
76
+ text = [system, prompt].compact.join("\n")
77
+ estimated = (text.length / 4.0).ceil
78
+ estimated += max_tokens.to_i if max_tokens
79
+ estimated
80
+ end
81
+
82
+ def estimate_usd(tokens:, model: nil, config: RubyLlmMesh.configuration)
83
+ prices = resolve_prices(config, model)
84
+ (tokens / 1000.0) * prices[:input_per_1k]
85
+ end
86
+
87
+ def resolve_prices(config, model)
88
+ table = config.budget_prices || {}
89
+ entry = model ? table[model.to_s] || table[model.to_sym] : nil
90
+ entry ||= table[:default] || table["default"]
91
+ DEFAULT_PRICES.merge(entry || {})
92
+ end
93
+
94
+ def compute_cost(usage, provider:, model:, config: RubyLlmMesh.configuration)
95
+ prices = resolve_prices(config, model)
96
+ prompt_tokens = usage_value(usage, :prompt_tokens, :input_tokens)
97
+ completion_tokens = usage_value(usage, :completion_tokens, :output_tokens)
98
+ input_cost = (prompt_tokens / 1000.0) * prices[:input_per_1k]
99
+ output_cost = (completion_tokens / 1000.0) * prices[:output_per_1k]
100
+ input_cost + output_cost
101
+ end
102
+
103
+ def extract_tokens(usage)
104
+ usage_value(usage, :total_tokens, :prompt_tokens, :completion_tokens, :input_tokens, :output_tokens)
105
+ end
106
+
107
+ private
108
+
109
+ def usage_value(usage, *keys)
110
+ hash = usage.is_a?(Hash) ? usage : {}
111
+ keys.each do |key|
112
+ value = hash[key] || hash[key.to_s]
113
+ return value.to_i if value
114
+ end
115
+ hash["total_tokens"].to_i
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def token_limit?
122
+ !@config.budget_max_tokens.nil?
123
+ end
124
+
125
+ def usd_limit?
126
+ !@config.budget_max_usd.nil?
127
+ end
128
+
129
+ def tokens_remaining
130
+ return nil unless token_limit?
131
+
132
+ [@config.budget_max_tokens - @tokens_consumed, 0].max
133
+ end
134
+
135
+ def usd_remaining
136
+ return nil unless usd_limit?
137
+
138
+ [(@config.budget_max_usd - @usd_consumed).round(6), 0.0].max
139
+ end
140
+
141
+ def raise_budget_exceeded!(dimension, consumed, limit)
142
+ raise BudgetExceededError.new(
143
+ "Budget exceeded: #{dimension} limit #{limit} (#{consumed} consumed)",
144
+ dimension: dimension.to_sym,
145
+ consumed: consumed,
146
+ limit: limit
147
+ )
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLlmMesh
4
+ module Cache
5
+ # Process-local vector entry store used when Redis is unavailable.
6
+ class MemoryStore
7
+ Entry = Struct.new(:id, :prompt, :embedding, :payload, :expires_at, keyword_init: true)
8
+
9
+ def initialize
10
+ @entries = []
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def all
15
+ @mutex.synchronize do
16
+ now = Time.now
17
+ @entries.reject! { |e| e.expires_at && e.expires_at <= now }
18
+ @entries.map(&:dup)
19
+ end
20
+ end
21
+
22
+ def write(id:, prompt:, embedding:, payload:, ttl:)
23
+ expires_at = ttl && ttl.positive? ? Time.now + ttl : nil
24
+ entry = Entry.new(
25
+ id: id,
26
+ prompt: prompt,
27
+ embedding: embedding,
28
+ payload: payload,
29
+ expires_at: expires_at
30
+ )
31
+ @mutex.synchronize do
32
+ @entries.reject! { |e| e.id == id }
33
+ @entries << entry
34
+ end
35
+ true
36
+ end
37
+
38
+ def clear!
39
+ @mutex.synchronize { @entries.clear }
40
+ end
41
+
42
+ def size
43
+ all.length
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module RubyLlmMesh
7
+ module Cache
8
+ # Redis-backed store for distributed semantic cache entries.
9
+ # Requires the optional `redis` gem — soft-loaded only when configured.
10
+ class RedisStore
11
+ INDEX_KEY = "ruby_llm_mesh:semantic_cache:index"
12
+ ENTRY_PREFIX = "ruby_llm_mesh:semantic_cache:entry:"
13
+
14
+ def self.redis_available?
15
+ return @redis_available if defined?(@redis_available) && !@redis_available.nil?
16
+
17
+ begin
18
+ require "redis"
19
+ @redis_available = true
20
+ rescue LoadError
21
+ @redis_available = false
22
+ end
23
+ end
24
+
25
+ def initialize(url:)
26
+ unless self.class.redis_available?
27
+ raise ConfigurationError,
28
+ "semantic cache backend :redis requires the optional `redis` gem — " \
29
+ "add `gem \"redis\"` to your Gemfile or omit redis_url to use memory store"
30
+ end
31
+
32
+ @redis = ::Redis.new(url: url)
33
+ end
34
+
35
+ def all
36
+ ids = @redis.smembers(INDEX_KEY)
37
+ stale = []
38
+ entries = ids.filter_map do |id|
39
+ raw = @redis.get("#{ENTRY_PREFIX}#{id}")
40
+ unless raw
41
+ stale << id
42
+ next
43
+ end
44
+
45
+ data = JSON.parse(raw)
46
+ MemoryStore::Entry.new(
47
+ id: id,
48
+ prompt: data["prompt"],
49
+ embedding: data["embedding"],
50
+ payload: data["payload"],
51
+ expires_at: data["expires_at"] ? Time.at(data["expires_at"]) : nil
52
+ )
53
+ rescue JSON::ParserError
54
+ stale << id
55
+ nil
56
+ end
57
+ cleanup_stale_index!(stale)
58
+ entries
59
+ end
60
+
61
+ def write(id:, prompt:, embedding:, payload:, ttl:)
62
+ id ||= SecureRandom.uuid
63
+ expires_at = ttl && ttl.positive? ? Time.now.to_i + ttl : nil
64
+ data = JSON.generate(
65
+ "prompt" => prompt,
66
+ "embedding" => embedding,
67
+ "payload" => payload,
68
+ "expires_at" => expires_at
69
+ )
70
+ key = "#{ENTRY_PREFIX}#{id}"
71
+ if ttl && ttl.positive?
72
+ @redis.setex(key, ttl, data)
73
+ else
74
+ @redis.set(key, data)
75
+ end
76
+ begin
77
+ @redis.sadd(INDEX_KEY, [id])
78
+ rescue ArgumentError, TypeError, Redis::CommandError
79
+ @redis.sadd(INDEX_KEY, id)
80
+ end
81
+ true
82
+ end
83
+
84
+ def clear!
85
+ ids = @redis.smembers(INDEX_KEY)
86
+ keys = ids.map { |id| "#{ENTRY_PREFIX}#{id}" }
87
+ @redis.del(*keys) if keys.any?
88
+ @redis.del(INDEX_KEY)
89
+ end
90
+
91
+ def size
92
+ all.length
93
+ end
94
+
95
+ private
96
+
97
+ def cleanup_stale_index!(ids)
98
+ return if ids.nil? || ids.empty?
99
+
100
+ ids.each do |id|
101
+ begin
102
+ @redis.srem(INDEX_KEY, [id])
103
+ rescue ArgumentError, TypeError, Redis::CommandError
104
+ @redis.srem(INDEX_KEY, id)
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "securerandom"
5
+ require_relative "memory_store"
6
+ require_relative "redis_store"
7
+
8
+ module RubyLlmMesh
9
+ module Cache
10
+ # Distributed (or process-local) semantic response cache.
11
+ # Embeds prompts via Rag::Embeddings and matches with cosine similarity.
12
+ class SemanticCache
13
+ class << self
14
+ def instance(config: RubyLlmMesh.configuration)
15
+ @instance ||= new(config: config)
16
+ end
17
+
18
+ def reset!
19
+ @instance&.clear!
20
+ @instance = nil
21
+ end
22
+ end
23
+
24
+ attr_reader :store, :embedder
25
+
26
+ def initialize(config: RubyLlmMesh.configuration, store: nil, embedder: nil)
27
+ @config = config
28
+ @embedder = embedder || Rag::Embeddings.new(dimensions: config.semantic_cache_dimensions)
29
+ @store = store || build_store
30
+ end
31
+
32
+ def enabled?
33
+ !!@config.semantic_cache_enabled
34
+ end
35
+
36
+ def lookup(prompt, system: nil)
37
+ return nil unless enabled?
38
+
39
+ query = cache_key_text(prompt, system)
40
+ query_vec = @embedder.embed(query)
41
+ threshold = @config.semantic_cache_threshold
42
+ best = nil
43
+ best_score = -1.0
44
+
45
+ @store.all.each do |entry|
46
+ next if entry.expires_at && entry.expires_at <= Time.now
47
+ next unless entry.embedding.is_a?(Array) && entry.embedding.length == query_vec.length
48
+
49
+ score = @embedder.cosine_similarity(query_vec, entry.embedding)
50
+ if score >= threshold && score > best_score
51
+ best = entry
52
+ best_score = score
53
+ end
54
+ end
55
+
56
+ return nil unless best
57
+
58
+ payload = best.payload || {}
59
+ Response.new(
60
+ content: payload["content"] || payload[:content],
61
+ provider: :semantic_cache,
62
+ model: payload["model"] || payload[:model],
63
+ usage: payload["usage"] || payload[:usage] || {},
64
+ raw: { cache_id: best.id, similarity: best_score, original_provider: payload["provider"] },
65
+ latency_ms: 0,
66
+ fallback_used: false,
67
+ cache_hit: true
68
+ )
69
+ end
70
+
71
+ def store_response(prompt, response, system: nil)
72
+ return false unless enabled?
73
+ return false if response.nil? || response.cache_hit
74
+
75
+ query = cache_key_text(prompt, system)
76
+ embedding = @embedder.embed(query)
77
+ id = Digest::SHA256.hexdigest(query)[0, 32]
78
+ payload = {
79
+ "content" => response.content,
80
+ "provider" => response.provider.to_s,
81
+ "model" => response.model,
82
+ "usage" => response.usage
83
+ }
84
+ @store.write(
85
+ id: id,
86
+ prompt: query,
87
+ embedding: embedding,
88
+ payload: payload,
89
+ ttl: @config.semantic_cache_ttl
90
+ )
91
+ end
92
+
93
+ def clear!
94
+ @store.clear!
95
+ end
96
+
97
+ private
98
+
99
+ def cache_key_text(prompt, system)
100
+ [system.to_s.strip, prompt.to_s.strip].reject(&:empty?).join("\n---\n")
101
+ end
102
+
103
+ def build_store
104
+ backend = @config.semantic_cache_backend
105
+ backend = infer_backend if backend.nil?
106
+
107
+ case backend.to_sym
108
+ when :redis
109
+ RedisStore.new(url: @config.redis_url)
110
+ when :memory
111
+ MemoryStore.new
112
+ else
113
+ raise ConfigurationError, "Unknown semantic_cache_backend: #{backend.inspect}"
114
+ end
115
+ end
116
+
117
+ def infer_backend
118
+ if @config.redis_url && !@config.redis_url.to_s.empty? && RedisStore.redis_available?
119
+ :redis
120
+ else
121
+ :memory
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -22,13 +22,23 @@ module RubyLlmMesh
22
22
  true
23
23
  when OPEN
24
24
  if Time.now - entry[:opened_at] >= @reset_timeout
25
+ # Single probe: transition to half-open and allow only this caller.
25
26
  entry[:state] = HALF_OPEN
27
+ entry[:opened_at] = Time.now
26
28
  true
27
29
  else
28
30
  false
29
31
  end
30
32
  when HALF_OPEN
31
- true
33
+ # If the probe never reported (hung request), allow another after timeout.
34
+ # With reset_timeout 0, stay blocked until success/failure is recorded.
35
+ if @reset_timeout.positive? && entry[:opened_at] &&
36
+ Time.now - entry[:opened_at] >= @reset_timeout
37
+ entry[:opened_at] = Time.now
38
+ true
39
+ else
40
+ false
41
+ end
32
42
  end
33
43
  end
34
44
  end