feat(13-13): Routstr backend adapter — Nostr discovery, OpenAI chat, Cashu payment attach

Task 1 decision (proceed-docs-with-probe-first, operator-selected via
AskUserQuestion 2026-08-05): 13-ROUTSTR-FINDINGS.md observed 0 of 9 cited
protocol claims (no live provider was announcing on any of the 3 default
relays in a 30s window on 2026-08-03; relay reachability itself WAS
confirmed). This backend is written against docs.routstr.com's cited shape,
with the first live chat-completions call doubling as the capability probe:
a non-success HTTP status or a response missing the expected
choices[0].message shape fails loudly (bails with the real status/body)
rather than silently degrading.

- assistant/backends/routstr.rs (new): RoutstrBackend implements the
  Backend trait — discover_providers subscribes for kind-38421
  provider-announcement events over the existing Tor-proxy-aware Nostr
  client (nostr_discovery::build_nostr_client, never a second relay
  client), process-cached with a 5-minute TTL; select_provider picks the
  globally cheapest affordable (provider, model) price across every
  discovered provider (Routstr has no fixed target model the way
  Ollama/Claude do — CONTEXT.md delegates provider selection strategy to
  Claude's discretion), preferring an onion endpoint when Tor is up;
  attach_payment calls the existing budget-capped auto_pay_token verbatim
  (never hand-rolled); parse_openai_tool_calls parses the one
  string-encoded function.arguments shape exactly once at this adapter's
  edge; screen_outbound (G-B1/G-B2) runs before any body leaves the node,
  exactly as it does for Claude; ROUTSTR_MAX_TOKENS caps every request
  explicitly.
- assistant/egress.rs: message_is_turn_own gains "system" and "tool" role
  handling plus an OpenAI tool_calls-sibling-field check — the pre-existing
  function was written only against Claude's wire shape (system as a
  top-level field, tool results wrapped in role:"user") and would have
  silently stripped Routstr's system prompt and tool-result context out of
  every outbound request via G-B2's fail-closed default arm. Fixed with 4
  new regression tests pinning both wire shapes.
- assistant/backends/mod.rs: registers `pub mod routstr;`. select_backend's
  actual wiring of the Routstr leg (budget-gated, per D-05) is Task 3's
  commit, once AssistantBudget exists — this task's own acceptance criteria
  do not require select_backend integration, only the adapter itself.

