use std::sync::Arc;
use rto_serve::{ChatRequest, CompletionStats, Engine, EngineError, FinishReason, ModelInfo};
const ASK_CONTEXT_NODES: usize = 12;
const _: () = assert!(ASK_CONTEXT_NODES < rto_remote::payload::MAX_CONTEXT_ITEMS);
fn classify(err: &rto_remote::RemoteError) -> EngineError {
let text = err.to_string();
match err {
rto_remote::RemoteError::NotConsented(_) => EngineError::InvalidRequest(text),
rto_remote::RemoteError::NoTransport { .. } => EngineError::Unsupported(text),
rto_remote::RemoteError::Transport { .. } | rto_remote::RemoteError::Ledger(_) => {
EngineError::Inference(text)
}
_ => EngineError::Inference(text),
}
}
pub struct RemoteBackedEngine {
local: Arc<dyn Engine>,
endpoint: rto_remote::Endpoint,
decision: rto_remote::Decision,
ledger: rto_remote::Ledger,
workspace: Arc<rto_graph::Workspace>,
}
impl RemoteBackedEngine {
pub fn new(
local: Arc<dyn Engine>,
endpoint: rto_remote::Endpoint,
decision: rto_remote::Decision,
ledger: rto_remote::Ledger,
workspace: Arc<rto_graph::Workspace>,
) -> Self {
Self {
local,
endpoint,
decision,
ledger,
workspace,
}
}
fn payload_for(&self, req: &ChatRequest) -> Result<rto_remote::Payload, EngineError> {
if !req.images.is_empty() || !req.audio.is_empty() {
return Err(EngineError::InvalidRequest(
"the remote model tier carries text only: images and audio are not on \
ADR-0019's payload allow-list, and Roteiro will not send bytes it cannot \
show you in a dry-run"
.to_owned(),
));
}
let instruction = req
.messages
.iter()
.filter(|m| m.role == "user")
.map(|m| m.content.trim())
.filter(|c| !c.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if instruction.is_empty() {
return Err(EngineError::InvalidRequest(
"a remote Ask needs a question in a `user` message: assistant turns and tool \
results are deliberately not forwarded to the hosted model (ADR-0019 §4)"
.to_owned(),
));
}
let nodes = self.ground(&instruction);
rto_remote::Payload::new(&instruction, &nodes)
.map_err(|e| EngineError::InvalidRequest(e.to_string()))
}
fn ground(&self, question: &str) -> Vec<rto_graph::Node> {
let inner =
|store: &rto_graph::Store| -> Result<Vec<rto_graph::Node>, rto_graph::StoreError> {
let ctx = rto_spec::context(store, question, ASK_CONTEXT_NODES)?;
let keys: Vec<String> = ctx
.symbols
.iter()
.map(|s| s.node.key.clone())
.chain(ctx.docs.iter().map(|d| d.key.clone()))
.take(ASK_CONTEXT_NODES)
.collect();
let mut nodes = Vec::with_capacity(keys.len());
for key in keys {
if let Some(node) = store.get_node(&key)? {
nodes.push(node);
}
}
Ok(nodes)
};
self.workspace
.with_store(None, inner)
.ok()
.and_then(Result::ok)
.unwrap_or_default()
}
}
impl Engine for RemoteBackedEngine {
fn models(&self) -> Vec<ModelInfo> {
let mut out = vec![ModelInfo {
id: self.endpoint.model().to_owned(),
}];
out.extend(self.local.models());
out
}
fn chat_stream(
&self,
req: &ChatRequest,
on_token: &mut dyn FnMut(&str),
) -> Result<CompletionStats, EngineError> {
if req.model != self.endpoint.model() {
return self.local.chat_stream(req, on_token);
}
let payload = self.payload_for(req)?;
let raw = rto_remote::call_with(
&self.endpoint,
&payload,
self.decision,
&self.ledger,
&|| rto_exec::rfc3339_utc(std::time::SystemTime::now()),
Some(&crate::remote_transport::call),
)
.map_err(|e| classify(&e))?;
let answer =
rto_remote::response::parse(&raw).map_err(|e| EngineError::Inference(e.to_string()))?;
on_token(&answer.text);
Ok(CompletionStats {
prompt_tokens: 0,
completion_tokens: 0,
finish_reason: FinishReason::Stop,
})
}
fn embed(&self, model: &str, inputs: &[String]) -> Result<Vec<Vec<f32>>, EngineError> {
self.local.embed(model, inputs)
}
}
#[cfg(test)]
mod tests {
use super::{RemoteBackedEngine, classify};
use rto_serve::{ChatRequest, CompletionStats, Engine, EngineError, FinishReason, Message};
use std::sync::Arc;
struct LocalSpy {
seen: std::sync::Mutex<Vec<String>>,
}
impl Engine for LocalSpy {
fn models(&self) -> Vec<rto_serve::ModelInfo> {
vec![rto_serve::ModelInfo {
id: "qwen3-0.6b".to_owned(),
}]
}
fn chat_stream(
&self,
req: &ChatRequest,
on_token: &mut dyn FnMut(&str),
) -> Result<CompletionStats, EngineError> {
self.seen.lock().expect("lock").push(req.model.clone());
on_token("the local answer");
Ok(CompletionStats {
prompt_tokens: 1,
completion_tokens: 1,
finish_reason: FinishReason::Stop,
})
}
}
fn engine(
dir: &std::path::Path,
invocation: Option<bool>,
) -> (RemoteBackedEngine, Arc<LocalSpy>) {
let local = Arc::new(LocalSpy {
seen: std::sync::Mutex::new(Vec::new()),
});
let endpoint = rto_remote::Endpoint::new(
"http://127.0.0.1:1/v1/chat/completions",
"a-vendor-model",
rto_remote::ProducerTrust::VendorAsserted,
)
.expect("a valid endpoint");
let decision = rto_remote::consent::decide(
rto_remote::ConfigGrant::from_layers(None, Some(true)),
invocation,
);
let ledger = rto_remote::Ledger::at(dir.join("egress.jsonl"));
let workspace = Arc::new(rto_graph::Workspace::from_stores(Vec::<(
String,
rto_graph::Store,
)>::new()));
(
RemoteBackedEngine::new(
Arc::clone(&local) as Arc<dyn Engine>,
endpoint,
decision,
ledger,
workspace,
),
local,
)
}
fn ask(model: &str, turns: &[(&str, &str)]) -> ChatRequest {
ChatRequest {
model: model.to_owned(),
messages: turns
.iter()
.map(|(role, content)| Message {
role: (*role).to_owned(),
content: (*content).to_owned(),
})
.collect(),
images: Vec::new(),
audio: Vec::new(),
temperature: 0.0,
max_tokens: 256,
}
}
fn temp_dir(label: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static NEXT: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!(
"roteiro-remote-engine-{label}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("create the test directory");
dir
}
#[test]
fn a_local_model_id_is_delegated_untouched() {
let dir = temp_dir("delegated");
let (engine, local) = engine(&dir, Some(true));
let mut text = String::new();
let stats = engine
.chat_stream(&ask("qwen3-0.6b", &[("user", "what is this?")]), &mut |t| {
text.push_str(t);
})
.expect("the local engine answered");
assert_eq!(text, "the local answer");
assert_eq!(stats.prompt_tokens, 1, "the local engine's own accounting");
assert_eq!(*local.seen.lock().expect("lock"), vec!["qwen3-0.6b"]);
assert!(
!dir.join("egress.jsonl").exists(),
"a local answer is not an egress and leaves no ledger line"
);
}
#[test]
fn a_shut_gate_refuses_and_does_not_answer_from_the_local_model() {
let dir = temp_dir("shut-gate");
let (engine, local) = engine(&dir, None);
let mut text = String::new();
let err = engine
.chat_stream(
&ask("a-vendor-model", &[("user", "what is this?")]),
&mut |t| {
text.push_str(t);
},
)
.expect_err("the gate is shut");
assert!(matches!(err, EngineError::InvalidRequest(_)), "{err:?}");
assert!(
err.to_string().contains("not enabled for this run"),
"names the gate: {err}"
);
assert!(text.is_empty(), "nothing was emitted");
assert!(
local.seen.lock().expect("lock").is_empty(),
"the local engine must not have been asked instead"
);
assert!(
!dir.join("egress.jsonl").exists(),
"a refusal disclosed nothing, so it records nothing"
);
}
#[test]
fn a_refused_gate_is_a_client_error_and_the_rest_keep_their_classes() {
use rto_remote::RemoteError;
let refused = classify(&RemoteError::NotConsented(
rto_remote::Reason::InvocationUnset,
));
assert!(
matches!(refused, EngineError::InvalidRequest(_)),
"{refused:?}"
);
assert!(
refused.to_string().contains("not enabled for this run"),
"{refused}"
);
let no_backend = classify(&RemoteError::NoTransport {
endpoint: "https://models.example/v1".to_owned(),
});
assert!(
matches!(no_backend, EngineError::Unsupported(_)),
"{no_backend:?}"
);
let unreachable = classify(&RemoteError::Transport {
endpoint: "https://models.example/v1".to_owned(),
detail: "connection refused".to_owned(),
});
assert!(
matches!(unreachable, EngineError::Inference(_)),
"{unreachable:?}"
);
assert!(
unreachable.to_string().contains("did **not** fall back"),
"and it still refuses to degrade: {unreachable}"
);
}
#[test]
fn the_hosted_model_leads_and_the_local_models_remain() {
let dir = temp_dir("models");
let (engine, _) = engine(&dir, Some(true));
let ids: Vec<String> = engine.models().into_iter().map(|m| m.id).collect();
assert_eq!(ids, vec!["a-vendor-model", "qwen3-0.6b"]);
assert_eq!(engine.endpoint.model(), "a-vendor-model");
}
#[test]
fn only_the_user_turns_reach_the_payload() {
let dir = temp_dir("reduction");
let (engine, _) = engine(&dir, Some(true));
let req = ask(
"a-vendor-model",
&[
("system", "you are a helpful assistant"),
("user", "what does the store do?"),
(
"assistant",
"it holds Store::apply_import_layer and friends",
),
("tool", "{\"secret_from_a_tool\":\"hunter2\"}"),
("user", "and how is it tested?"),
],
);
let payload = engine.payload_for(&req).expect("assembles");
let instruction = payload.instruction();
assert_eq!(
instruction,
"what does the store do?\n\nand how is it tested?"
);
for dropped in [
"helpful assistant",
"apply_import_layer",
"secret_from_a_tool",
"hunter2",
] {
assert!(
!instruction.contains(dropped),
"`{dropped}` is not a user turn but reached the instruction: {instruction}"
);
}
let body = rto_remote::dry_run(&engine.endpoint, &payload);
assert!(!body.contains("hunter2"), "{body}");
assert!(!body.contains("apply_import_layer"), "{body}");
}
#[test]
fn a_request_with_no_user_turn_is_refused_with_its_reason() {
let dir = temp_dir("no-user-turn");
let (engine, _) = engine(&dir, Some(true));
let err = engine
.payload_for(&ask(
"a-vendor-model",
&[("assistant", "I already answered that")],
))
.expect_err("no question");
assert!(matches!(err, EngineError::InvalidRequest(_)), "{err:?}");
assert!(err.to_string().contains("not forwarded"), "{err}");
}
#[test]
fn attached_media_is_refused_rather_than_sent() {
let dir = temp_dir("media");
let (engine, _) = engine(&dir, Some(true));
let mut req = ask("a-vendor-model", &[("user", "describe this")]);
req.images = vec![vec![0x89, b'P', b'N', b'G']];
let err = engine
.payload_for(&req)
.expect_err("images are not sendable");
assert!(matches!(err, EngineError::InvalidRequest(_)), "{err:?}");
assert!(err.to_string().contains("text only"), "{err}");
}
}