@anvailabs/sandhi 0.5.1 → 0.6.1
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/contracts.d.ts +2 -1
- package/index.d.ts +4 -0
- package/package.json +18 -3
- package/Cargo.lock +0 -1625
- package/Cargo.toml +0 -38
- package/__test__/gateway.test.mjs +0 -860
- package/__test__/typed_error.test.mjs +0 -48
- package/artifacts/sandhi.darwin-arm64.node +0 -0
- package/artifacts/sandhi.linux-x64-gnu.node +0 -0
- package/build.rs +0 -3
- package/npm/darwin-arm64/package.json +0 -19
- package/npm/darwin-arm64/sandhi.darwin-arm64.node +0 -0
- package/npm/linux-x64-gnu/package.json +0 -22
- package/npm/linux-x64-gnu/sandhi.linux-x64-gnu.node +0 -0
- package/src/lib.rs +0 -834
package/src/lib.rs
DELETED
|
@@ -1,834 +0,0 @@
|
|
|
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
|
-
}
|