@anvailabs/sandhi 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,834 @@
1
+ //! Sandhi Node binding (napi-rs), published as `@anvailabs/sandhi`.
2
+ //!
3
+ //! `ProviderRuntime` exposes persistent typed chat-contract handles; provider-native request and
4
+ //! response JSON never crosses the binding. The same module exposes metering and budget APIs.
5
+
6
+ use std::collections::HashMap;
7
+ use std::path::PathBuf;
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ use std::sync::{Arc, Mutex};
10
+
11
+ use napi::bindgen_prelude::*;
12
+ use napi_derive::napi;
13
+
14
+ use sandhi_core::{
15
+ parse_anthropic_usage, parse_bedrock_usage, parse_cohere_usage, parse_gemini_usage,
16
+ parse_ollama_usage, parse_openai_responses_usage, parse_openai_usage, Backend, Budget,
17
+ BudgetLedger, Dimension, KeyStore, ParsedUsage, UsageAggregateV1, UsageAggregator, UsageEvent,
18
+ VirtualKey,
19
+ };
20
+ use sandhi_providers::{
21
+ AnthropicAuthScheme, GeminiAuthScheme, ProviderError, ProviderFamily, ProviderHandle,
22
+ ProviderRuntime as RustProviderRuntime,
23
+ };
24
+
25
+ fn parse_anthropic_auth_scheme(value: Option<&str>) -> Result<AnthropicAuthScheme> {
26
+ match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
27
+ None | Some("") | Some("api_key") => Ok(AnthropicAuthScheme::ApiKey),
28
+ Some("bearer") => Ok(AnthropicAuthScheme::Bearer),
29
+ Some(other) => Err(Error::from_reason(format!(
30
+ "unsupported Anthropic auth_scheme {other:?}; expected 'api_key' or 'bearer'"
31
+ ))),
32
+ }
33
+ }
34
+
35
+ fn parse_gemini_auth_scheme(value: Option<&str>) -> Result<GeminiAuthScheme> {
36
+ match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
37
+ None | Some("") | Some("api_key") => Ok(GeminiAuthScheme::ApiKey),
38
+ Some("bearer") => Ok(GeminiAuthScheme::Bearer),
39
+ Some(other) => Err(Error::from_reason(format!(
40
+ "unsupported Gemini auth_scheme {other:?}; expected 'api_key' or 'bearer'"
41
+ ))),
42
+ }
43
+ }
44
+
45
+ #[derive(Clone, Copy, PartialEq, Eq)]
46
+ enum OpenAiProtocol {
47
+ ChatCompletions,
48
+ Responses,
49
+ ChatGptResponses,
50
+ }
51
+
52
+ fn parse_openai_protocol(value: Option<&str>) -> Result<OpenAiProtocol> {
53
+ match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
54
+ None | Some("") | Some("chat_completions") | Some("openai_chat_completions") => {
55
+ Ok(OpenAiProtocol::ChatCompletions)
56
+ }
57
+ Some("responses") | Some("openai_responses") => Ok(OpenAiProtocol::Responses),
58
+ Some("chatgpt_responses") | Some("codex_responses") => {
59
+ Ok(OpenAiProtocol::ChatGptResponses)
60
+ }
61
+ Some(other) => Err(Error::from_reason(format!(
62
+ "unsupported protocol {other:?}; expected 'chat_completions', 'responses', or 'chatgpt_responses'"
63
+ ))),
64
+ }
65
+ }
66
+
67
+ fn parse_chat_request(request_json: &str) -> Result<sandhi_core::ChatRequestV1> {
68
+ let request: sandhi_core::ChatRequestV1 = serde_json::from_str(request_json)
69
+ .map_err(|e| Error::from_reason(format!("invalid ChatRequestV1 JSON: {e}")))?;
70
+ request
71
+ .validate()
72
+ .map_err(|e| Error::from_reason(format!("invalid ChatRequestV1: {e}")))?;
73
+ Ok(request)
74
+ }
75
+
76
+ fn typed_provider_error(error: ProviderError, provider: &str) -> Error {
77
+ let typed = error.as_typed(Some(provider));
78
+ Error::from_reason(serde_json::to_string(&typed).unwrap_or_else(|_| error.to_string()))
79
+ }
80
+
81
+ /// Persistent factory for typed provider handles. The HTTP pool, retry policy, and circuit
82
+ /// breaker belong to each returned handle rather than being rebuilt for every request.
83
+ #[napi(js_name = "ProviderRuntime")]
84
+ pub struct JsProviderRuntime {
85
+ inner: RustProviderRuntime,
86
+ }
87
+
88
+ impl Default for JsProviderRuntime {
89
+ fn default() -> Self {
90
+ Self::new()
91
+ }
92
+ }
93
+
94
+ #[napi]
95
+ impl JsProviderRuntime {
96
+ #[napi(constructor)]
97
+ pub fn new() -> Self {
98
+ Self {
99
+ inner: RustProviderRuntime::new(),
100
+ }
101
+ }
102
+
103
+ #[napi]
104
+ #[allow(clippy::too_many_arguments)]
105
+ pub fn openai_compat(
106
+ &self,
107
+ provider: String,
108
+ base_url: String,
109
+ api_key: String,
110
+ headers_json: Option<String>,
111
+ max_retries: Option<u32>,
112
+ timeout_secs: Option<f64>,
113
+ stream_idle_timeout_secs: Option<f64>,
114
+ ) -> Result<TypedProvider> {
115
+ let headers = parse_headers_json(headers_json)?;
116
+ let handle = self.inner.openai_compat(
117
+ provider.clone(),
118
+ base_url,
119
+ api_key,
120
+ headers,
121
+ max_retries,
122
+ timeout_secs,
123
+ stream_idle_timeout_secs,
124
+ );
125
+ Ok(TypedProvider { provider, handle })
126
+ }
127
+
128
+ #[napi]
129
+ #[allow(clippy::too_many_arguments)]
130
+ pub fn openai_responses(
131
+ &self,
132
+ provider: String,
133
+ base_url: String,
134
+ bearer_token: String,
135
+ headers_json: Option<String>,
136
+ max_retries: Option<u32>,
137
+ timeout_secs: Option<f64>,
138
+ stream_idle_timeout_secs: Option<f64>,
139
+ ) -> Result<TypedProvider> {
140
+ let handle = self.inner.openai_responses(
141
+ provider.clone(),
142
+ base_url,
143
+ bearer_token,
144
+ parse_headers_json(headers_json)?,
145
+ max_retries,
146
+ timeout_secs,
147
+ stream_idle_timeout_secs,
148
+ );
149
+ Ok(TypedProvider { provider, handle })
150
+ }
151
+
152
+ #[napi]
153
+ #[allow(clippy::too_many_arguments)]
154
+ pub fn provider(
155
+ &self,
156
+ provider: String,
157
+ model: String,
158
+ api_key: String,
159
+ base_url: Option<String>,
160
+ headers_json: Option<String>,
161
+ max_retries: Option<u32>,
162
+ timeout_secs: Option<f64>,
163
+ stream_idle_timeout_secs: Option<f64>,
164
+ auth_scheme: Option<String>,
165
+ protocol: Option<String>,
166
+ ) -> Result<TypedProvider> {
167
+ let normalized = provider.trim().to_ascii_lowercase();
168
+ let protocol = parse_openai_protocol(protocol.as_deref())?;
169
+ // Contract principle (TD-0008): `authScheme: "bearer"` is a no-op for
170
+ // families whose default IS Bearer — accepted family-agnostically for
171
+ // gateway callers; only a contradictory scheme is rejected. Parity with
172
+ // the Python binding.
173
+ if let Some(scheme) = auth_scheme
174
+ .as_deref()
175
+ .map(str::trim)
176
+ .filter(|value| !value.is_empty())
177
+ {
178
+ let scheme_families = matches!(
179
+ normalized.as_str(),
180
+ "anthropic" | "claude" | "gemini" | "google"
181
+ );
182
+ if !scheme_families && scheme != "bearer" {
183
+ return Err(Error::from_reason(format!(
184
+ "authScheme {scheme:?} is only valid for the Anthropic or Gemini \
185
+ protocol; other families always authenticate with 'Authorization: Bearer' \
186
+ (authScheme=\"bearer\" is accepted there as a no-op)"
187
+ )));
188
+ }
189
+ }
190
+ let handle = if protocol != OpenAiProtocol::ChatCompletions {
191
+ let resolved_base_url = if let Some(base_url) = base_url {
192
+ base_url
193
+ } else {
194
+ sandhi_providers::resolve_openai_compat_provider(&provider)
195
+ .map(|spec| spec.base_url_for_model(&model).to_owned())
196
+ .ok_or_else(|| {
197
+ Error::from_reason(
198
+ "Responses protocol requires baseUrl for an unknown provider",
199
+ )
200
+ })?
201
+ };
202
+ let headers = parse_headers_json(headers_json)?;
203
+ if protocol == OpenAiProtocol::ChatGptResponses {
204
+ self.inner.chatgpt_responses(
205
+ provider.clone(),
206
+ resolved_base_url,
207
+ api_key,
208
+ headers,
209
+ max_retries,
210
+ timeout_secs,
211
+ stream_idle_timeout_secs,
212
+ )
213
+ } else {
214
+ self.inner.openai_responses(
215
+ provider.clone(),
216
+ resolved_base_url,
217
+ api_key,
218
+ headers,
219
+ max_retries,
220
+ timeout_secs,
221
+ stream_idle_timeout_secs,
222
+ )
223
+ }
224
+ } else {
225
+ match normalized.as_str() {
226
+ "anthropic" | "claude" => self.inner.anthropic(
227
+ base_url.unwrap_or_else(|| ProviderFamily::Anthropic.default_base_url().into()),
228
+ api_key,
229
+ parse_anthropic_auth_scheme(auth_scheme.as_deref())?,
230
+ parse_headers_json(headers_json.clone())?,
231
+ max_retries,
232
+ timeout_secs,
233
+ stream_idle_timeout_secs,
234
+ ),
235
+ "gemini" | "google" => self.inner.gemini(
236
+ base_url.unwrap_or_else(|| ProviderFamily::Gemini.default_base_url().into()),
237
+ api_key,
238
+ parse_gemini_auth_scheme(auth_scheme.as_deref())?,
239
+ parse_headers_json(headers_json.clone())?,
240
+ max_retries,
241
+ timeout_secs,
242
+ stream_idle_timeout_secs,
243
+ ),
244
+ "cohere" => self.inner.cohere(
245
+ base_url.unwrap_or_else(|| ProviderFamily::Cohere.default_base_url().into()),
246
+ api_key,
247
+ parse_headers_json(headers_json.clone())?,
248
+ max_retries,
249
+ timeout_secs,
250
+ stream_idle_timeout_secs,
251
+ ),
252
+ "ollama" => self.inner.ollama(
253
+ base_url.unwrap_or_else(|| ProviderFamily::Ollama.default_base_url().into()),
254
+ api_key,
255
+ parse_headers_json(headers_json.clone())?,
256
+ max_retries,
257
+ timeout_secs,
258
+ stream_idle_timeout_secs,
259
+ ),
260
+ _ => {
261
+ let headers = parse_headers_json(headers_json)?;
262
+ if let Some(base_url) = base_url {
263
+ self.inner.openai_compat(
264
+ normalized,
265
+ base_url,
266
+ api_key,
267
+ headers,
268
+ max_retries,
269
+ timeout_secs,
270
+ stream_idle_timeout_secs,
271
+ )
272
+ } else {
273
+ self.inner
274
+ .known_openai_compat(
275
+ &normalized,
276
+ &model,
277
+ api_key,
278
+ headers,
279
+ max_retries,
280
+ timeout_secs,
281
+ stream_idle_timeout_secs,
282
+ )
283
+ .map_err(|error| typed_provider_error(error, &normalized))?
284
+ }
285
+ }
286
+ }
287
+ };
288
+ Ok(TypedProvider {
289
+ provider: handle.slug().to_owned(),
290
+ handle,
291
+ })
292
+ }
293
+ }
294
+
295
+ fn parse_headers_json(value: Option<String>) -> Result<reqwest::header::HeaderMap> {
296
+ let mut headers = reqwest::header::HeaderMap::new();
297
+ let Some(value) = value else {
298
+ return Ok(headers);
299
+ };
300
+ let values: HashMap<String, String> = serde_json::from_str(&value)
301
+ .map_err(|e| Error::from_reason(format!("headersJson is not a string map: {e}")))?;
302
+ for (name, value) in values {
303
+ let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
304
+ .map_err(|e| Error::from_reason(format!("invalid header name: {e}")))?;
305
+ let value = reqwest::header::HeaderValue::from_str(&value)
306
+ .map_err(|e| Error::from_reason(format!("invalid header value: {e}")))?;
307
+ headers.insert(name, value);
308
+ }
309
+ Ok(headers)
310
+ }
311
+
312
+ /// A persistent provider handle accepting Sandhi chat-contract v1 JSON documents.
313
+ #[napi]
314
+ pub struct TypedProvider {
315
+ provider: String,
316
+ handle: ProviderHandle,
317
+ }
318
+
319
+ #[napi]
320
+ impl TypedProvider {
321
+ #[napi(getter)]
322
+ pub fn provider(&self) -> &str {
323
+ &self.provider
324
+ }
325
+
326
+ /// `wireHeadersJson` (optional, TD-0022 D1): per-call wire headers — a string map sent
327
+ /// with THIS call only, for gateway-path metadata that changes per turn (e.g.
328
+ /// `x-sandhi-step-id`). Transport-owned names are stripped and can never override the
329
+ /// credential.
330
+ #[napi]
331
+ pub async fn complete_json(
332
+ &self,
333
+ request_json: String,
334
+ wire_headers_json: Option<String>,
335
+ ) -> Result<String> {
336
+ let call_headers = parse_headers_json(wire_headers_json)?;
337
+ let request = parse_chat_request(&request_json)?;
338
+ let response = self
339
+ .handle
340
+ .complete_with(request, call_headers)
341
+ .await
342
+ .map_err(|e| typed_provider_error(e, &self.provider))?;
343
+ serde_json::to_string(&response).map_err(|e| Error::from_reason(e.to_string()))
344
+ }
345
+
346
+ /// `wireHeadersJson` (optional, TD-0022 D1): per-call wire headers, as `completeJson`.
347
+ #[napi]
348
+ pub fn stream_json(
349
+ &self,
350
+ request_json: String,
351
+ wire_headers_json: Option<String>,
352
+ ) -> Result<TypedEventStream> {
353
+ let call_headers = parse_headers_json(wire_headers_json)?;
354
+ let request = parse_chat_request(&request_json)?;
355
+ let handle = self.handle.clone();
356
+ let provider = self.provider.clone();
357
+ let (tx, rx) = tokio::sync::mpsc::channel::<std::result::Result<String, String>>(64);
358
+ tokio::spawn(async move {
359
+ use futures_util::StreamExt;
360
+ match handle.stream_with(request, call_headers).await {
361
+ Ok(mut stream) => {
362
+ while let Some(event) = stream.next().await {
363
+ let (item, stop) = match event {
364
+ Ok(event) => (
365
+ serde_json::to_string(&event).map_err(|e| e.to_string()),
366
+ false,
367
+ ),
368
+ Err(error) => (
369
+ Err(serde_json::to_string(&error.as_typed(Some(&provider)))
370
+ .unwrap_or_else(|_| error.to_string())),
371
+ true,
372
+ ),
373
+ };
374
+ if tx.send(item).await.is_err() || stop {
375
+ break;
376
+ }
377
+ }
378
+ }
379
+ Err(error) => {
380
+ let _ = tx
381
+ .send(Err(serde_json::to_string(&error.as_typed(Some(&provider)))
382
+ .unwrap_or_else(|_| error.to_string())))
383
+ .await;
384
+ }
385
+ }
386
+ });
387
+ Ok(TypedEventStream {
388
+ rx: Arc::new(tokio::sync::Mutex::new(rx)),
389
+ })
390
+ }
391
+ }
392
+
393
+ /// Pull-based stream of serialized `ChatStreamEventV1` documents.
394
+ #[napi]
395
+ pub struct TypedEventStream {
396
+ rx: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<std::result::Result<String, String>>>>,
397
+ }
398
+
399
+ #[napi]
400
+ impl TypedEventStream {
401
+ #[napi]
402
+ pub async fn read(&self) -> Result<Option<String>> {
403
+ match self.rx.lock().await.recv().await {
404
+ Some(Ok(item)) => Ok(Some(item)),
405
+ Some(Err(error)) => Err(Error::from_reason(error)),
406
+ None => Ok(None),
407
+ }
408
+ }
409
+ }
410
+
411
+ /// The neutral token breakdown parsed from a provider response.
412
+ #[napi(object)]
413
+ pub struct UsageBreakdown {
414
+ pub tokens_in: u32,
415
+ pub tokens_out: u32,
416
+ pub cache_creation_tokens: u32,
417
+ pub cache_read_tokens: u32,
418
+ }
419
+
420
+ /// A neutral usage event (mirrors `usage-event.v1.schema.json`).
421
+ #[napi(object)]
422
+ pub struct Event {
423
+ pub schema_version: String,
424
+ pub request_id: String,
425
+ pub occurred_at: String,
426
+ pub provider: String,
427
+ pub model: String,
428
+ pub backend: String,
429
+ pub virtual_key_id: Option<String>,
430
+ pub subject_id: Option<String>,
431
+ pub group_id: Option<String>,
432
+ pub route: Option<String>,
433
+ pub session_id: Option<String>,
434
+ pub tokens_in: u32,
435
+ pub tokens_out: u32,
436
+ pub cache_creation_tokens: u32,
437
+ pub cache_read_tokens: u32,
438
+ pub usage_completeness: String,
439
+ pub attempts: u32,
440
+ pub outcome: Option<String>,
441
+ pub upstream_request_id: Option<String>,
442
+ pub gpu_seconds: Option<f64>,
443
+ }
444
+
445
+ /// The usage-event wire-contract major version this build targets.
446
+ #[napi]
447
+ pub fn wire_contract_version() -> String {
448
+ UsageEvent::SCHEMA_VERSION.to_string()
449
+ }
450
+
451
+ /// The neutral chat-contract (ChatRequestV1/ChatStreamEventV1) major version this
452
+ /// build targets. Today equal to `wire_contract_version()` (pinned by a
453
+ /// sandhi-core test); exported separately so a future split cannot silently
454
+ /// invalidate consumer handshakes that validate the chat contract.
455
+ #[napi]
456
+ pub fn chat_contract_version() -> String {
457
+ sandhi_core::chat::CHAT_SCHEMA_VERSION_V1.to_string()
458
+ }
459
+
460
+ /// Additive growth counter within the v1 chat contract (W3c). Consumers
461
+ /// feature-detect this export — an older binding without it reads as minor 0
462
+ /// — and gate trust in newer additive fields on a minimum minor.
463
+ #[napi]
464
+ pub fn chat_contract_minor() -> u32 {
465
+ sandhi_core::chat::CHAT_CONTRACT_MINOR
466
+ }
467
+
468
+ /// Resolve an OpenAI-compatible provider spec (slug, aliases, base_url) as JSON.
469
+ /// Parity with the Python binding's `provider_spec` (TD-0008 P4); JSON here
470
+ /// because napi objects would otherwise diverge from the schema'd facades.
471
+ #[napi]
472
+ pub fn provider_spec_json(provider: String, model: Option<String>) -> Result<String> {
473
+ let spec = sandhi_providers::resolve_openai_compat_provider(&provider)
474
+ .ok_or_else(|| Error::from_reason(format!("unknown provider: {provider}")))?;
475
+ let base_url = model
476
+ .as_deref()
477
+ .map_or(spec.base_url, |name| spec.base_url_for_model(name));
478
+ serde_json::to_string(&serde_json::json!({
479
+ "slug": spec.slug,
480
+ "aliases": spec.aliases,
481
+ "base_url": base_url,
482
+ }))
483
+ .map_err(|error| Error::from_reason(format!("serialize provider spec: {error}")))
484
+ }
485
+
486
+ /// Return a checked chat-contract JSON Schema document by name
487
+ /// (e.g. "chat-stream-event.v1"). Parity with the Python binding.
488
+ #[napi]
489
+ pub fn chat_contract_schema_json(name: String) -> Result<String> {
490
+ let filename = if name.ends_with(".schema.json") {
491
+ name.clone()
492
+ } else {
493
+ format!("{name}.schema.json")
494
+ };
495
+ sandhi_core::contract_schema_documents()
496
+ .remove(filename.as_str())
497
+ .ok_or_else(|| Error::from_reason(format!("unknown chat contract schema: {name}")))
498
+ }
499
+
500
+ /// Return the versioned typed descriptor for a known provider as JSON.
501
+ #[napi]
502
+ pub fn provider_descriptor_json(provider: String) -> Result<String> {
503
+ let descriptor = sandhi_providers::provider_descriptor(&provider)
504
+ .ok_or_else(|| Error::from_reason(format!("unknown provider: {provider}")))?;
505
+ serde_json::to_string(&descriptor)
506
+ .map_err(|error| Error::from_reason(format!("serialize provider descriptor: {error}")))
507
+ }
508
+
509
+ /// Return the curated model descriptors for a known provider as a JSON list (TD-0004 catalog DATA).
510
+ /// Carries facts only (id, context window, max output, capabilities); no pricing.
511
+ #[napi]
512
+ pub fn provider_models_json(provider: String) -> Result<String> {
513
+ let descriptor = sandhi_providers::provider_descriptor(&provider)
514
+ .ok_or_else(|| Error::from_reason(format!("unknown provider: {provider}")))?;
515
+ serde_json::to_string(&descriptor.models)
516
+ .map_err(|error| Error::from_reason(format!("serialize provider models: {error}")))
517
+ }
518
+
519
+ /// Parse a provider response body (JSON string) into the neutral token breakdown. `provider`
520
+ /// selects the parser: `anthropic` → the Anthropic Messages shape; anything else → OpenAI-compat.
521
+ #[napi]
522
+ pub fn parse_usage(provider: String, response_json: String) -> Result<UsageBreakdown> {
523
+ let value: serde_json::Value = serde_json::from_str(&response_json)
524
+ .map_err(|e| Error::from_reason(format!("response_json is not valid JSON: {e}")))?;
525
+ Ok(usage_breakdown(&parse_for(&provider, &value)))
526
+ }
527
+
528
+ /// In-process metering middleware: virtual keys, budgets, and usage-event emission.
529
+ #[napi]
530
+ pub struct Gateway {
531
+ inner: Mutex<Inner>,
532
+ counter: AtomicU64,
533
+ }
534
+
535
+ struct Inner {
536
+ keys: KeyStore,
537
+ ledger: BudgetLedger,
538
+ events: Vec<UsageEvent>,
539
+ jsonl_path: Option<PathBuf>,
540
+ }
541
+
542
+ #[napi]
543
+ impl Gateway {
544
+ /// `sinkPath` — append emitted events as JSONL to this file (plus an in-memory buffer).
545
+ #[napi(constructor)]
546
+ pub fn new(sink_path: Option<String>) -> Self {
547
+ Gateway {
548
+ inner: Mutex::new(Inner {
549
+ keys: KeyStore::new(),
550
+ ledger: BudgetLedger::new(),
551
+ events: Vec::new(),
552
+ jsonl_path: sink_path.map(PathBuf::from),
553
+ }),
554
+ counter: AtomicU64::new(0),
555
+ }
556
+ }
557
+
558
+ /// Register a virtual key: `id` → subject/group attribution + an opaque `upstream` ref.
559
+ #[napi]
560
+ pub fn add_virtual_key(
561
+ &self,
562
+ id: String,
563
+ subject: Option<String>,
564
+ group: Option<String>,
565
+ upstream: Option<String>,
566
+ ) {
567
+ self.inner.lock().unwrap().keys.insert(VirtualKey {
568
+ id,
569
+ subject_id: subject,
570
+ group_id: group,
571
+ upstream_ref: upstream.unwrap_or_default(),
572
+ ..Default::default()
573
+ });
574
+ }
575
+
576
+ /// Set a token budget on a scope (e.g. `group:platform`). Exposed as a 64-bit `bigint` (napi
577
+ /// has no bare `u64`); negatives clamp to 0 — a budget is a non-negative token count.
578
+ #[napi]
579
+ pub fn set_budget(&self, scope: String, tokens: i64) {
580
+ self.inner
581
+ .lock()
582
+ .unwrap()
583
+ .ledger
584
+ .set_limit(scope, Budget::tokens(tokens.max(0) as u64));
585
+ }
586
+
587
+ /// Would `add` more tokens be within the scope's budget?
588
+ #[napi]
589
+ pub fn check_budget(&self, scope: String, add: i64) -> bool {
590
+ self.inner
591
+ .lock()
592
+ .unwrap()
593
+ .ledger
594
+ .check(&scope, add.max(0) as u64)
595
+ .is_ok()
596
+ }
597
+
598
+ /// Tokens spent so far on a scope.
599
+ #[napi]
600
+ pub fn spent(&self, scope: String) -> i64 {
601
+ i64::try_from(self.inner.lock().unwrap().ledger.spent(&scope)).unwrap_or(i64::MAX)
602
+ }
603
+
604
+ /// Meter one completed call: parse usage from `responseJson` (built-in parser for `provider`),
605
+ /// attribute it to `virtualKey`, emit the neutral event + record the budget, and return the
606
+ /// event. Throws on an unknown virtual key or bad JSON.
607
+ #[napi]
608
+ pub fn meter(
609
+ &self,
610
+ virtual_key: String,
611
+ provider: String,
612
+ model: String,
613
+ response_json: String,
614
+ session_id: Option<String>,
615
+ route: Option<String>,
616
+ ) -> Result<Event> {
617
+ let value: serde_json::Value = serde_json::from_str(&response_json)
618
+ .map_err(|e| Error::from_reason(format!("response_json is not valid JSON: {e}")))?;
619
+ let parsed = parse_for(&provider, &value);
620
+ self.record_and_build(&virtual_key, &provider, &model, parsed, session_id, route)
621
+ }
622
+
623
+ /// Meter from token counts you supply directly (bypass parsing) — the escape hatch for any
624
+ /// provider Sandhi doesn't natively parse: do your own parsing and pass the counts. Same
625
+ /// attribution + budget + emit as `meter()`.
626
+ #[napi]
627
+ #[allow(clippy::too_many_arguments)]
628
+ pub fn meter_tokens(
629
+ &self,
630
+ virtual_key: String,
631
+ provider: String,
632
+ model: String,
633
+ tokens_in: u32,
634
+ tokens_out: u32,
635
+ cache_creation_tokens: Option<u32>,
636
+ cache_read_tokens: Option<u32>,
637
+ session_id: Option<String>,
638
+ route: Option<String>,
639
+ ) -> Result<Event> {
640
+ let parsed = ParsedUsage {
641
+ tokens_in: u64::from(tokens_in),
642
+ tokens_out: u64::from(tokens_out),
643
+ cache_creation_tokens: u64::from(cache_creation_tokens.unwrap_or(0)),
644
+ cache_read_tokens: u64::from(cache_read_tokens.unwrap_or(0)),
645
+ reasoning_tokens: 0,
646
+ };
647
+ self.record_and_build(&virtual_key, &provider, &model, parsed, session_id, route)
648
+ }
649
+
650
+ /// All events emitted so far (in-memory).
651
+ #[napi]
652
+ pub fn events(&self) -> Vec<Event> {
653
+ self.inner
654
+ .lock()
655
+ .unwrap()
656
+ .events
657
+ .iter()
658
+ .map(event_to_napi)
659
+ .collect()
660
+ }
661
+
662
+ /// Fold the events recorded so far into `UsageAggregateV1` rows for one attribution
663
+ /// `dimension`, serialized as a JSON array (TD-0009 P2). `dimension` is one of `subject`
664
+ /// (`user`), `group`, `provider`, `model`, `key` (`virtual_key`), `session`, or `total`.
665
+ ///
666
+ /// The fold is `sandhi_core::UsageAggregator` — the same definition the proxy, the CLI, and
667
+ /// the dashboard read, so the in-process path cannot disagree with them. `cap` bounds the
668
+ /// number of distinct keys before the rest fold into `"(overflow)"` (default 1024): a
669
+ /// long-lived process metering unbounded `sessionId`s loses per-key detail, never the sum —
670
+ /// the rows always add up to the exact total. Throws on an unknown dimension.
671
+ #[napi]
672
+ pub fn usage_snapshot_json(&self, dimension: String, cap: Option<u32>) -> Result<String> {
673
+ let parsed = Dimension::parse(&dimension).ok_or_else(|| {
674
+ Error::from_reason(format!(
675
+ "unknown usage dimension {dimension:?}; expected one of \
676
+ subject, group, provider, model, key, session, total"
677
+ ))
678
+ })?;
679
+ let inner = self.inner.lock().unwrap();
680
+ serde_json::to_string(&fold_usage(
681
+ &inner.events,
682
+ parsed,
683
+ cap.map(|cap| cap as usize),
684
+ ))
685
+ .map_err(|e| Error::from_reason(format!("serialize usage aggregate: {e}")))
686
+ }
687
+ }
688
+
689
+ impl Gateway {
690
+ fn next_request_id(&self) -> String {
691
+ let n = self.counter.fetch_add(1, Ordering::Relaxed);
692
+ let millis = std::time::SystemTime::now()
693
+ .duration_since(std::time::UNIX_EPOCH)
694
+ .map(|d| d.as_millis())
695
+ .unwrap_or(0);
696
+ format!("req_{millis}_{n}")
697
+ }
698
+
699
+ /// Shared tail: resolve the key, build + emit the event, record the budget, return it.
700
+ fn record_and_build(
701
+ &self,
702
+ virtual_key: &str,
703
+ provider: &str,
704
+ model: &str,
705
+ parsed: ParsedUsage,
706
+ session_id: Option<String>,
707
+ route: Option<String>,
708
+ ) -> Result<Event> {
709
+ let mut inner = self.inner.lock().unwrap();
710
+ let vk = inner
711
+ .keys
712
+ .resolve(virtual_key)
713
+ .ok_or_else(|| Error::from_reason(format!("unknown virtual key: {virtual_key}")))?;
714
+
715
+ let event = parsed.apply(
716
+ UsageEvent::new(
717
+ self.next_request_id(),
718
+ now_rfc3339(),
719
+ provider,
720
+ model,
721
+ Backend::External,
722
+ )
723
+ .with_attribution(
724
+ Some(vk.id.clone()),
725
+ vk.subject_id.clone(),
726
+ vk.group_id.clone(),
727
+ )
728
+ .with_session(session_id)
729
+ .with_route(route),
730
+ );
731
+
732
+ if let Some(path) = &inner.jsonl_path {
733
+ if let Ok(line) = serde_json::to_string(&event) {
734
+ use std::io::Write;
735
+ if let Ok(mut f) = std::fs::OpenOptions::new()
736
+ .create(true)
737
+ .append(true)
738
+ .open(path)
739
+ {
740
+ let _ = writeln!(f, "{line}");
741
+ }
742
+ }
743
+ }
744
+ let scope = match &vk.group_id {
745
+ Some(g) => format!("group:{g}"),
746
+ None => format!("vk:{}", vk.id),
747
+ };
748
+ inner.ledger.record(&scope, event.billable_tokens());
749
+ inner.events.push(event.clone());
750
+ drop(inner);
751
+
752
+ Ok(event_to_napi(&event))
753
+ }
754
+ }
755
+
756
+ /// Fold recorded events into aggregate rows for one dimension. Mirrors the Python binding exactly
757
+ /// (same core fold, same insertion order, same serializer), which is what makes the two
758
+ /// snapshots byte-identical rather than merely similar (TD-0009 D6).
759
+ fn fold_usage(
760
+ events: &[UsageEvent],
761
+ dimension: Dimension,
762
+ cap: Option<usize>,
763
+ ) -> Vec<UsageAggregateV1> {
764
+ let mut agg = match cap {
765
+ Some(cap) => UsageAggregator::with_cap(dimension, cap),
766
+ None => UsageAggregator::new(dimension),
767
+ };
768
+ for event in events {
769
+ agg.add(event);
770
+ }
771
+ agg.rows()
772
+ }
773
+
774
+ fn parse_for(provider: &str, value: &serde_json::Value) -> ParsedUsage {
775
+ match provider {
776
+ "anthropic" => parse_anthropic_usage(value),
777
+ "gemini" => parse_gemini_usage(value),
778
+ "cohere" => parse_cohere_usage(value),
779
+ "ollama" => parse_ollama_usage(value),
780
+ "bedrock" => parse_bedrock_usage(value),
781
+ "openai_responses" | "responses" => parse_openai_responses_usage(value),
782
+ _ => parse_openai_usage(value),
783
+ }
784
+ .unwrap_or_default()
785
+ }
786
+
787
+ fn now_rfc3339() -> String {
788
+ use time::format_description::well_known::Rfc3339;
789
+ time::OffsetDateTime::now_utc()
790
+ .format(&Rfc3339)
791
+ .unwrap_or_default()
792
+ }
793
+
794
+ fn usage_breakdown(u: &ParsedUsage) -> UsageBreakdown {
795
+ UsageBreakdown {
796
+ tokens_in: u.tokens_in as u32,
797
+ tokens_out: u.tokens_out as u32,
798
+ cache_creation_tokens: u.cache_creation_tokens as u32,
799
+ cache_read_tokens: u.cache_read_tokens as u32,
800
+ }
801
+ }
802
+
803
+ fn event_to_napi(e: &UsageEvent) -> Event {
804
+ Event {
805
+ schema_version: e.schema_version.clone(),
806
+ request_id: e.request_id.clone(),
807
+ occurred_at: e.occurred_at.clone(),
808
+ provider: e.provider.clone(),
809
+ model: e.model.clone(),
810
+ backend: match e.backend {
811
+ Backend::External => "external".to_string(),
812
+ Backend::SelfHosted => "self_hosted".to_string(),
813
+ },
814
+ virtual_key_id: e.virtual_key_id.clone(),
815
+ subject_id: e.subject_id.clone(),
816
+ group_id: e.group_id.clone(),
817
+ route: e.route.clone(),
818
+ session_id: e.session_id.clone(),
819
+ tokens_in: e.tokens_in as u32,
820
+ tokens_out: e.tokens_out as u32,
821
+ cache_creation_tokens: e.cache_creation_tokens as u32,
822
+ cache_read_tokens: e.cache_read_tokens as u32,
823
+ usage_completeness: match e.usage_completeness {
824
+ sandhi_core::UsageCompleteness::Final => "final",
825
+ sandhi_core::UsageCompleteness::Partial => "partial",
826
+ sandhi_core::UsageCompleteness::Unavailable => "unavailable",
827
+ }
828
+ .into(),
829
+ attempts: e.attempts,
830
+ outcome: e.outcome.clone(),
831
+ upstream_request_id: e.upstream_request_id.clone(),
832
+ gpu_seconds: e.gpu_seconds,
833
+ }
834
+ }