30/30 assistant::backends:: tests pass (17 new in routstr.rs, 3 new in
egress.rs's OpenAI-shape regression tests were run separately at 12/12).
Zero new packages (nostr-sdk/reqwest already in-tree); dispatcher.rs and
Cargo.toml untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 01:26:59 -04:00
co-authored by Claude Fable 5
parent 8548544db9
commit a3521e5eeb
3 changed files with 1289 additions and 16 deletions
@@ -12,6 +12,7 @@ use super::tools::{ChatMessage, ToolCall, ToolDef};
pub mod claude;
pub mod ollama;
pub mod routstr;
#[cfg(test)]
pub mod scripted;
File diff suppressed because it is too large Load Diff
+163 -16
View File
@@ -221,19 +221,33 @@ fn has_bip39_length_word_run(body: &str) -> bool {
false
}
/// Whether one wire-format message (Anthropic Messages API shape) is
/// entirely accounted for by THIS turn's own fields — G-B2's mechanical
/// allowlist, not an eyeballed judgment (E-04). A "user" role message is
/// either the operator's own turn text or a `tool_result` block whose
/// content matches one of this turn's own tool results (Claude's wire
/// format sends tool results back as role "user" — see
/// `backends/claude.rs::message_to_wire`). An "assistant" role message is
/// either plain text (the model's own prior answer) or `tool_use` blocks
/// whose tool name is one of this turn's granted tools.
/// Whether one wire-format message is entirely accounted for by THIS
/// turn's own fields — G-B2's mechanical allowlist, not an eyeballed
/// judgment (E-04). Handles BOTH cloud-leg wire shapes this function has
/// ever been asked to screen: Claude's Messages API shape (tool results
/// travel as role "user" with an array of `tool_result` blocks — see
/// `backends/claude.rs::message_to_wire`) and the OpenAI-compatible shape
/// 13-13's Routstr leg introduced (the system prompt travels as its own
/// `role: "system"` message rather than a top-level field, and tool
/// results travel as their own `role: "tool"` messages — see
/// `backends/routstr.rs::message_to_wire`/`ollama.rs`'s identical
/// convention, though `ollama.rs` never calls this function at all since
/// nothing leaves the node on that leg). A "user" role message is either
/// the operator's own turn text or a `tool_result` block whose content
/// matches one of this turn's own tool results. An "assistant" role
/// message is either plain text (the model's own prior answer) or
/// `tool_use`/`tool_calls` entries whose tool name is one of this turn's
/// granted tools.
fn message_is_turn_own(msg: &Value, ctx: &EgressContext) -> bool {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
let content = msg.get("content").cloned().unwrap_or(Value::Null);
match role {
// OpenAI-shape only (Claude's system prompt is a top-level field,
// never a message) — this node's own system prompt is always this
// turn's own content, by construction (13-CONTEXT.md D-16/AI-SPEC
// §4b.3: one static, phase-authored persona, never assembled from
// prior model output).
"system" => true,
"user" => {
if let Some(s) = content.as_str() {
return s == ctx.user_turn;
@@ -246,9 +260,9 @@ fn message_is_turn_own(msg: &Value, ctx: &EgressContext) -> bool {
.any(|r| r == block_content)
});
}
// System messages / unrecognized shapes never appear as
// "user"-role entries in Claude's wire format; treat anything
// else as not-this-turn's-own rather than guessing.
// Unrecognized shapes never appear as "user"-role entries in
// either wire format; treat anything else as not-this-turn's-
// own rather than guessing.
false
}
"assistant" => {
@@ -264,12 +278,37 @@ fn message_is_turn_own(msg: &Value, ctx: &EgressContext) -> bool {
}
});
}
// Plain-string assistant content is a prior answer — always
// this turn's own conversational content, never foreign data.
// OpenAI-shape tool-call turns carry `tool_calls` as a
// SIBLING field to `content` (which is `null`, not an array)
// — never checked above, so check it explicitly here: every
// named function must be one of this turn's granted tools.
if let Some(tool_calls) = msg.get("tool_calls").and_then(|t| t.as_array()) {
return tool_calls.iter().all(|call| {
let name = call
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("");
ctx.granted_tool_names.iter().any(|n| n == name)
});
}
// Plain-string (or null, with no tool_calls) assistant content
// is a prior answer — always this turn's own conversational
// content, never foreign data.
true
}
// System prompt travels as a top-level field, never as a message —
// any other role here is unrecognized and therefore NOT
// OpenAI-shape only: a tool-result message, echoing one call's
// result back by id. This turn's own iff its content matches one
// of this turn's own tool results — the same allowlist Claude's
// "user"-wrapped tool_result blocks are checked against above,
// just carried on a different wire role.
"tool" => {
let block_content = content.as_str().unwrap_or("");
ctx.this_turn_tool_results
.iter()
.any(|r| r == block_content)
}
// Any other role here is unrecognized and therefore NOT
// mechanically verifiable as this turn's own. Fail closed.
_ => false,
}
@@ -498,4 +537,112 @@ mod tests {
EgressVerdict::BlockFallBackLocal
);
}
/// 13-13 regression: the OpenAI-compatible wire shape (Routstr) sends
/// the system prompt as its own `role: "system"` message rather than a
/// top-level field the way Claude does. Before `message_is_turn_own`
/// learned this role, it fell into the `_ => false` fail-closed arm and
/// the system prompt was silently stripped out of every Routstr
/// request — this pins that the system message survives unchanged.
#[test]
fn openai_shape_system_message_is_turn_own() {
let user_turn = "what's my disk space?";
let body = json!({
"model": "some-routstr-model",
"messages": [
{"role": "system", "content": "you are the node's assistant"},
{"role": "user", "content": user_turn},
],
})
.to_string();
let ctx = ctx_for(user_turn, &[], &[]);
assert_eq!(
screen_outbound(&body, &ctx),
EgressVerdict::Allow,
"an OpenAI-shape system message must never be treated as unrelated context"
);
}
/// 13-13 regression: OpenAI-shape tool results travel as their own
/// `role: "tool"` message (never Claude's `role: "user"`-wrapped
/// `tool_result` blocks). This turn's own tool result must survive;
/// an unrelated one must still be truncated out exactly like G-B2
/// already proves for Claude's shape. Calls `assert_turn_minimal`
/// (G-B2 only) directly rather than `screen_outbound` — this test's
/// synthetic JSON key/role vocabulary is dense with short lowercase
/// words and can otherwise collide with G-B1's unrelated BIP39-length
/// heuristic by coincidence; that heuristic is already covered by its
/// own dedicated tests above and is not what this test is about.
#[test]
fn openai_shape_tool_role_result_is_turn_own_and_unrelated_ones_are_truncated() {
let user_turn = "restart immich";
let this_turn_result = r#"{"restarted":true}"#;
let unrelated_result = r#"{"unrelated":"a different topic entirely"}"#;
let body = json!({
"model": "some-routstr-model",
"messages": [
{"role": "system", "content": "sys prompt text"},
{"role": "tool", "tool_call_id": "old-1", "content": unrelated_result},
{"role": "user", "content": user_turn},
{"role": "assistant", "content": Value::Null, "tool_calls": [
{"id": "call-1", "type": "function", "function": {"name": "app_restart", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call-1", "content": this_turn_result},
],
})
.to_string();
let ctx = ctx_for(user_turn, &[this_turn_result], &["app_restart"]);
match assert_turn_minimal(&body, &ctx) {
EgressVerdict::Truncate(new_body) => {
assert!(
!new_body.contains("a different topic entirely"),
"an unrelated OpenAI-shape tool result must be truncated out: {new_body}"
);
assert!(
new_body.contains("restarted"),
"this turn's own OpenAI-shape tool result must survive: {new_body}"
);
assert!(
new_body.contains("app_restart"),
"this turn's own granted tool_calls entry must survive: {new_body}"
);
assert!(
new_body.contains("sys prompt text"),
"the system message must survive truncation: {new_body}"
);
}
other => panic!("expected Truncate, got {other:?}"),
}
}
/// An OpenAI-shape assistant turn calling a tool NOT in this turn's
/// granted set is not this turn's own content — fails closed exactly
/// like Claude's `tool_use` block check already does. Calls
/// `assert_turn_minimal` directly for the same reason as the test
/// above — isolating G-B2's own logic from G-B1's unrelated heuristic.
#[test]
fn openai_shape_ungranted_tool_call_is_not_turn_own() {
let user_turn = "hello";
let body = json!({
"model": "some-routstr-model",
"messages": [
{"role": "system", "content": "sys prompt text"},
{"role": "user", "content": user_turn},
{"role": "assistant", "content": Value::Null, "tool_calls": [
{"id": "call-1", "type": "function", "function": {"name": "wallet_send", "arguments": "{}"}},
]},
],
})
.to_string();
let ctx = ctx_for(user_turn, &[], &["app_restart"]); // wallet_send NOT granted
match assert_turn_minimal(&body, &ctx) {
EgressVerdict::Truncate(new_body) => {
assert!(
!new_body.contains("wallet_send"),
"an ungranted tool_calls entry must be truncated out: {new_body}"
);
}
other => panic!("expected Truncate, got {other:?}"),
}
}
}