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:
co-authored by
Claude Fable 5
parent
8548544db9
commit
a3521e5eeb
@@ -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
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user