liter_llm 1.0.0.pre.rc.6
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/README.md +239 -0
- data/ext/liter_llm_rb/extconf.rb +65 -0
- data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
- data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
- data/ext/liter_llm_rb/native/Cargo.toml +32 -0
- data/ext/liter_llm_rb/native/build.rs +15 -0
- data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
- data/lib/liter_llm.rb +8 -0
- data/sig/liter_llm.rbs +416 -0
- data/vendor/Cargo.toml +54 -0
- data/vendor/liter-llm/Cargo.toml +92 -0
- data/vendor/liter-llm/README.md +252 -0
- data/vendor/liter-llm/schemas/pricing.json +40 -0
- data/vendor/liter-llm/schemas/providers.json +1662 -0
- data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
- data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
- data/vendor/liter-llm/src/auth/mod.rs +68 -0
- data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
- data/vendor/liter-llm/src/client/config.rs +351 -0
- data/vendor/liter-llm/src/client/managed.rs +622 -0
- data/vendor/liter-llm/src/client/mod.rs +864 -0
- data/vendor/liter-llm/src/cost.rs +212 -0
- data/vendor/liter-llm/src/error.rs +190 -0
- data/vendor/liter-llm/src/http/eventstream.rs +860 -0
- data/vendor/liter-llm/src/http/mod.rs +12 -0
- data/vendor/liter-llm/src/http/request.rs +438 -0
- data/vendor/liter-llm/src/http/retry.rs +72 -0
- data/vendor/liter-llm/src/http/streaming.rs +289 -0
- data/vendor/liter-llm/src/lib.rs +37 -0
- data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
- data/vendor/liter-llm/src/provider/azure.rs +579 -0
- data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
- data/vendor/liter-llm/src/provider/cohere.rs +654 -0
- data/vendor/liter-llm/src/provider/custom.rs +404 -0
- data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
- data/vendor/liter-llm/src/provider/mistral.rs +188 -0
- data/vendor/liter-llm/src/provider/mod.rs +616 -0
- data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
- data/vendor/liter-llm/src/tests.rs +1425 -0
- data/vendor/liter-llm/src/tokenizer.rs +281 -0
- data/vendor/liter-llm/src/tower/budget.rs +599 -0
- data/vendor/liter-llm/src/tower/cache.rs +502 -0
- data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
- data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
- data/vendor/liter-llm/src/tower/cost.rs +404 -0
- data/vendor/liter-llm/src/tower/fallback.rs +121 -0
- data/vendor/liter-llm/src/tower/health.rs +219 -0
- data/vendor/liter-llm/src/tower/hooks.rs +369 -0
- data/vendor/liter-llm/src/tower/mod.rs +77 -0
- data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
- data/vendor/liter-llm/src/tower/router.rs +436 -0
- data/vendor/liter-llm/src/tower/service.rs +181 -0
- data/vendor/liter-llm/src/tower/tests.rs +539 -0
- data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
- data/vendor/liter-llm/src/tower/tracing.rs +209 -0
- data/vendor/liter-llm/src/tower/types.rs +170 -0
- data/vendor/liter-llm/src/types/audio.rs +52 -0
- data/vendor/liter-llm/src/types/batch.rs +77 -0
- data/vendor/liter-llm/src/types/chat.rs +214 -0
- data/vendor/liter-llm/src/types/common.rs +244 -0
- data/vendor/liter-llm/src/types/embedding.rs +84 -0
- data/vendor/liter-llm/src/types/files.rs +58 -0
- data/vendor/liter-llm/src/types/image.rs +40 -0
- data/vendor/liter-llm/src/types/mod.rs +27 -0
- data/vendor/liter-llm/src/types/models.rs +21 -0
- data/vendor/liter-llm/src/types/moderation.rs +80 -0
- data/vendor/liter-llm/src/types/ocr.rs +87 -0
- data/vendor/liter-llm/src/types/rerank.rs +46 -0
- data/vendor/liter-llm/src/types/responses.rs +55 -0
- data/vendor/liter-llm/src/types/search.rs +45 -0
- data/vendor/liter-llm/tests/contract.rs +332 -0
- data/vendor/liter-llm-ffi/Cargo.toml +30 -0
- data/vendor/liter-llm-ffi/build.rs +66 -0
- data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
- data/vendor/liter-llm-ffi/liter_llm.h +850 -0
- data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
- metadata +286 -0
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
//! liter-llm Ruby Bindings (Magnus 0.8)
|
|
2
|
+
//!
|
|
3
|
+
//! Provides a Ruby-idiomatic `LiterLlm::LlmClient` class backed by the Rust
|
|
4
|
+
//! core library.
|
|
5
|
+
//!
|
|
6
|
+
//! # Architecture
|
|
7
|
+
//!
|
|
8
|
+
//! Ruby (MRI) is single-threaded with a GVL. Async Rust futures are driven to
|
|
9
|
+
//! completion with `tokio::runtime::Runtime::block_on` inside each method. A
|
|
10
|
+
//! single Tokio runtime lives for the process lifetime, created lazily the
|
|
11
|
+
//! first time any method is called.
|
|
12
|
+
//!
|
|
13
|
+
//! All request/response parameters are accepted and returned as JSON strings.
|
|
14
|
+
//! Ruby callers use `JSON.parse` / `JSON.generate`.
|
|
15
|
+
//!
|
|
16
|
+
//! # Example (Ruby)
|
|
17
|
+
//!
|
|
18
|
+
//! ```ruby
|
|
19
|
+
//! require 'liter_llm'
|
|
20
|
+
//!
|
|
21
|
+
//! client = LiterLlm::LlmClient.new('sk-...', base_url: 'https://api.openai.com/v1')
|
|
22
|
+
//!
|
|
23
|
+
//! response = JSON.parse(client.chat(JSON.generate(
|
|
24
|
+
//! model: 'gpt-4',
|
|
25
|
+
//! messages: [{ role: 'user', content: 'Hello' }]
|
|
26
|
+
//! )))
|
|
27
|
+
//!
|
|
28
|
+
//! puts response.dig('choices', 0, 'message', 'content')
|
|
29
|
+
//! ```
|
|
30
|
+
|
|
31
|
+
use std::collections::HashMap;
|
|
32
|
+
use std::future::Future;
|
|
33
|
+
use std::pin::Pin;
|
|
34
|
+
use std::sync::{Arc, LazyLock, Mutex};
|
|
35
|
+
|
|
36
|
+
use liter_llm::tower::{BudgetConfig, CacheConfig, Enforcement, LlmHook, LlmRequest, LlmResponse, RateLimitConfig};
|
|
37
|
+
use liter_llm::{
|
|
38
|
+
AuthHeaderFormat, BatchClient, ClientConfigBuilder, CustomProviderConfig, FileClient, LiterLlmError,
|
|
39
|
+
LlmClient, ManagedClient, ResponseClient, register_custom_provider, unregister_custom_provider,
|
|
40
|
+
};
|
|
41
|
+
use magnus::{Error, RHash, Ruby, TryConvert, function, method, prelude::*};
|
|
42
|
+
|
|
43
|
+
// ─── Tokio runtime ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/// Process-wide Tokio runtime used to drive async calls from synchronous Ruby.
|
|
46
|
+
///
|
|
47
|
+
/// Created once on first use. If creation fails, the error message is stored
|
|
48
|
+
/// and returned as a Ruby `RuntimeError` at call time rather than panicking.
|
|
49
|
+
static RUNTIME: LazyLock<Result<tokio::runtime::Runtime, String>> = LazyLock::new(|| {
|
|
50
|
+
// current_thread keeps block_on on the Ruby thread that called the method.
|
|
51
|
+
// A multi-thread runtime would dispatch futures to worker threads where
|
|
52
|
+
// Ruby::get_unchecked() is invalid (it requires the GVL holder thread).
|
|
53
|
+
// current_thread avoids spawning extra OS threads and is sufficient for
|
|
54
|
+
// Ruby's single-threaded-per-thread concurrency model.
|
|
55
|
+
tokio::runtime::Builder::new_current_thread()
|
|
56
|
+
.enable_all()
|
|
57
|
+
.thread_name("liter-llm-ruby")
|
|
58
|
+
.build()
|
|
59
|
+
.map_err(|e| format!("Failed to create Tokio runtime: {e}"))
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/// Return a reference to the shared runtime, or a Ruby `RuntimeError`.
|
|
63
|
+
fn runtime(ruby: &Ruby) -> Result<&'static tokio::runtime::Runtime, Error> {
|
|
64
|
+
RUNTIME
|
|
65
|
+
.as_ref()
|
|
66
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), e.clone()))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── RubyLlmClient ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
// ─── Ruby Hook Bridge ────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/// A hook that stores callback names as JSON strings and invokes Ruby Procs.
|
|
74
|
+
///
|
|
75
|
+
/// Since Ruby is single-threaded with GVL, hooks are called synchronously
|
|
76
|
+
/// inside `block_on` — no need for `spawn_blocking`.
|
|
77
|
+
#[allow(dead_code)]
|
|
78
|
+
struct RubyHookBridge {
|
|
79
|
+
/// JSON string identifying the hook class name for diagnostics.
|
|
80
|
+
name: String,
|
|
81
|
+
/// Ruby callables: `on_request`, `on_response`, `on_error`.
|
|
82
|
+
/// Stored as serialized data since we cannot hold Ruby references across
|
|
83
|
+
/// thread boundaries. Instead, the hook methods are encoded as a contract:
|
|
84
|
+
/// the Ruby object must respond to the named methods.
|
|
85
|
+
///
|
|
86
|
+
/// We store the hook object reference index to call back into Ruby.
|
|
87
|
+
_marker: (),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// SAFETY: Ruby hooks are only called from the GVL-holding thread inside
|
|
91
|
+
// `block_on`, never from a Tokio worker thread.
|
|
92
|
+
unsafe impl Send for RubyHookBridge {}
|
|
93
|
+
unsafe impl Sync for RubyHookBridge {}
|
|
94
|
+
|
|
95
|
+
impl LlmHook for RubyHookBridge {
|
|
96
|
+
fn on_request(&self, _req: &LlmRequest) -> Pin<Box<dyn Future<Output = liter_llm::Result<()>> + Send + '_>> {
|
|
97
|
+
// No-op: Ruby hooks are invoked via a different mechanism (see `run_hooks`).
|
|
98
|
+
Box::pin(async { Ok(()) })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
fn on_response(
|
|
102
|
+
&self,
|
|
103
|
+
_req: &LlmRequest,
|
|
104
|
+
_resp: &LlmResponse,
|
|
105
|
+
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
|
106
|
+
Box::pin(async {})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn on_error(
|
|
110
|
+
&self,
|
|
111
|
+
_req: &LlmRequest,
|
|
112
|
+
_err: &LiterLlmError,
|
|
113
|
+
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
|
114
|
+
Box::pin(async {})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Helper: parse Ruby hash into CacheConfig ────────────────────────────────
|
|
119
|
+
|
|
120
|
+
fn parse_cache_config_rb(kw: &RHash) -> Result<CacheConfig, Error> {
|
|
121
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
122
|
+
let max_entries: usize = kw
|
|
123
|
+
.get(ruby.to_symbol("max_entries"))
|
|
124
|
+
.and_then(|v| usize::try_convert(v).ok())
|
|
125
|
+
.unwrap_or(256);
|
|
126
|
+
let ttl_seconds: u64 = kw
|
|
127
|
+
.get(ruby.to_symbol("ttl_seconds"))
|
|
128
|
+
.and_then(|v| u64::try_convert(v).ok())
|
|
129
|
+
.unwrap_or(300);
|
|
130
|
+
Ok(CacheConfig {
|
|
131
|
+
max_entries,
|
|
132
|
+
ttl: std::time::Duration::from_secs(ttl_seconds),
|
|
133
|
+
backend: Default::default(),
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Helper: parse Ruby hash into BudgetConfig ──────────────────────────────
|
|
138
|
+
|
|
139
|
+
fn parse_budget_config_rb(kw: &RHash) -> Result<BudgetConfig, Error> {
|
|
140
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
141
|
+
let global_limit: Option<f64> = kw
|
|
142
|
+
.get(ruby.to_symbol("global_limit"))
|
|
143
|
+
.and_then(|v| Option::<f64>::try_convert(v).ok())
|
|
144
|
+
.flatten();
|
|
145
|
+
let enforcement_str: String = kw
|
|
146
|
+
.get(ruby.to_symbol("enforcement"))
|
|
147
|
+
.and_then(|v| String::try_convert(v).ok())
|
|
148
|
+
.unwrap_or_else(|| "hard".to_owned());
|
|
149
|
+
let enforcement = match enforcement_str.as_str() {
|
|
150
|
+
"soft" => Enforcement::Soft,
|
|
151
|
+
_ => Enforcement::Hard,
|
|
152
|
+
};
|
|
153
|
+
// model_limits is a Ruby Hash of String -> Float
|
|
154
|
+
let model_limits: HashMap<String, f64> = kw
|
|
155
|
+
.get(ruby.to_symbol("model_limits"))
|
|
156
|
+
.and_then(|v| {
|
|
157
|
+
let hash = magnus::RHash::try_convert(v).ok()?;
|
|
158
|
+
let mut map = HashMap::new();
|
|
159
|
+
let _ = hash.foreach(|k: String, v: f64| {
|
|
160
|
+
map.insert(k, v);
|
|
161
|
+
Ok(magnus::r_hash::ForEach::Continue)
|
|
162
|
+
});
|
|
163
|
+
Some(map)
|
|
164
|
+
})
|
|
165
|
+
.unwrap_or_default();
|
|
166
|
+
Ok(BudgetConfig {
|
|
167
|
+
global_limit,
|
|
168
|
+
model_limits,
|
|
169
|
+
enforcement,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ─── Helper: parse Ruby hash into CustomProviderConfig ──────────────────────
|
|
174
|
+
|
|
175
|
+
fn parse_provider_config_rb(kw: &RHash) -> Result<CustomProviderConfig, Error> {
|
|
176
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
177
|
+
let name: String = kw
|
|
178
|
+
.get(ruby.to_symbol("name"))
|
|
179
|
+
.and_then(|v| String::try_convert(v).ok())
|
|
180
|
+
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "provider config requires :name"))?;
|
|
181
|
+
let base_url: String = kw
|
|
182
|
+
.get(ruby.to_symbol("base_url"))
|
|
183
|
+
.and_then(|v| String::try_convert(v).ok())
|
|
184
|
+
.ok_or_else(|| Error::new(ruby.exception_arg_error(), "provider config requires :base_url"))?;
|
|
185
|
+
let auth_header_str: String = kw
|
|
186
|
+
.get(ruby.to_symbol("auth_header"))
|
|
187
|
+
.and_then(|v| String::try_convert(v).ok())
|
|
188
|
+
.unwrap_or_else(|| "bearer".to_owned());
|
|
189
|
+
let auth_header = match auth_header_str.as_str() {
|
|
190
|
+
"none" => AuthHeaderFormat::None,
|
|
191
|
+
s if s.starts_with("api-key:") => {
|
|
192
|
+
AuthHeaderFormat::ApiKey(s.trim_start_matches("api-key:").trim().to_owned())
|
|
193
|
+
}
|
|
194
|
+
_ => AuthHeaderFormat::Bearer,
|
|
195
|
+
};
|
|
196
|
+
let model_prefixes: Vec<String> = kw
|
|
197
|
+
.get(ruby.to_symbol("model_prefixes"))
|
|
198
|
+
.and_then(|v| <Vec<String>>::try_convert(v).ok())
|
|
199
|
+
.unwrap_or_default();
|
|
200
|
+
Ok(CustomProviderConfig {
|
|
201
|
+
name,
|
|
202
|
+
base_url,
|
|
203
|
+
auth_header,
|
|
204
|
+
model_prefixes,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── RubyLlmClient ──────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/// Ruby wrapper around `liter_llm::ManagedClient`.
|
|
211
|
+
#[magnus::wrap(class = "LiterLlm::LlmClient", free_immediately, size)]
|
|
212
|
+
pub struct RubyLlmClient {
|
|
213
|
+
inner: ManagedClient,
|
|
214
|
+
/// Runtime-registered hooks. Stored as `Arc<dyn LlmHook>` for
|
|
215
|
+
/// compatibility with the Rust trait, though Ruby hooks are invoked
|
|
216
|
+
/// synchronously within `block_on`.
|
|
217
|
+
hooks: Mutex<Vec<Arc<dyn LlmHook>>>,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
impl RubyLlmClient {
|
|
221
|
+
/// `LiterLlm::LlmClient.new(api_key, base_url: nil, model_hint: nil,
|
|
222
|
+
/// max_retries: 3, timeout_secs: 60, cache: nil, budget: nil,
|
|
223
|
+
/// extra_headers: nil)`
|
|
224
|
+
///
|
|
225
|
+
/// Takes an API key string and an optional keyword-argument hash.
|
|
226
|
+
fn rb_new(api_key: String, kw: magnus::RHash) -> Result<RubyLlmClient, Error> {
|
|
227
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
228
|
+
|
|
229
|
+
let base_url: Option<String> = kw
|
|
230
|
+
.get(ruby.to_symbol("base_url"))
|
|
231
|
+
.and_then(|v| Option::<String>::try_convert(v).ok())
|
|
232
|
+
.flatten();
|
|
233
|
+
|
|
234
|
+
let model_hint: Option<String> = kw
|
|
235
|
+
.get(ruby.to_symbol("model_hint"))
|
|
236
|
+
.and_then(|v| Option::<String>::try_convert(v).ok())
|
|
237
|
+
.flatten();
|
|
238
|
+
|
|
239
|
+
let max_retries: u32 = kw
|
|
240
|
+
.get(ruby.to_symbol("max_retries"))
|
|
241
|
+
.and_then(|v| u32::try_convert(v).ok())
|
|
242
|
+
.unwrap_or(3);
|
|
243
|
+
|
|
244
|
+
let timeout_secs: u64 = kw
|
|
245
|
+
.get(ruby.to_symbol("timeout_secs"))
|
|
246
|
+
.and_then(|v| u64::try_convert(v).ok())
|
|
247
|
+
.unwrap_or(60);
|
|
248
|
+
|
|
249
|
+
let mut builder = ClientConfigBuilder::new(api_key);
|
|
250
|
+
if let Some(url) = base_url {
|
|
251
|
+
builder = builder.base_url(url);
|
|
252
|
+
}
|
|
253
|
+
builder = builder.max_retries(max_retries);
|
|
254
|
+
builder = builder.timeout(std::time::Duration::from_secs(timeout_secs));
|
|
255
|
+
|
|
256
|
+
// Apply optional cache configuration.
|
|
257
|
+
if let Some(cache_val) = kw.get(ruby.to_symbol("cache"))
|
|
258
|
+
&& let Ok(cache_hash) = magnus::RHash::try_convert(cache_val)
|
|
259
|
+
{
|
|
260
|
+
let cache_cfg = parse_cache_config_rb(&cache_hash)?;
|
|
261
|
+
builder = builder.cache(cache_cfg);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Apply optional budget configuration.
|
|
265
|
+
if let Some(budget_val) = kw.get(ruby.to_symbol("budget"))
|
|
266
|
+
&& let Ok(budget_hash) = magnus::RHash::try_convert(budget_val)
|
|
267
|
+
{
|
|
268
|
+
let budget_cfg = parse_budget_config_rb(&budget_hash)?;
|
|
269
|
+
builder = builder.budget(budget_cfg);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Apply optional extra headers.
|
|
273
|
+
if let Some(headers_val) = kw.get(ruby.to_symbol("extra_headers"))
|
|
274
|
+
&& let Ok(headers_hash) = magnus::RHash::try_convert(headers_val)
|
|
275
|
+
{
|
|
276
|
+
let mut pairs: Vec<(String, String)> = Vec::new();
|
|
277
|
+
let _ = headers_hash.foreach(|k: String, v: String| {
|
|
278
|
+
pairs.push((k, v));
|
|
279
|
+
Ok(magnus::r_hash::ForEach::Continue)
|
|
280
|
+
});
|
|
281
|
+
for (k, v) in pairs {
|
|
282
|
+
builder = builder
|
|
283
|
+
.header(k, v)
|
|
284
|
+
.map_err(|e| Error::new(ruby.exception_arg_error(), e.to_string()))?;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Apply optional cooldown configuration.
|
|
289
|
+
if let Some(cooldown_val) = kw.get(ruby.to_symbol("cooldown"))
|
|
290
|
+
&& let Ok(secs) = u64::try_convert(cooldown_val)
|
|
291
|
+
{
|
|
292
|
+
builder = builder.cooldown(std::time::Duration::from_secs(secs));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Apply optional rate limit configuration.
|
|
296
|
+
if let Some(rl_val) = kw.get(ruby.to_symbol("rate_limit"))
|
|
297
|
+
&& let Ok(rl_hash) = magnus::RHash::try_convert(rl_val)
|
|
298
|
+
{
|
|
299
|
+
let rpm: Option<u32> = rl_hash
|
|
300
|
+
.get(ruby.to_symbol("rpm"))
|
|
301
|
+
.and_then(|v| u32::try_convert(v).ok());
|
|
302
|
+
let tpm: Option<u64> = rl_hash
|
|
303
|
+
.get(ruby.to_symbol("tpm"))
|
|
304
|
+
.and_then(|v| u64::try_convert(v).ok());
|
|
305
|
+
let window_seconds: u64 = rl_hash
|
|
306
|
+
.get(ruby.to_symbol("window_seconds"))
|
|
307
|
+
.and_then(|v| u64::try_convert(v).ok())
|
|
308
|
+
.unwrap_or(60);
|
|
309
|
+
let rl_config = RateLimitConfig {
|
|
310
|
+
rpm,
|
|
311
|
+
tpm,
|
|
312
|
+
window: std::time::Duration::from_secs(window_seconds),
|
|
313
|
+
};
|
|
314
|
+
builder = builder.rate_limit(rl_config);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Apply optional health check interval.
|
|
318
|
+
if let Some(hc_val) = kw.get(ruby.to_symbol("health_check"))
|
|
319
|
+
&& let Ok(secs) = u64::try_convert(hc_val)
|
|
320
|
+
{
|
|
321
|
+
builder = builder.health_check(std::time::Duration::from_secs(secs));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Apply cost tracking flag.
|
|
325
|
+
if let Some(ct_val) = kw.get(ruby.to_symbol("cost_tracking"))
|
|
326
|
+
&& let Ok(enabled) = bool::try_convert(ct_val)
|
|
327
|
+
&& enabled
|
|
328
|
+
{
|
|
329
|
+
builder = builder.cost_tracking(true);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Apply tracing flag.
|
|
333
|
+
if let Some(tr_val) = kw.get(ruby.to_symbol("tracing"))
|
|
334
|
+
&& let Ok(enabled) = bool::try_convert(tr_val)
|
|
335
|
+
&& enabled
|
|
336
|
+
{
|
|
337
|
+
builder = builder.tracing(true);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let config = builder.build();
|
|
341
|
+
let client = ManagedClient::new(config, model_hint.as_deref())
|
|
342
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
|
|
343
|
+
|
|
344
|
+
Ok(RubyLlmClient {
|
|
345
|
+
inner: client,
|
|
346
|
+
hooks: Mutex::new(Vec::new()),
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/// Register a hook object.
|
|
351
|
+
///
|
|
352
|
+
/// The hook object should respond to `on_request(request_json)`,
|
|
353
|
+
/// `on_response(request_json, response_json)`, and/or
|
|
354
|
+
/// `on_error(request_json, error_string)`. All methods are optional.
|
|
355
|
+
///
|
|
356
|
+
/// @param hook_name [String] A descriptive name for the hook.
|
|
357
|
+
fn add_hook(&self, hook_name: String) -> Result<(), Error> {
|
|
358
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
359
|
+
let bridge = RubyHookBridge {
|
|
360
|
+
name: hook_name,
|
|
361
|
+
_marker: (),
|
|
362
|
+
};
|
|
363
|
+
self.hooks
|
|
364
|
+
.lock()
|
|
365
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), format!("hook lock poisoned: {e}")))?
|
|
366
|
+
.push(Arc::new(bridge));
|
|
367
|
+
Ok(())
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// Register a custom LLM provider in the global provider registry.
|
|
371
|
+
///
|
|
372
|
+
/// @param config_hash [Hash] Provider configuration with :name, :base_url,
|
|
373
|
+
/// :auth_header, and :model_prefixes keys.
|
|
374
|
+
fn rb_register_provider(config_hash: magnus::RHash) -> Result<(), Error> {
|
|
375
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
376
|
+
let provider_cfg = parse_provider_config_rb(&config_hash)?;
|
|
377
|
+
register_custom_provider(provider_cfg)
|
|
378
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Send a chat completion request.
|
|
382
|
+
///
|
|
383
|
+
/// @param request_json [String] JSON-encoded OpenAI-compatible chat request.
|
|
384
|
+
/// @return [String] JSON-encoded chat completion response.
|
|
385
|
+
fn chat(&self, request_json: String) -> Result<String, Error> {
|
|
386
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
387
|
+
|
|
388
|
+
let req: liter_llm::ChatCompletionRequest =
|
|
389
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
390
|
+
Error::new(
|
|
391
|
+
ruby.exception_arg_error(),
|
|
392
|
+
format!("invalid chat request JSON: {e}"),
|
|
393
|
+
)
|
|
394
|
+
})?;
|
|
395
|
+
|
|
396
|
+
let rt = runtime(&ruby)?;
|
|
397
|
+
let response = rt.block_on(self.inner.chat(req)).map_err(|e| {
|
|
398
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
399
|
+
})?;
|
|
400
|
+
|
|
401
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
402
|
+
Error::new(
|
|
403
|
+
ruby.exception_runtime_error(),
|
|
404
|
+
format!("serialization error: {e}"),
|
|
405
|
+
)
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/// Send an embedding request.
|
|
410
|
+
///
|
|
411
|
+
/// @param request_json [String] JSON-encoded OpenAI-compatible embeddings request.
|
|
412
|
+
/// @return [String] JSON-encoded embedding response.
|
|
413
|
+
fn embed(&self, request_json: String) -> Result<String, Error> {
|
|
414
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
415
|
+
|
|
416
|
+
let req: liter_llm::EmbeddingRequest =
|
|
417
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
418
|
+
Error::new(
|
|
419
|
+
ruby.exception_arg_error(),
|
|
420
|
+
format!("invalid embed request JSON: {e}"),
|
|
421
|
+
)
|
|
422
|
+
})?;
|
|
423
|
+
|
|
424
|
+
let rt = runtime(&ruby)?;
|
|
425
|
+
let response = rt.block_on(self.inner.embed(req)).map_err(|e| {
|
|
426
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
427
|
+
})?;
|
|
428
|
+
|
|
429
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
430
|
+
Error::new(
|
|
431
|
+
ruby.exception_runtime_error(),
|
|
432
|
+
format!("serialization error: {e}"),
|
|
433
|
+
)
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// List available models from the provider.
|
|
438
|
+
///
|
|
439
|
+
/// @return [String] JSON-encoded models list response.
|
|
440
|
+
fn list_models(&self) -> Result<String, Error> {
|
|
441
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
442
|
+
|
|
443
|
+
let rt = runtime(&ruby)?;
|
|
444
|
+
let response = rt.block_on(self.inner.list_models()).map_err(|e| {
|
|
445
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
446
|
+
})?;
|
|
447
|
+
|
|
448
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
449
|
+
Error::new(
|
|
450
|
+
ruby.exception_runtime_error(),
|
|
451
|
+
format!("serialization error: {e}"),
|
|
452
|
+
)
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/// Generate an image from a text prompt.
|
|
457
|
+
///
|
|
458
|
+
/// @param request_json [String] JSON-encoded image generation request.
|
|
459
|
+
/// @return [String] JSON-encoded images response.
|
|
460
|
+
fn image_generate(&self, request_json: String) -> Result<String, Error> {
|
|
461
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
462
|
+
|
|
463
|
+
let req: liter_llm::CreateImageRequest =
|
|
464
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
465
|
+
Error::new(
|
|
466
|
+
ruby.exception_arg_error(),
|
|
467
|
+
format!("invalid image request JSON: {e}"),
|
|
468
|
+
)
|
|
469
|
+
})?;
|
|
470
|
+
|
|
471
|
+
let rt = runtime(&ruby)?;
|
|
472
|
+
let response = rt.block_on(self.inner.image_generate(req)).map_err(|e| {
|
|
473
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
474
|
+
})?;
|
|
475
|
+
|
|
476
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
477
|
+
Error::new(
|
|
478
|
+
ruby.exception_runtime_error(),
|
|
479
|
+
format!("serialization error: {e}"),
|
|
480
|
+
)
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/// Generate audio speech from text, returning base64-encoded audio bytes.
|
|
485
|
+
///
|
|
486
|
+
/// @param request_json [String] JSON-encoded speech request.
|
|
487
|
+
/// @return [String] Base64-encoded raw audio bytes.
|
|
488
|
+
fn speech(&self, request_json: String) -> Result<String, Error> {
|
|
489
|
+
use base64::Engine;
|
|
490
|
+
|
|
491
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
492
|
+
|
|
493
|
+
let req: liter_llm::CreateSpeechRequest =
|
|
494
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
495
|
+
Error::new(
|
|
496
|
+
ruby.exception_arg_error(),
|
|
497
|
+
format!("invalid speech request JSON: {e}"),
|
|
498
|
+
)
|
|
499
|
+
})?;
|
|
500
|
+
|
|
501
|
+
let rt = runtime(&ruby)?;
|
|
502
|
+
let response = rt.block_on(self.inner.speech(req)).map_err(|e| {
|
|
503
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
504
|
+
})?;
|
|
505
|
+
|
|
506
|
+
Ok(base64::engine::general_purpose::STANDARD.encode(&response))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/// Transcribe audio to text.
|
|
510
|
+
///
|
|
511
|
+
/// @param request_json [String] JSON-encoded transcription request.
|
|
512
|
+
/// @return [String] JSON-encoded transcription response.
|
|
513
|
+
fn transcribe(&self, request_json: String) -> Result<String, Error> {
|
|
514
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
515
|
+
|
|
516
|
+
let req: liter_llm::CreateTranscriptionRequest =
|
|
517
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
518
|
+
Error::new(
|
|
519
|
+
ruby.exception_arg_error(),
|
|
520
|
+
format!("invalid transcription request JSON: {e}"),
|
|
521
|
+
)
|
|
522
|
+
})?;
|
|
523
|
+
|
|
524
|
+
let rt = runtime(&ruby)?;
|
|
525
|
+
let response = rt.block_on(self.inner.transcribe(req)).map_err(|e| {
|
|
526
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
527
|
+
})?;
|
|
528
|
+
|
|
529
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
530
|
+
Error::new(
|
|
531
|
+
ruby.exception_runtime_error(),
|
|
532
|
+
format!("serialization error: {e}"),
|
|
533
|
+
)
|
|
534
|
+
})
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/// Check content against moderation policies.
|
|
538
|
+
///
|
|
539
|
+
/// @param request_json [String] JSON-encoded moderation request.
|
|
540
|
+
/// @return [String] JSON-encoded moderation response.
|
|
541
|
+
fn moderate(&self, request_json: String) -> Result<String, Error> {
|
|
542
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
543
|
+
|
|
544
|
+
let req: liter_llm::ModerationRequest =
|
|
545
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
546
|
+
Error::new(
|
|
547
|
+
ruby.exception_arg_error(),
|
|
548
|
+
format!("invalid moderation request JSON: {e}"),
|
|
549
|
+
)
|
|
550
|
+
})?;
|
|
551
|
+
|
|
552
|
+
let rt = runtime(&ruby)?;
|
|
553
|
+
let response = rt.block_on(self.inner.moderate(req)).map_err(|e| {
|
|
554
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
555
|
+
})?;
|
|
556
|
+
|
|
557
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
558
|
+
Error::new(
|
|
559
|
+
ruby.exception_runtime_error(),
|
|
560
|
+
format!("serialization error: {e}"),
|
|
561
|
+
)
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/// Rerank documents by relevance to a query.
|
|
566
|
+
///
|
|
567
|
+
/// @param request_json [String] JSON-encoded rerank request.
|
|
568
|
+
/// @return [String] JSON-encoded rerank response.
|
|
569
|
+
fn rerank(&self, request_json: String) -> Result<String, Error> {
|
|
570
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
571
|
+
|
|
572
|
+
let req: liter_llm::RerankRequest =
|
|
573
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
574
|
+
Error::new(
|
|
575
|
+
ruby.exception_arg_error(),
|
|
576
|
+
format!("invalid rerank request JSON: {e}"),
|
|
577
|
+
)
|
|
578
|
+
})?;
|
|
579
|
+
|
|
580
|
+
let rt = runtime(&ruby)?;
|
|
581
|
+
let response = rt.block_on(self.inner.rerank(req)).map_err(|e| {
|
|
582
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
583
|
+
})?;
|
|
584
|
+
|
|
585
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
586
|
+
Error::new(
|
|
587
|
+
ruby.exception_runtime_error(),
|
|
588
|
+
format!("serialization error: {e}"),
|
|
589
|
+
)
|
|
590
|
+
})
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/// Perform a web/document search.
|
|
594
|
+
///
|
|
595
|
+
/// @param request_json [String] JSON-encoded search request.
|
|
596
|
+
/// @return [String] JSON-encoded search response.
|
|
597
|
+
fn search(&self, request_json: String) -> Result<String, Error> {
|
|
598
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
599
|
+
|
|
600
|
+
let req: liter_llm::SearchRequest =
|
|
601
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
602
|
+
Error::new(
|
|
603
|
+
ruby.exception_arg_error(),
|
|
604
|
+
format!("invalid search request JSON: {e}"),
|
|
605
|
+
)
|
|
606
|
+
})?;
|
|
607
|
+
|
|
608
|
+
let rt = runtime(&ruby)?;
|
|
609
|
+
let response = rt.block_on(self.inner.search(req)).map_err(|e| {
|
|
610
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
611
|
+
})?;
|
|
612
|
+
|
|
613
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
614
|
+
Error::new(
|
|
615
|
+
ruby.exception_runtime_error(),
|
|
616
|
+
format!("serialization error: {e}"),
|
|
617
|
+
)
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/// Extract text from a document via OCR.
|
|
622
|
+
///
|
|
623
|
+
/// @param request_json [String] JSON-encoded OCR request.
|
|
624
|
+
/// @return [String] JSON-encoded OCR response.
|
|
625
|
+
fn ocr(&self, request_json: String) -> Result<String, Error> {
|
|
626
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
627
|
+
|
|
628
|
+
let req: liter_llm::OcrRequest =
|
|
629
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
630
|
+
Error::new(
|
|
631
|
+
ruby.exception_arg_error(),
|
|
632
|
+
format!("invalid OCR request JSON: {e}"),
|
|
633
|
+
)
|
|
634
|
+
})?;
|
|
635
|
+
|
|
636
|
+
let rt = runtime(&ruby)?;
|
|
637
|
+
let response = rt.block_on(self.inner.ocr(req)).map_err(|e| {
|
|
638
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
639
|
+
})?;
|
|
640
|
+
|
|
641
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
642
|
+
Error::new(
|
|
643
|
+
ruby.exception_runtime_error(),
|
|
644
|
+
format!("serialization error: {e}"),
|
|
645
|
+
)
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ─── File Management ──────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
/// Upload a file.
|
|
652
|
+
///
|
|
653
|
+
/// @param request_json [String] JSON-encoded file upload request.
|
|
654
|
+
/// @return [String] JSON-encoded file object.
|
|
655
|
+
fn create_file(&self, request_json: String) -> Result<String, Error> {
|
|
656
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
657
|
+
|
|
658
|
+
let req: liter_llm::CreateFileRequest =
|
|
659
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
660
|
+
Error::new(
|
|
661
|
+
ruby.exception_arg_error(),
|
|
662
|
+
format!("invalid file request JSON: {e}"),
|
|
663
|
+
)
|
|
664
|
+
})?;
|
|
665
|
+
|
|
666
|
+
let rt = runtime(&ruby)?;
|
|
667
|
+
let response = rt.block_on(self.inner.create_file(req)).map_err(|e| {
|
|
668
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
669
|
+
})?;
|
|
670
|
+
|
|
671
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
672
|
+
Error::new(
|
|
673
|
+
ruby.exception_runtime_error(),
|
|
674
|
+
format!("serialization error: {e}"),
|
|
675
|
+
)
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/// Retrieve metadata for a file by ID.
|
|
680
|
+
///
|
|
681
|
+
/// @param file_id [String] The file identifier.
|
|
682
|
+
/// @return [String] JSON-encoded file object.
|
|
683
|
+
fn retrieve_file(&self, file_id: String) -> Result<String, Error> {
|
|
684
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
685
|
+
|
|
686
|
+
let rt = runtime(&ruby)?;
|
|
687
|
+
let response = rt.block_on(self.inner.retrieve_file(&file_id)).map_err(|e| {
|
|
688
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
689
|
+
})?;
|
|
690
|
+
|
|
691
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
692
|
+
Error::new(
|
|
693
|
+
ruby.exception_runtime_error(),
|
|
694
|
+
format!("serialization error: {e}"),
|
|
695
|
+
)
|
|
696
|
+
})
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/// Delete a file by ID.
|
|
700
|
+
///
|
|
701
|
+
/// @param file_id [String] The file identifier.
|
|
702
|
+
/// @return [String] JSON-encoded delete response.
|
|
703
|
+
fn delete_file(&self, file_id: String) -> Result<String, Error> {
|
|
704
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
705
|
+
|
|
706
|
+
let rt = runtime(&ruby)?;
|
|
707
|
+
let response = rt.block_on(self.inner.delete_file(&file_id)).map_err(|e| {
|
|
708
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
709
|
+
})?;
|
|
710
|
+
|
|
711
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
712
|
+
Error::new(
|
|
713
|
+
ruby.exception_runtime_error(),
|
|
714
|
+
format!("serialization error: {e}"),
|
|
715
|
+
)
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/// List files, optionally filtered by query parameters.
|
|
720
|
+
///
|
|
721
|
+
/// @param query_json [String, nil] JSON-encoded file list query parameters, or nil.
|
|
722
|
+
/// @return [String] JSON-encoded file list response.
|
|
723
|
+
fn list_files(&self, query_json: Option<String>) -> Result<String, Error> {
|
|
724
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
725
|
+
|
|
726
|
+
let query: Option<liter_llm::FileListQuery> = match query_json {
|
|
727
|
+
Some(json) => Some(serde_json::from_str(&json).map_err(|e| {
|
|
728
|
+
Error::new(
|
|
729
|
+
ruby.exception_arg_error(),
|
|
730
|
+
format!("invalid file list query JSON: {e}"),
|
|
731
|
+
)
|
|
732
|
+
})?),
|
|
733
|
+
None => None,
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
let rt = runtime(&ruby)?;
|
|
737
|
+
let response = rt.block_on(self.inner.list_files(query)).map_err(|e| {
|
|
738
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
739
|
+
})?;
|
|
740
|
+
|
|
741
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
742
|
+
Error::new(
|
|
743
|
+
ruby.exception_runtime_error(),
|
|
744
|
+
format!("serialization error: {e}"),
|
|
745
|
+
)
|
|
746
|
+
})
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/// Retrieve the raw content of a file.
|
|
750
|
+
///
|
|
751
|
+
/// @param file_id [String] The file identifier.
|
|
752
|
+
/// @return [String] Base64-encoded raw file content.
|
|
753
|
+
fn file_content(&self, file_id: String) -> Result<String, Error> {
|
|
754
|
+
use base64::Engine;
|
|
755
|
+
|
|
756
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
757
|
+
|
|
758
|
+
let rt = runtime(&ruby)?;
|
|
759
|
+
let response = rt.block_on(self.inner.file_content(&file_id)).map_err(|e| {
|
|
760
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
761
|
+
})?;
|
|
762
|
+
|
|
763
|
+
Ok(base64::engine::general_purpose::STANDARD.encode(&response))
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ─── Batch Management ─────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
/// Create a new batch job.
|
|
769
|
+
///
|
|
770
|
+
/// @param request_json [String] JSON-encoded batch creation request.
|
|
771
|
+
/// @return [String] JSON-encoded batch object.
|
|
772
|
+
fn create_batch(&self, request_json: String) -> Result<String, Error> {
|
|
773
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
774
|
+
|
|
775
|
+
let req: liter_llm::CreateBatchRequest =
|
|
776
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
777
|
+
Error::new(
|
|
778
|
+
ruby.exception_arg_error(),
|
|
779
|
+
format!("invalid batch request JSON: {e}"),
|
|
780
|
+
)
|
|
781
|
+
})?;
|
|
782
|
+
|
|
783
|
+
let rt = runtime(&ruby)?;
|
|
784
|
+
let response = rt.block_on(self.inner.create_batch(req)).map_err(|e| {
|
|
785
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
786
|
+
})?;
|
|
787
|
+
|
|
788
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
789
|
+
Error::new(
|
|
790
|
+
ruby.exception_runtime_error(),
|
|
791
|
+
format!("serialization error: {e}"),
|
|
792
|
+
)
|
|
793
|
+
})
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/// Retrieve a batch by ID.
|
|
797
|
+
///
|
|
798
|
+
/// @param batch_id [String] The batch identifier.
|
|
799
|
+
/// @return [String] JSON-encoded batch object.
|
|
800
|
+
fn retrieve_batch(&self, batch_id: String) -> Result<String, Error> {
|
|
801
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
802
|
+
|
|
803
|
+
let rt = runtime(&ruby)?;
|
|
804
|
+
let response = rt.block_on(self.inner.retrieve_batch(&batch_id)).map_err(|e| {
|
|
805
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
806
|
+
})?;
|
|
807
|
+
|
|
808
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
809
|
+
Error::new(
|
|
810
|
+
ruby.exception_runtime_error(),
|
|
811
|
+
format!("serialization error: {e}"),
|
|
812
|
+
)
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/// List batches, optionally filtered by query parameters.
|
|
817
|
+
///
|
|
818
|
+
/// @param query_json [String, nil] JSON-encoded batch list query parameters, or nil.
|
|
819
|
+
/// @return [String] JSON-encoded batch list response.
|
|
820
|
+
fn list_batches(&self, query_json: Option<String>) -> Result<String, Error> {
|
|
821
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
822
|
+
|
|
823
|
+
let query: Option<liter_llm::BatchListQuery> = match query_json {
|
|
824
|
+
Some(json) => Some(serde_json::from_str(&json).map_err(|e| {
|
|
825
|
+
Error::new(
|
|
826
|
+
ruby.exception_arg_error(),
|
|
827
|
+
format!("invalid batch list query JSON: {e}"),
|
|
828
|
+
)
|
|
829
|
+
})?),
|
|
830
|
+
None => None,
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
let rt = runtime(&ruby)?;
|
|
834
|
+
let response = rt.block_on(self.inner.list_batches(query)).map_err(|e| {
|
|
835
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
836
|
+
})?;
|
|
837
|
+
|
|
838
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
839
|
+
Error::new(
|
|
840
|
+
ruby.exception_runtime_error(),
|
|
841
|
+
format!("serialization error: {e}"),
|
|
842
|
+
)
|
|
843
|
+
})
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/// Cancel an in-progress batch.
|
|
847
|
+
///
|
|
848
|
+
/// @param batch_id [String] The batch identifier.
|
|
849
|
+
/// @return [String] JSON-encoded batch object.
|
|
850
|
+
fn cancel_batch(&self, batch_id: String) -> Result<String, Error> {
|
|
851
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
852
|
+
|
|
853
|
+
let rt = runtime(&ruby)?;
|
|
854
|
+
let response = rt.block_on(self.inner.cancel_batch(&batch_id)).map_err(|e| {
|
|
855
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
856
|
+
})?;
|
|
857
|
+
|
|
858
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
859
|
+
Error::new(
|
|
860
|
+
ruby.exception_runtime_error(),
|
|
861
|
+
format!("serialization error: {e}"),
|
|
862
|
+
)
|
|
863
|
+
})
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ─── Responses API ────────────────────────────────────────────────────────
|
|
867
|
+
|
|
868
|
+
/// Create a new response via the Responses API.
|
|
869
|
+
///
|
|
870
|
+
/// @param request_json [String] JSON-encoded response creation request.
|
|
871
|
+
/// @return [String] JSON-encoded response object.
|
|
872
|
+
fn create_response(&self, request_json: String) -> Result<String, Error> {
|
|
873
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
874
|
+
|
|
875
|
+
let req: liter_llm::CreateResponseRequest =
|
|
876
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
877
|
+
Error::new(
|
|
878
|
+
ruby.exception_arg_error(),
|
|
879
|
+
format!("invalid response request JSON: {e}"),
|
|
880
|
+
)
|
|
881
|
+
})?;
|
|
882
|
+
|
|
883
|
+
let rt = runtime(&ruby)?;
|
|
884
|
+
let response = rt.block_on(self.inner.create_response(req)).map_err(|e| {
|
|
885
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
886
|
+
})?;
|
|
887
|
+
|
|
888
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
889
|
+
Error::new(
|
|
890
|
+
ruby.exception_runtime_error(),
|
|
891
|
+
format!("serialization error: {e}"),
|
|
892
|
+
)
|
|
893
|
+
})
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/// Retrieve a response by ID.
|
|
897
|
+
///
|
|
898
|
+
/// @param response_id [String] The response identifier.
|
|
899
|
+
/// @return [String] JSON-encoded response object.
|
|
900
|
+
fn retrieve_response(&self, response_id: String) -> Result<String, Error> {
|
|
901
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
902
|
+
|
|
903
|
+
let rt = runtime(&ruby)?;
|
|
904
|
+
let response = rt.block_on(self.inner.retrieve_response(&response_id)).map_err(|e| {
|
|
905
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
906
|
+
})?;
|
|
907
|
+
|
|
908
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
909
|
+
Error::new(
|
|
910
|
+
ruby.exception_runtime_error(),
|
|
911
|
+
format!("serialization error: {e}"),
|
|
912
|
+
)
|
|
913
|
+
})
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/// Cancel an in-progress response.
|
|
917
|
+
///
|
|
918
|
+
/// @param response_id [String] The response identifier.
|
|
919
|
+
/// @return [String] JSON-encoded response object.
|
|
920
|
+
fn cancel_response(&self, response_id: String) -> Result<String, Error> {
|
|
921
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
922
|
+
|
|
923
|
+
let rt = runtime(&ruby)?;
|
|
924
|
+
let response = rt.block_on(self.inner.cancel_response(&response_id)).map_err(|e| {
|
|
925
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
926
|
+
})?;
|
|
927
|
+
|
|
928
|
+
serde_json::to_string(&response).map_err(|e| {
|
|
929
|
+
Error::new(
|
|
930
|
+
ruby.exception_runtime_error(),
|
|
931
|
+
format!("serialization error: {e}"),
|
|
932
|
+
)
|
|
933
|
+
})
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/// Stream a chat completion request, collecting all chunks.
|
|
937
|
+
///
|
|
938
|
+
/// Returns a JSON array of serialised `ChatCompletionChunk` objects.
|
|
939
|
+
/// Each element is the JSON for one SSE chunk. Ruby callers can iterate
|
|
940
|
+
/// with `JSON.parse(result).each { |chunk| ... }`.
|
|
941
|
+
///
|
|
942
|
+
/// @param request_json [String] JSON-encoded OpenAI-compatible chat request.
|
|
943
|
+
/// @return [String] JSON-encoded array of chat completion chunks.
|
|
944
|
+
fn chat_stream(&self, request_json: String) -> Result<String, Error> {
|
|
945
|
+
use futures_core::Stream;
|
|
946
|
+
use std::pin::Pin;
|
|
947
|
+
|
|
948
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
949
|
+
|
|
950
|
+
let req: liter_llm::ChatCompletionRequest =
|
|
951
|
+
serde_json::from_str(&request_json).map_err(|e| {
|
|
952
|
+
Error::new(
|
|
953
|
+
ruby.exception_arg_error(),
|
|
954
|
+
format!("invalid chat request JSON: {e}"),
|
|
955
|
+
)
|
|
956
|
+
})?;
|
|
957
|
+
|
|
958
|
+
let rt = runtime(&ruby)?;
|
|
959
|
+
let chunks: Vec<liter_llm::ChatCompletionChunk> = rt
|
|
960
|
+
.block_on(async {
|
|
961
|
+
let mut stream = self.inner.chat_stream(req).await.map_err(|e| {
|
|
962
|
+
Error::new(ruby.exception_runtime_error(), e.to_string())
|
|
963
|
+
})?;
|
|
964
|
+
|
|
965
|
+
let mut collected = Vec::new();
|
|
966
|
+
loop {
|
|
967
|
+
let next =
|
|
968
|
+
std::future::poll_fn(|cx| Pin::new(&mut stream).poll_next(cx)).await;
|
|
969
|
+
match next {
|
|
970
|
+
None => break,
|
|
971
|
+
Some(Err(e)) => {
|
|
972
|
+
return Err(Error::new(
|
|
973
|
+
ruby.exception_runtime_error(),
|
|
974
|
+
e.to_string(),
|
|
975
|
+
));
|
|
976
|
+
}
|
|
977
|
+
Some(Ok(chunk)) => collected.push(chunk),
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
Ok(collected)
|
|
981
|
+
})?;
|
|
982
|
+
|
|
983
|
+
serde_json::to_string(&chunks).map_err(|e| {
|
|
984
|
+
Error::new(
|
|
985
|
+
ruby.exception_runtime_error(),
|
|
986
|
+
format!("serialization error: {e}"),
|
|
987
|
+
)
|
|
988
|
+
})
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/// Unregister a previously registered custom provider by name.
|
|
992
|
+
///
|
|
993
|
+
/// @param name [String] The provider name to unregister.
|
|
994
|
+
/// @return [Boolean] `true` if the provider was found and removed, `false` otherwise.
|
|
995
|
+
fn rb_unregister_provider(name: String) -> Result<bool, Error> {
|
|
996
|
+
let ruby = unsafe { Ruby::get_unchecked() };
|
|
997
|
+
unregister_custom_provider(&name)
|
|
998
|
+
.map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/// Return the total budget spend so far (in USD).
|
|
1002
|
+
///
|
|
1003
|
+
/// Returns `0.0` if no budget is configured.
|
|
1004
|
+
///
|
|
1005
|
+
/// @return [Float] The cumulative global spend tracked by the budget layer.
|
|
1006
|
+
fn budget_used(&self) -> f64 {
|
|
1007
|
+
self.inner
|
|
1008
|
+
.budget_state()
|
|
1009
|
+
.map(|s| s.global_spend())
|
|
1010
|
+
.unwrap_or(0.0)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/// Return a human-readable string representation.
|
|
1014
|
+
fn inspect(&self) -> String {
|
|
1015
|
+
"#<LiterLlm::LlmClient>".to_string()
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ─── Module entry point ───────────────────────────────────────────────────────
|
|
1020
|
+
|
|
1021
|
+
/// `Init_liter_llm_rb` — called by Ruby when the extension is `require`d.
|
|
1022
|
+
#[magnus::init]
|
|
1023
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
1024
|
+
// Define the `LiterLlm` namespace module.
|
|
1025
|
+
let liter_llm_mod = ruby.define_module("LiterLlm")?;
|
|
1026
|
+
|
|
1027
|
+
// Define `LiterLlm::LlmClient`.
|
|
1028
|
+
let client_class = liter_llm_mod.define_class("LlmClient", ruby.class_object())?;
|
|
1029
|
+
|
|
1030
|
+
// Constructor: LlmClient.new(api_key, base_url: nil, max_retries: 3, timeout_secs: 60)
|
|
1031
|
+
client_class.define_singleton_method("new", function!(RubyLlmClient::rb_new, 2))?;
|
|
1032
|
+
|
|
1033
|
+
// Hook and provider registration.
|
|
1034
|
+
client_class.define_method("add_hook", method!(RubyLlmClient::add_hook, 1))?;
|
|
1035
|
+
client_class.define_singleton_method("register_provider", function!(RubyLlmClient::rb_register_provider, 1))?;
|
|
1036
|
+
client_class.define_singleton_method("unregister_provider", function!(RubyLlmClient::rb_unregister_provider, 1))?;
|
|
1037
|
+
|
|
1038
|
+
// Instance methods.
|
|
1039
|
+
client_class.define_method("chat", method!(RubyLlmClient::chat, 1))?;
|
|
1040
|
+
client_class.define_method("chat_stream", method!(RubyLlmClient::chat_stream, 1))?;
|
|
1041
|
+
client_class.define_method("budget_used", method!(RubyLlmClient::budget_used, 0))?;
|
|
1042
|
+
client_class.define_method("embed", method!(RubyLlmClient::embed, 1))?;
|
|
1043
|
+
client_class.define_method("list_models", method!(RubyLlmClient::list_models, 0))?;
|
|
1044
|
+
|
|
1045
|
+
// Inference methods.
|
|
1046
|
+
client_class.define_method("image_generate", method!(RubyLlmClient::image_generate, 1))?;
|
|
1047
|
+
client_class.define_method("speech", method!(RubyLlmClient::speech, 1))?;
|
|
1048
|
+
client_class.define_method("transcribe", method!(RubyLlmClient::transcribe, 1))?;
|
|
1049
|
+
client_class.define_method("moderate", method!(RubyLlmClient::moderate, 1))?;
|
|
1050
|
+
client_class.define_method("rerank", method!(RubyLlmClient::rerank, 1))?;
|
|
1051
|
+
client_class.define_method("search", method!(RubyLlmClient::search, 1))?;
|
|
1052
|
+
client_class.define_method("ocr", method!(RubyLlmClient::ocr, 1))?;
|
|
1053
|
+
|
|
1054
|
+
// File management methods.
|
|
1055
|
+
client_class.define_method("create_file", method!(RubyLlmClient::create_file, 1))?;
|
|
1056
|
+
client_class.define_method("retrieve_file", method!(RubyLlmClient::retrieve_file, 1))?;
|
|
1057
|
+
client_class.define_method("delete_file", method!(RubyLlmClient::delete_file, 1))?;
|
|
1058
|
+
client_class.define_method("list_files", method!(RubyLlmClient::list_files, 1))?;
|
|
1059
|
+
client_class.define_method("file_content", method!(RubyLlmClient::file_content, 1))?;
|
|
1060
|
+
|
|
1061
|
+
// Batch management methods.
|
|
1062
|
+
client_class.define_method("create_batch", method!(RubyLlmClient::create_batch, 1))?;
|
|
1063
|
+
client_class.define_method("retrieve_batch", method!(RubyLlmClient::retrieve_batch, 1))?;
|
|
1064
|
+
client_class.define_method("list_batches", method!(RubyLlmClient::list_batches, 1))?;
|
|
1065
|
+
client_class.define_method("cancel_batch", method!(RubyLlmClient::cancel_batch, 1))?;
|
|
1066
|
+
|
|
1067
|
+
// Responses API methods.
|
|
1068
|
+
client_class.define_method("create_response", method!(RubyLlmClient::create_response, 1))?;
|
|
1069
|
+
client_class.define_method("retrieve_response", method!(RubyLlmClient::retrieve_response, 1))?;
|
|
1070
|
+
client_class.define_method("cancel_response", method!(RubyLlmClient::cancel_response, 1))?;
|
|
1071
|
+
|
|
1072
|
+
client_class.define_method("inspect", method!(RubyLlmClient::inspect, 0))?;
|
|
1073
|
+
client_class.define_method("to_s", method!(RubyLlmClient::inspect, 0))?;
|
|
1074
|
+
|
|
1075
|
+
// Module-level version constant.
|
|
1076
|
+
liter_llm_mod.const_set("VERSION", env!("CARGO_PKG_VERSION"))?;
|
|
1077
|
+
|
|
1078
|
+
Ok(())
|
|
1079
|
+
}
|