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
|
@@ -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
|
-
|
|
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
|