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.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +239 -0
  3. data/ext/liter_llm_rb/extconf.rb +65 -0
  4. data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
  5. data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
  6. data/ext/liter_llm_rb/native/Cargo.toml +32 -0
  7. data/ext/liter_llm_rb/native/build.rs +15 -0
  8. data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
  9. data/lib/liter_llm.rb +8 -0
  10. data/sig/liter_llm.rbs +416 -0
  11. data/vendor/Cargo.toml +54 -0
  12. data/vendor/liter-llm/Cargo.toml +92 -0
  13. data/vendor/liter-llm/README.md +252 -0
  14. data/vendor/liter-llm/schemas/pricing.json +40 -0
  15. data/vendor/liter-llm/schemas/providers.json +1662 -0
  16. data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
  17. data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
  18. data/vendor/liter-llm/src/auth/mod.rs +68 -0
  19. data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
  20. data/vendor/liter-llm/src/client/config.rs +351 -0
  21. data/vendor/liter-llm/src/client/managed.rs +622 -0
  22. data/vendor/liter-llm/src/client/mod.rs +864 -0
  23. data/vendor/liter-llm/src/cost.rs +212 -0
  24. data/vendor/liter-llm/src/error.rs +190 -0
  25. data/vendor/liter-llm/src/http/eventstream.rs +860 -0
  26. data/vendor/liter-llm/src/http/mod.rs +12 -0
  27. data/vendor/liter-llm/src/http/request.rs +438 -0
  28. data/vendor/liter-llm/src/http/retry.rs +72 -0
  29. data/vendor/liter-llm/src/http/streaming.rs +289 -0
  30. data/vendor/liter-llm/src/lib.rs +37 -0
  31. data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
  32. data/vendor/liter-llm/src/provider/azure.rs +579 -0
  33. data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
  34. data/vendor/liter-llm/src/provider/cohere.rs +654 -0
  35. data/vendor/liter-llm/src/provider/custom.rs +404 -0
  36. data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
  37. data/vendor/liter-llm/src/provider/mistral.rs +188 -0
  38. data/vendor/liter-llm/src/provider/mod.rs +616 -0
  39. data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
  40. data/vendor/liter-llm/src/tests.rs +1425 -0
  41. data/vendor/liter-llm/src/tokenizer.rs +281 -0
  42. data/vendor/liter-llm/src/tower/budget.rs +599 -0
  43. data/vendor/liter-llm/src/tower/cache.rs +502 -0
  44. data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
  45. data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
  46. data/vendor/liter-llm/src/tower/cost.rs +404 -0
  47. data/vendor/liter-llm/src/tower/fallback.rs +121 -0
  48. data/vendor/liter-llm/src/tower/health.rs +219 -0
  49. data/vendor/liter-llm/src/tower/hooks.rs +369 -0
  50. data/vendor/liter-llm/src/tower/mod.rs +77 -0
  51. data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
  52. data/vendor/liter-llm/src/tower/router.rs +436 -0
  53. data/vendor/liter-llm/src/tower/service.rs +181 -0
  54. data/vendor/liter-llm/src/tower/tests.rs +539 -0
  55. data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
  56. data/vendor/liter-llm/src/tower/tracing.rs +209 -0
  57. data/vendor/liter-llm/src/tower/types.rs +170 -0
  58. data/vendor/liter-llm/src/types/audio.rs +52 -0
  59. data/vendor/liter-llm/src/types/batch.rs +77 -0
  60. data/vendor/liter-llm/src/types/chat.rs +214 -0
  61. data/vendor/liter-llm/src/types/common.rs +244 -0
  62. data/vendor/liter-llm/src/types/embedding.rs +84 -0
  63. data/vendor/liter-llm/src/types/files.rs +58 -0
  64. data/vendor/liter-llm/src/types/image.rs +40 -0
  65. data/vendor/liter-llm/src/types/mod.rs +27 -0
  66. data/vendor/liter-llm/src/types/models.rs +21 -0
  67. data/vendor/liter-llm/src/types/moderation.rs +80 -0
  68. data/vendor/liter-llm/src/types/ocr.rs +87 -0
  69. data/vendor/liter-llm/src/types/rerank.rs +46 -0
  70. data/vendor/liter-llm/src/types/responses.rs +55 -0
  71. data/vendor/liter-llm/src/types/search.rs +45 -0
  72. data/vendor/liter-llm/tests/contract.rs +332 -0
  73. data/vendor/liter-llm-ffi/Cargo.toml +30 -0
  74. data/vendor/liter-llm-ffi/build.rs +66 -0
  75. data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
  76. data/vendor/liter-llm-ffi/liter_llm.h +850 -0
  77. data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
  78. 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
+ }