feat(decision): pluggable System One backend — TypeSafe (hosted) or Laya (local)

Laya (github.com/NandhaKishorM/laya) is the same System One abstraction as
TypeSafe — identical choice/score/noul primitives — but local, open-source
(Apache 2.0) and free. Added it as a swappable backend, entirely additively:
the hosted TypeSafe path is byte-for-byte unchanged (key alone → same endpoint,
model, bearer as before).

- typesafe.rs: endpoint/model/bearer are now instance fields with env overrides
  (NEUROSPLOIT_DECISION_ENDPOINT / _MODEL). Defaults are the hosted TypeSafe API.
  from_env() now also activates when a local endpoint is configured (no key).
  backend_label() names the active backend in the run banner.
- tools/laya_shim.py: a stdlib HTTP shim that loads Laya and exposes the exact
  POST /systemone contract the client already speaks. Model downloads on first
  use (HF cache); no key; evidence stays on the box.
- CLI: --decision-backend typesafe|laya. `laya` installs laya if missing, starts
  the shim, waits for readiness, and points the client at it — all optional,
  only when the operator selects it.

383 tests; the hosted TypeSafe behaviour is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 5 committed 2026-09-20 19:27:14 -03:00
1 parent d752e252e6
commit 56b2c80ae4
6 files changed
+285 -10

No files matched your search

@@ -2349,7 +2349,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
let _ = tx.send(format!("notify: 🧮 TypeSafe confirmation loop confirmed {confirmed_by_agent} finding(s) the LLM path left unconfirmed")).await;
}
let _ = tx.send("notify: 🧮 TypeSafe System One adjudicating findings…".to_string()).await;
let _ = tx.send(format!("notify: 🧮 System One adjudicating findings via {}…", ts.backend_label())).await;
let mut refined = 0usize;
for f in findings.iter_mut() {
let state = typesafe_state(f);
+33 -8
View File
@@ -33,8 +33,8 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::time::Duration;
const ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
const MODEL: &str = "jev-latest";
const DEFAULT_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
const DEFAULT_MODEL: &str = "jev-latest";
/// A question to evaluate against the state.
#[derive(Debug, Clone, Serialize)]
@@ -123,32 +123,57 @@ struct ApiResponse {
pub struct TypeSafe {
key: String,
client: reqwest::Client,
/// Endpoint to POST to. Defaults to TypeSafe's hosted API; a local backend
/// (e.g. the Laya shim) is selected by setting `NEUROSPLOIT_DECISION_ENDPOINT`.
endpoint: String,
/// Model id sent in the request. `NEUROSPLOIT_DECISION_MODEL` overrides it.
model: String,
/// Whether to send `Authorization: Bearer`. A local backend needs no key.
bearer: bool,
}
impl TypeSafe {
/// Build from `TYPESAFE_API_KEY`. None when unset — the caller then skips
/// System One entirely rather than failing.
pub fn from_env() -> Option<TypeSafe> {
let key = std::env::var("TYPESAFE_API_KEY").ok().filter(|k| !k.trim().is_empty())?;
let key = std::env::var("TYPESAFE_API_KEY").ok().filter(|k| !k.trim().is_empty());
// A local decision backend (the Laya shim) is configured by its endpoint
// and needs no key. The hosted TypeSafe path is unchanged: a key alone
// still works exactly as before.
let endpoint = std::env::var("NEUROSPLOIT_DECISION_ENDPOINT").ok().filter(|e| !e.trim().is_empty());
if key.is_none() && endpoint.is_none() {
return None;
}
let bearer = key.is_some();
Some(TypeSafe {
key,
client: reqwest::Client::builder().timeout(Duration::from_secs(30)).build().unwrap_or_default(),
key: key.unwrap_or_default(),
client: reqwest::Client::builder().timeout(Duration::from_secs(60)).build().unwrap_or_default(),
endpoint: endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.to_string()),
model: std::env::var("NEUROSPLOIT_DECISION_MODEL").ok().filter(|m| !m.trim().is_empty()).unwrap_or_else(|| DEFAULT_MODEL.to_string()),
bearer,
})
}
pub fn new(key: &str) -> TypeSafe {
TypeSafe { key: key.to_string(), client: reqwest::Client::new() }
TypeSafe { key: key.to_string(), client: reqwest::Client::new(), endpoint: DEFAULT_ENDPOINT.to_string(), model: DEFAULT_MODEL.to_string(), bearer: true }
}
/// Which backend this instance talks to, for the run banner.
pub fn backend_label(&self) -> String {
if self.bearer { format!("TypeSafe ({})", self.model) } else { format!("local decision backend ({})", self.endpoint) }
}
/// Evaluate a set of independent questions over one state, in parallel (the
/// API runs them together — they cannot see one another's answers).
pub async fn evaluate(&self, state: serde_json::Value, questions: BTreeMap<String, Question>) -> Result<BTreeMap<String, Answer>, String> {
let req = Request { model: MODEL.into(), state, questions };
let req = Request { model: self.model.clone(), state, questions };
// A short retry on the documented transient codes (429/529).
let mut attempt = 0;
loop {
attempt += 1;
let resp = self.client.post(ENDPOINT).bearer_auth(&self.key).json(&req).send().await;
let mut rb = self.client.post(&self.endpoint).json(&req);
if self.bearer { rb = rb.bearer_auth(&self.key); }
let resp = rb.send().await;
match resp {
Ok(r) => {
let status = r.status().as_u16();