diff --git a/Cargo.lock b/Cargo.lock index febf6e6f0643..df4143972977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -902,6 +902,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.13.1" @@ -977,6 +987,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + [[package]] name = "better_scoped_tls" version = "1.0.1" @@ -1278,6 +1294,19 @@ dependencies = [ "serde", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -1299,6 +1328,72 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "base64 0.21.7", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-payment-instructions" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c300c948b2ff78c965ea3a613372352125448a22f1acf49e95e3878149824091" +dependencies = [ + "bitcoin", + "lightning", + "lightning-invoice", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346568ebaab2918487cea76dd55dae13c27bb618cdb737c952e69eb2017c4118" +dependencies = [ + "bitcoin-internals", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1436,6 +1531,15 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -1694,6 +1798,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cashu" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b4ad04070fb67914feb2641e7b54001000f1f7f67a7c39a08f32edf01fd3ae" +dependencies = [ + "bitcoin", + "cbor-diag", + "ciborium", + "lightning", + "lightning-invoice", + "once_cell", + "serde", + "serde_json", + "serde_with", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.18", + "tracing", + "unicode-normalization", + "url", + "uuid", + "web-time", + "zeroize", +] + [[package]] name = "castaway" version = "0.2.4" @@ -1712,6 +1842,25 @@ dependencies = [ "cipher", ] +[[package]] +name = "cbor-diag" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429" +dependencies = [ + "bs58", + "chrono", + "data-encoding", + "half", + "nom 7.1.3", + "num-bigint", + "num-rational", + "num-traits", + "separator", + "url", + "uuid", +] + [[package]] name = "cc" version = "1.2.61" @@ -1724,6 +1873,139 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdk" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6536eb1df7a497cb334edf6bb9eea1fa8e4a9b08c79fe45c40d43413ecb88c8" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "bitcoin", + "bitcoin-payment-instructions", + "cbor-diag", + "cdk-common", + "cdk-signatory", + "ciborium", + "futures", + "getrandom 0.2.17", + "gloo-timers", + "jsonwebtoken 9.3.1", + "lightning", + "lightning-invoice", + "regex", + "ring", + "rustls", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "web-time", + "zeroize", +] + +[[package]] +name = "cdk-common" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a371d218ffe83dcabd202278251af53744b8fa3e846124ddcd34247c4e38bdc3" +dependencies = [ + "anyhow", + "async-trait", + "bitcoin", + "cashu", + "cbor-diag", + "cdk-http-client", + "ciborium", + "futures", + "getrandom 0.2.17", + "jsonwebtoken 9.3.1", + "lightning", + "lightning-invoice", + "parking_lot", + "paste", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tonic", + "tracing", + "url", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "cdk-http-client" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf194792d45975b360911417d3713e4d3f0142e18b082e2a8c2c88a91630b43d" +dependencies = [ + "futures", + "futures-channel", + "js-sys", + "regex", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio-tungstenite 0.26.2", + "tracing", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "cdk-redb" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ef814ccde71d17e2ddc43d3341156ca0d04b224be178a93b731c70f4bdde92" +dependencies = [ + "async-trait", + "cdk-common", + "lightning-invoice", + "paste", + "redb", + "serde", + "serde_json", + "thiserror 2.0.18", + "tracing", + "uuid", +] + +[[package]] +name = "cdk-signatory" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f40745c9402049bbba47ae52e1fc7552d4af8e99572fd73697448f51bbb3a02" +dependencies = [ + "anyhow", + "async-trait", + "bip39", + "bitcoin", + "cdk-common", + "clap", + "getrandom 0.2.17", + "home", + "rustls", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "tracing-subscriber", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1787,6 +2069,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -3009,6 +3318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -3204,6 +3514,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "dnssec-prover" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" +dependencies = [ + "bitcoin_hashes", +] + [[package]] name = "doc-comment" version = "0.3.4" @@ -4331,6 +4650,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "goose" version = "1.33.0" @@ -4377,7 +4708,7 @@ dependencies = [ "indoc", "insta", "jsonschema", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "keyring", "lazy_static", "libc", @@ -4476,7 +4807,10 @@ dependencies = [ "axum", "base64 0.22.1", "bat", + "bip39", "bzip2", + "cdk", + "cdk-redb", "chrono", "clap", "clap_complete", @@ -4489,6 +4823,7 @@ dependencies = [ "futures", "goose", "goose-mcp", + "home", "indicatif", "open", "rand 0.8.5", @@ -4705,6 +5040,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" + [[package]] name = "hashbrown" version = "0.14.5" @@ -4772,6 +5113,21 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + [[package]] name = "hipstr" version = "0.6.0" @@ -5658,6 +6014,21 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -5808,6 +6179,55 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "lightning" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c90397b635e3ece6b9a723fb470a46cb9b3592f217d72e40540a5fada00289d" +dependencies = [ + "bech32", + "bitcoin", + "dnssec-prover", + "hashbrown 0.13.2", + "libm", + "lightning-invoice", + "lightning-macros", + "lightning-types", + "possiblyrandom", +] + +[[package]] +name = "lightning-invoice" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85e5e14bcdb30d746e9785b04f27938292e8944f78f26517e01e91691f6b3f2" +dependencies = [ + "bech32", + "bitcoin", + "lightning-types", + "serde", +] + +[[package]] +name = "lightning-macros" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c717494cdc2c8bb85bee7113031248f5f6c64f8802b33c1c9e2d98e594aa71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "lightning-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb1aac93f22f2c2eac8a0ee83bb1a1ea58673caa2c82847302710b83364d04e6" +dependencies = [ + "bitcoin", +] + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -7253,6 +7673,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "possiblyrandom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b122a615d72104fb3d8b26523fdf9232cd8ee06949fb37e4ce3ff964d15dffd" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -7808,6 +8237,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -7942,6 +8380,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -8454,6 +8893,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -8524,6 +8975,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -8566,6 +9038,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "separator" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5" + [[package]] name = "seq-macro" version = "0.3.6" @@ -8721,8 +9199,17 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", "serde_core", + "serde_json", "serde_with_macros", + "time", ] [[package]] @@ -10729,6 +11216,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.26.2", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -11199,6 +11702,25 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -11549,6 +12071,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index c5576f817dc9..4b98f1c5a7da 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -65,6 +65,12 @@ sha2 = { workspace = true } sigstore-verify = { version = "=0.6.5", default-features = false } axum.workspace = true + +cdk = { version = "0.16", default-features = false, features = ["wallet"] } +cdk-redb = { version = "0.16", default-features = false, features = ["wallet"] } +home = "0.5" +bip39 = { version = "2.1", features = ["rand"] } + [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 0d9396bf6333..4466104dd1dd 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -27,6 +27,7 @@ use crate::commands::schedule::{ handle_schedule_sessions, }; use crate::commands::session::{handle_session_list, handle_session_remove}; +use crate::commands::wallet::{handle_wallet_balance, handle_wallet_topup, handle_wallet_withdraw}; use crate::recipes::extract_from_cli::extract_recipe_info_from_cli; use crate::recipes::recipe::{explain_recipe, render_recipe_as_yaml}; use crate::session::{build_session, SessionBuilderConfig}; @@ -274,6 +275,13 @@ pub struct InputOptions { pub render_recipe: bool, } +#[derive(Subcommand)] +enum WalletCommand { + Balance {}, + Topup { token: String }, + Withdraw { amount: Option }, +} + /// Output configuration options for the run command #[derive(Args, Debug, Clone)] pub struct OutputOptions { @@ -726,6 +734,13 @@ enum Command { #[command(about = "Configure goose settings")] Configure {}, + /// Manage the local Cashu wallet (balance, topup, withdraw) + #[command(about = "Manage the local Cashu wallet (balance, topup, withdraw)")] + Wallet { + #[command(subcommand)] + command: WalletCommand, + }, + /// Display goose configuration information #[command(about = "Display goose information")] Info { @@ -1063,6 +1078,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Configure {}) => "configure", Some(Command::Doctor {}) => "doctor", Some(Command::Info { .. }) => "info", + Some(Command::Wallet { .. }) => "wallet", Some(Command::Mcp { .. }) => "mcp", Some(Command::Acp { .. }) => "acp", Some(Command::Serve { .. }) => "serve", @@ -1815,6 +1831,11 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Configure {}) => handle_configure().await, Some(Command::Doctor {}) => crate::commands::doctor::handle_doctor().await, Some(Command::Info { verbose, check }) => handle_info(verbose, check).await, + Some(Command::Wallet { command }) => match command { + WalletCommand::Balance {} => handle_wallet_balance().await, + WalletCommand::Topup { token } => handle_wallet_topup(token).await, + WalletCommand::Withdraw { amount } => handle_wallet_withdraw(amount).await, + }, Some(Command::Mcp { server }) => handle_mcp_command(server).await, Some(Command::Acp { builtins }) => goose::acp::server::run(builtins).await, Some(Command::Serve { diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index c682724276cd..84bac77add1f 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -709,6 +709,16 @@ pub async fn configure_provider_dialog() -> anyhow::Result { } } + // Routstr's profile system replaces the standard config-key prompt + // for this provider. Asking the user for a URL here, *before* the + // model fetch, lets goose configure stay coherent: the picker always + // runs against whichever URL the user just chose. Selecting an URL + // that matches an existing profile auto-switches (with refund); + // entering a new URL creates a `default` profile. + if provider_name == "routstr" { + crate::commands::routstr::prompt_and_set_routstr_url().await?; + } + let non_primary_keys: Vec<_> = provider_meta .config_keys .iter() diff --git a/crates/goose-cli/src/commands/mod.rs b/crates/goose-cli/src/commands/mod.rs index 3dee8b63722e..f1ff4fddba4a 100644 --- a/crates/goose-cli/src/commands/mod.rs +++ b/crates/goose-cli/src/commands/mod.rs @@ -5,7 +5,10 @@ pub mod info; pub mod plugin; pub mod project; pub mod recipe; +pub mod routstr; +pub mod routstr_pending; pub mod schedule; pub mod session; pub mod term; pub mod update; +pub mod wallet; diff --git a/crates/goose-cli/src/commands/routstr.rs b/crates/goose-cli/src/commands/routstr.rs new file mode 100644 index 000000000000..7c511848001c --- /dev/null +++ b/crates/goose-cli/src/commands/routstr.rs @@ -0,0 +1,387 @@ +//! Routstr profile + Cashu helpers used by `goose configure`. +//! +//! Profile management — switching the active Routstr URL, refunding the +//! previously active profile into the local Cashu wallet, and auto-funding +//! the newly active profile from the local wallet — is driven entirely +//! through the `goose configure → Configure Providers → Routstr` URL +//! prompt. No top-level `goose routstr` subcommand is exposed; the surface +//! area is intentionally limited to the wallet (`goose wallet`) and the +//! configure flow. + +use anyhow::{anyhow, bail, Result}; +use cdk::Amount; +use console::style; +use goose::config::Config; +use goose::providers::routstr_api::{ + active_profile_name, balance_info, create_balance, load_profile, load_profiles, refund_balance, + set_active_profile, topup_balance, upsert_profile, BalanceInfoResponse, ProviderApiError, + RoutstrProfile, ROUTSTR_DEFAULT_HOST, +}; + +use crate::commands::routstr_pending; +use crate::commands::wallet::{open_wallet, receive_into_wallet, withdraw_to_token}; + +/// Default top-up amount in sats when the configure flow auto-funds the +/// active Routstr profile after a URL switch. +pub const DEFAULT_TOPUP_SATS: u64 = 2000; + +/// Switch the active profile to `name`. Refunds whatever the previously +/// active profile holds back into the local wallet (best-effort), then +/// auto-tops the new profile from the local wallet up to +/// [`DEFAULT_TOPUP_SATS`] (capped at the local wallet's actual balance). +/// +/// Used by [`prompt_and_set_routstr_url`] when the URL the user enters in +/// `goose configure` matches a profile other than the active one. +pub async fn handle_profile_use(name: String) -> Result<()> { + let config = Config::global(); + let profiles = load_profiles(config)?; + if !profiles.contains_key(&name) { + bail!( + "Routstr profile {name:?} not found. Available: {:?}", + profiles.keys().collect::>() + ); + } + + let current = active_profile_name(config); + if current == name { + println!( + "{}", + style(format!("Already on routstr profile {name:?}.")).dim() + ); + return Ok(()); + } + + if let Some(active_profile) = profiles.get(¤t) { + if !active_profile.api_key.is_empty() { + match refund_active_into_wallet(¤t, active_profile).await { + Ok(sats) => { + println!( + "{}", + style(format!( + "✓ refunded {sats} sats from {current:?} into local wallet" + )) + .green() + ); + let mut updated = active_profile.clone(); + updated.api_key.clear(); + upsert_profile(config, ¤t, updated)?; + } + Err(e) => { + eprintln!( + "{}", + style(format!( + "⚠ refund of {current:?} failed: {e}. \ + Queued for retry — the next `goose wallet \ + topup/balance/withdraw` will try again." + )) + .yellow() + ); + if let Err(qe) = routstr_pending::enqueue( + &active_profile.url, + &active_profile.api_key, + e.to_string(), + ) { + eprintln!( + "{}", + style(format!( + " ⚠ couldn't queue pending refund ({qe}); the api_key is still \ + in ROUTSTR_PROFILES.{current:?} for manual retry." + )) + .yellow() + ); + } + // Clear the api_key from the profile slot — the queue + // is now the source of truth for it. If we left it in + // the profile, the next switch back here would try to + // refund a key we already enqueued. + let mut updated = active_profile.clone(); + updated.api_key.clear(); + upsert_profile(config, ¤t, updated)?; + } + } + } + } + + set_active_profile(config, &name)?; + println!( + "{}", + style(format!("✓ active routstr profile is now {name:?}")).green() + ); + + let new_profile = profiles + .get(&name) + .cloned() + .ok_or_else(|| anyhow!("internal: profile vanished during switch"))?; + if let Err(e) = autotopup_after_switch(&name, &new_profile).await { + eprintln!( + "{}", + style(format!( + "⚠ auto-topup skipped: {e}. Top up the local wallet with `goose wallet topup ` and re-run `goose configure → Routstr` against the same URL to fund this profile." + )) + .yellow() + ); + } + + Ok(()) +} + +/// `goose configure → Configure Providers → Routstr` URL prompt. +/// +/// Reconciles whatever URL the user types with the profile system: +/// - Same as the active profile's URL → no-op. +/// - Matches a different existing profile → switch to it (refund + auto-topup). +/// - New URL → refund the active profile, create a `default` profile with +/// the new URL, make it active. +pub async fn prompt_and_set_routstr_url() -> Result<()> { + let config = Config::global(); + + let active = active_profile_name(config); + let profiles = load_profiles(config)?; + let current_url = profiles + .get(&active) + .map(|p| p.url.clone()) + .unwrap_or_else(|| ROUTSTR_DEFAULT_HOST.to_string()); + + let entered: String = cliclack::input("Routstr URL") + .default_input(¤t_url) + .interact()?; + let entered = entered.trim().to_string(); + if entered.is_empty() { + return Ok(()); + } + + if entered == current_url && profiles.contains_key(&active) && !profiles.is_empty() { + // Same URL — this is the user's escape hatch for "fund this + // profile". If there's no api_key yet, *try* to drain the local + // wallet via `topup_active_from_local`; if the local wallet is + // empty, log a warning and let the configure flow proceed to the + // model picker anyway (model fetch doesn't require an api_key, so + // the user can still browse the catalogue before topping up). + let active_profile = profiles.get(&active).cloned().unwrap_or_default(); + if active_profile.api_key.is_empty() { + let _ = cliclack::log::info(format!( + "routstr profile {active:?} already points at {entered}; \ + funding from local wallet (if any)." + )); + if let Err(e) = autotopup_after_switch(&active, &active_profile).await { + let _ = cliclack::log::warning(format!( + "auto-topup skipped: {e}. Browsing models anyway; \ + re-run after `goose wallet topup ` to fund." + )); + } + return Ok(()); + } + let _ = cliclack::log::info(format!( + "routstr profile {active:?} already points at {entered}; nothing to do." + )); + return Ok(()); + } + + // Clear any legacy top-level `ROUTSTR_HOST` so the profile's URL is + // the only source of truth. Older builds wrote a flat `ROUTSTR_HOST` + // and our `from_env` honours it as an override — leaving it in place + // would silently mask the profile change the user just made. + let _ = config.delete("ROUTSTR_HOST"); + + // 1. Existing profile with a matching URL → switch to it. + if let Some((existing_name, _)) = profiles + .iter() + .find(|(n, p)| **n != active && p.url == entered) + { + let existing_name = existing_name.clone(); + let _ = cliclack::log::info(format!( + "URL {entered} matches existing routstr profile {existing_name:?}; switching." + )); + return handle_profile_use(existing_name).await; + } + + // 2. Otherwise create / update a `default` profile and switch. + // The currently active profile's `default`-named slot is about to + // be overwritten with the new URL below, so we MUST refund (or + // enqueue for retry) its api_key before we lose track of it. + if let Some(active_profile) = profiles.get(&active) { + if !active_profile.api_key.is_empty() { + match refund_active_into_wallet(&active, active_profile).await { + Ok(sats) => { + let _ = cliclack::log::info(format!( + "refunded {sats} sats from {active:?} into local wallet before changing URL" + )); + } + Err(e) => { + let _ = cliclack::log::warning(format!( + "refund of {active:?} failed: {e}. Queued for retry — the \ + next `goose wallet topup/balance/withdraw` will try again." + )); + if let Err(qe) = routstr_pending::enqueue( + &active_profile.url, + &active_profile.api_key, + e.to_string(), + ) { + let _ = cliclack::log::warning(format!( + "couldn't queue pending refund ({qe}); writing the api_key back into \ + ROUTSTR_PROFILES.{active:?} so it isn't lost when we overwrite the \ + default slot." + )); + // Best-effort fallback: keep the api_key on the + // OLD profile slot under a different name so it's + // still recoverable even if the queue file write + // failed. + let mut backup = active_profile.clone(); + backup.url = active_profile.url.clone(); + let backup_name = format!("{active}-pending-refund"); + let _ = upsert_profile(config, &backup_name, backup); + } + } + } + // Either way, clear the api_key from the active slot before + // we overwrite it below — the queue (or the backup profile) + // is now the source of truth for it. + let mut updated = active_profile.clone(); + updated.api_key.clear(); + upsert_profile(config, &active, updated)?; + } + } + + let new_name = "default".to_string(); + let mut profiles = load_profiles(config)?; + let new_profile = RoutstrProfile::new(entered.clone()); + profiles.insert(new_name.clone(), new_profile.clone()); + goose::providers::routstr_api::save_profiles(config, &profiles)?; + set_active_profile(config, &new_name)?; + let _ = cliclack::log::info(format!( + "routstr profile {new_name:?} now points at {entered} and is active." + )); + + // Auto-fund from the local wallet, just like a `goose routstr profile use` + // switch would. Without this, the configure flow's downstream + // `test_provider_configuration` step fires a chat against a brand-new + // empty profile and 401s with the "no api_key yet" error before the + // user has a chance to do anything. If the local wallet is empty the + // auto-topup is a soft-fail (warn + proceed), matching the same-URL + // branch above. + if let Err(e) = autotopup_after_switch(&new_name, &new_profile).await { + let _ = cliclack::log::warning(format!( + "auto-topup skipped: {e}. Top up the local wallet with \ + `goose wallet topup ` and re-run \ + `goose configure → Routstr` against the same URL to fund this profile." + )); + } + Ok(()) +} + +// =================== internal helpers =================== + +async fn refund_active_into_wallet(name: &str, profile: &RoutstrProfile) -> Result { + let resp = refund_balance(&profile.url, &profile.api_key) + .await + .map_err(|e| anyhow!("refund {name:?} failed: {e}"))?; + let wallet = open_wallet().await?; + let received = receive_into_wallet(&wallet, &resp.token).await?; + Ok(received.max(resp.amount.as_sats() as u64)) +} + +async fn autotopup_after_switch(name: &str, profile: &RoutstrProfile) -> Result<()> { + let current_sats: u64 = if profile.api_key.is_empty() { + 0 + } else { + let info: BalanceInfoResponse = balance_info(&profile.url, &profile.api_key) + .await + .map_err(|e| anyhow!(e))?; + (info.balance / 1000) as u64 + }; + if current_sats >= DEFAULT_TOPUP_SATS { + println!(" {name:?} already has {current_sats} sats; skipping auto-topup."); + return Ok(()); + } + + let needed = DEFAULT_TOPUP_SATS.saturating_sub(current_sats); + // Open the wallet in its own scope so the redb lock is released before + // `topup_active_from_local` re-opens the same database. + let local_sats = { + let wallet = open_wallet().await?; + u64::from(wallet.total_balance().await?) + }; + if local_sats == 0 { + bail!( + "local wallet empty — top up with `goose wallet topup ` then re-run `goose configure → Routstr` against the same URL" + ); + } + let to_send = local_sats.min(needed); + topup_active_from_local(to_send).await +} + +/// Drain `amount_sats` from the local wallet into the *currently active* +/// Routstr profile. Creates the profile's `sk-...` api_key on first use +/// (`/v1/balance/create`) or tops up an existing one (`/v1/balance/topup`). +async fn topup_active_from_local(amount_sats: u64) -> Result<()> { + let config = Config::global(); + let active = active_profile_name(config); + let (active_name, mut profile) = load_profile(config, Some(&active))?; + + let wallet = open_wallet().await?; + let local_balance: Amount = wallet.total_balance().await?; + if local_balance == Amount::ZERO { + bail!("local wallet empty — top up with `goose wallet topup ` first"); + } + + let to_send: Amount = Amount::from(amount_sats).min(local_balance); + let token = withdraw_to_token(&wallet, to_send).await?; + + if profile.api_key.is_empty() { + let resp = create_balance(&profile.url, &token) + .await + .map_err(|e| anyhow!(e))?; + profile.api_key = resp.api_key; + upsert_profile(config, &active_name, profile.clone())?; + println!( + "{}", + style(format!( + "✓ created api_key for {active_name:?} with {} sats ({} mSats) initial balance", + resp.balance / 1000, + resp.balance, + )) + .green() + ); + } else { + let _ = topup_balance(&profile.url, &profile.api_key, &token) + .await + .map_err(|e| anyhow!(e))?; + println!( + "{}", + style(format!( + "✓ topped up {active_name:?} by {} sats", + u64::from(to_send) + )) + .green() + ); + } + + let local_after: Amount = wallet.total_balance().await?; + println!( + " local wallet: {} sats ({} sats sent to proxy)", + u64::from(local_after), + u64::from(to_send), + ); + Ok(()) +} + +#[allow(dead_code)] +fn short_err(e: &ProviderApiError) -> String { + let s = e.to_string(); + if s.chars().count() > 80 { + let truncated: String = s.chars().take(77).collect(); + format!("{truncated}...") + } else { + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_topup_is_2000_sats() { + assert_eq!(DEFAULT_TOPUP_SATS, 2000); + } +} diff --git a/crates/goose-cli/src/commands/routstr_pending.rs b/crates/goose-cli/src/commands/routstr_pending.rs new file mode 100644 index 000000000000..e230fa8262e4 --- /dev/null +++ b/crates/goose-cli/src/commands/routstr_pending.rs @@ -0,0 +1,230 @@ +//! Persistent queue of `(url, sk-api_key)` pairs that failed to refund +//! during a Routstr profile switch. +//! +//! When `goose configure → Routstr → ` swaps the active profile, +//! the previous profile's tracked sats are returned to the local Cashu +//! wallet via `POST /v1/balance/refund`. If that POST fails (proxy down, +//! network offline, rate limited, …) the sats are still on the proxy but +//! the api_key may end up overwritten in `ROUTSTR_PROFILES`. To stop +//! losing those sats, we instead enqueue the failed `(url, api_key)` pair +//! into a file at `~/.cdk-gooose/pending-refunds.json` and try to drain +//! it the next time the user runs any local-wallet command. +//! +//! On disk format (JSON array): +//! +//! ```json +//! [ +//! { +//! "url": "https://routstr.otrta.me", +//! "api_key": "sk-abc...", +//! "queued_at": "2026-05-05T20:30:00Z", +//! "reason": "connection refused" +//! } +//! ] +//! ``` + +use anyhow::{anyhow, Context, Result}; +use cdk::wallet::Wallet; +use chrono::Utc; +use console::style; +use goose::providers::routstr_api::refund_balance; +use home::home_dir; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +use crate::commands::wallet::receive_into_wallet; + +const PENDING_FILE_NAME: &str = "pending-refunds.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingRefund { + pub url: String, + pub api_key: String, + pub queued_at: String, + #[serde(default)] + pub reason: String, +} + +fn pending_path() -> Result { + let home = home_dir().ok_or_else(|| anyhow!("Could not resolve home directory"))?; + Ok(home.join(".cdk-gooose").join(PENDING_FILE_NAME)) +} + +/// Read the pending-refunds queue from disk. Missing or malformed file +/// returns an empty vec so callers don't have to special-case. +pub fn read_pending() -> Vec { + let path = match pending_path() { + Ok(p) => p, + Err(_) => return Vec::new(), + }; + let body = match fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + serde_json::from_str(&body).unwrap_or_default() +} + +fn write_pending(items: &[PendingRefund]) -> Result<()> { + let path = pending_path()?; + if let Some(dir) = path.parent() { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let body = serde_json::to_string_pretty(items)?; + if items.is_empty() { + // Best-effort cleanup — don't error out if the file doesn't exist. + let _ = fs::remove_file(&path); + return Ok(()); + } + fs::write(&path, body).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Append a `(url, api_key)` pair to the queue. De-duplicates by api_key +/// so repeated switch attempts don't pile up. +pub fn enqueue(url: &str, api_key: &str, reason: impl Into) -> Result<()> { + if api_key.trim().is_empty() { + return Ok(()); + } + let mut items = read_pending(); + if items.iter().any(|p| p.api_key == api_key) { + return Ok(()); + } + items.push(PendingRefund { + url: url.to_string(), + api_key: api_key.to_string(), + queued_at: Utc::now().to_rfc3339(), + reason: reason.into(), + }); + write_pending(&items)?; + Ok(()) +} + +/// Walk every pending entry, attempt the refund, redeem returned tokens +/// into `wallet`. Successful entries are removed from the queue; +/// unsuccessful entries stay so the next call retries. +/// +/// Prints a per-entry status line. `quiet=true` suppresses output when +/// the queue is empty (used by routine `goose wallet balance` calls). +pub async fn drain(wallet: &Wallet, quiet: bool) -> DrainSummary { + let items = read_pending(); + if items.is_empty() { + if !quiet { + // No pending refunds to drain — keep this silent for + // routine commands; only print when explicitly asked. + } + return DrainSummary::default(); + } + + let mut survivors: Vec = Vec::new(); + let mut summary = DrainSummary { + attempted: items.len(), + ..Default::default() + }; + + println!( + "{}", + style(format!( + "↻ draining {} pending Routstr refund(s)…", + items.len() + )) + .dim() + ); + + for entry in items { + match refund_balance(&entry.url, &entry.api_key).await { + Ok(resp) => match receive_into_wallet(wallet, &resp.token).await { + Ok(sats) => { + let reported = resp.amount.as_sats() as u64; + summary.refunded_sats += reported.max(sats); + summary.refunded_count += 1; + println!( + "{}", + style(format!( + " ✓ refunded {} sats from {}", + reported.max(sats), + entry.url + )) + .green() + ); + } + Err(e) => { + summary.failed_count += 1; + eprintln!( + "{}", + style(format!( + " ⚠ {}: refund returned a token but receive failed ({e}); keeping queued", + entry.url + )) + .yellow() + ); + survivors.push(entry); + } + }, + Err(e) => { + summary.failed_count += 1; + eprintln!( + "{}", + style(format!( + " ⚠ {}: refund still failing ({e}); keeping queued", + entry.url + )) + .yellow() + ); + survivors.push(entry); + } + } + } + + if let Err(e) = write_pending(&survivors) { + eprintln!( + "{}", + style(format!( + " ⚠ couldn't update pending-refunds file ({e}); state may re-trigger drains" + )) + .yellow() + ); + } + + summary +} + +#[derive(Debug, Default)] +pub struct DrainSummary { + pub attempted: usize, + pub refunded_count: usize, + pub refunded_sats: u64, + pub failed_count: usize, +} + +impl DrainSummary { + pub fn is_empty(&self) -> bool { + self.attempted == 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_pending_returns_empty_when_missing() { + // pending_path() depends on $HOME; smoke-test that read_pending + // doesn't panic when the file is absent. + let _ = read_pending(); + } + + #[test] + fn enqueue_skips_empty_api_key() { + // Doesn't write to disk when the api_key is blank. + let res = enqueue("https://routstr.example", "", "test"); + assert!(res.is_ok()); + } + + #[test] + fn drain_summary_default_is_empty() { + let s = DrainSummary::default(); + assert!(s.is_empty()); + assert_eq!(s.refunded_sats, 0); + } +} diff --git a/crates/goose-cli/src/commands/wallet.rs b/crates/goose-cli/src/commands/wallet.rs new file mode 100644 index 000000000000..206acd10294a --- /dev/null +++ b/crates/goose-cli/src/commands/wallet.rs @@ -0,0 +1,250 @@ +//! Local Cashu wallet behind the Routstr provider. +//! +//! This is a *plain* CDK wallet — it receives Cashu tokens, holds proofs in +//! a local redb store, and lets the user mint a fresh Cashu token to drain +//! some or all of the balance. It knows **nothing** about Routstr. +//! +//! Sats only move to a Routstr instance via `goose routstr topup` (which +//! drains some of the local balance into a Cashu token and POSTs that to +//! `/v1/balance/topup`). Sats come back via `goose routstr refund` +//! (which calls `/v1/balance/refund`, redeems the returned Cashu +//! token here, and zeros the api_key on the proxy). + +use anyhow::{bail, Result}; +use bip39::Mnemonic; +use cdk::amount::SplitTarget; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; +use cdk::Amount; +use cdk_redb::WalletRedbDatabase; +use home::home_dir; +use std::fs; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +const DEFAULT_MINT_URL: &str = "https://mint.minibits.cash/Bitcoin"; + +/// Balance + provenance info for a single local CDK wallet, useful for +/// human-readable reporting. +pub struct LocalWalletStatus { + pub balance_sats: u64, + pub mint_url: String, + pub seed_path: PathBuf, +} + +pub async fn handle_wallet_balance() -> Result<()> { + let wallet = open_wallet().await?; + // Try to drain any queued failed-refunds first so the balance we + // print reflects all reachable sats. + let _ = crate::commands::routstr_pending::drain(&wallet, true).await; + let balance: Amount = wallet.total_balance().await?; + println!("local wallet: {} sats", u64::from(balance)); + println!("mint: {}", DEFAULT_MINT_URL); + + // Also show the active Routstr profile's balance so the user sees + // both layers (local sats + proxy-tracked sats) in one command. + print_active_profile_balance().await; + Ok(()) +} + +/// Look up the active Routstr profile and, if it's funded, ask the proxy +/// for its current balance via `GET /v1/balance/info`. Prints a one-line +/// summary or a friendly hint if there's nothing to show. +async fn print_active_profile_balance() { + use goose::config::Config; + use goose::providers::routstr_api::{active_profile_name, balance_info, load_profile}; + + let config = Config::global(); + let active = active_profile_name(config); + let (name, profile) = match load_profile(config, Some(&active)) { + Ok(pair) => pair, + Err(_) => { + println!(); + println!("active profile: (none — run `goose configure → Routstr` to add one)"); + return; + } + }; + + println!(); + println!("active profile: {name} ({})", profile.url); + + if profile.api_key.trim().is_empty() { + println!( + "proxy balance: (not funded yet — run `goose configure → Routstr → {}` to fund it from the local wallet)", + profile.url + ); + return; + } + + match balance_info(&profile.url, &profile.api_key).await { + Ok(info) => { + // Routstr reports balance in millisats. Divide by 1000 for sats. + let bal_sats = info.balance / 1000; + let bal_msats = info.balance; + let spent_sats = info.total_spent / 1000; + print!("proxy balance: {} sats ({} mSats)", bal_sats, bal_msats); + if info.reserved > 0 { + print!(" — {} mSats reserved", info.reserved); + } + println!(); + if info.total_requests > 0 { + println!( + "spent: {} sats over {} request{}", + spent_sats, + info.total_requests, + if info.total_requests == 1 { "" } else { "s" } + ); + } + } + Err(e) => { + println!("proxy balance: (couldn't reach {} — {e})", profile.url); + } + } +} + +pub async fn handle_wallet_topup(token: String) -> Result<()> { + let token = token.trim(); + if token.is_empty() { + println!("No token provided. Operation cancelled."); + return Ok(()); + } + + let wallet = open_wallet().await?; + // Drain any queued failed-refunds before receiving the new token — + // a topup is a natural moment to retry network-dependent refunds + // (the user is online if they're typing a token). + let _ = crate::commands::routstr_pending::drain(&wallet, true).await; + + let amount = wallet + .receive(token, ReceiveOptions::default()) + .await + .map_err(|e| anyhow::anyhow!("Failed to receive token: {e}"))?; + + let balance: Amount = wallet.total_balance().await?; + println!( + "Received {} sats. Local wallet balance: {} sats.", + u64::from(amount), + u64::from(balance), + ); + Ok(()) +} + +pub async fn handle_wallet_withdraw(amount: Option) -> Result<()> { + let wallet = open_wallet().await?; + // Drain queued refunds first so the user can withdraw whatever + // came back from previously-stranded api_keys in the same call. + let _ = crate::commands::routstr_pending::drain(&wallet, true).await; + let balance: Amount = wallet.total_balance().await?; + + if balance == Amount::ZERO { + println!("Local wallet is empty."); + return Ok(()); + } + + let amount = amount.map(Amount::from).unwrap_or(balance).min(balance); + + let prep_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = prep_send.confirm(None).await?; + println!("{}", token); + + let new_balance: Amount = wallet.total_balance().await?; + eprintln!( + "Withdrew {} sats. Local wallet balance: {} sats.", + u64::from(amount), + u64::from(new_balance), + ); + Ok(()) +} + +/// Open the local CDK wallet, creating the seed/redb on first use. Public +/// so the new `goose routstr` subcommand can reuse it for topup/refund. +pub async fn open_wallet() -> Result { + let work_dir = wallet_dir()?; + fs::create_dir_all(&work_dir)?; + + let cdk_wallet_path = work_dir.join("cdk-goose.redb"); + let wallet_db = WalletRedbDatabase::new(&cdk_wallet_path)?; + + let seed_path = work_dir.join("seed"); + let mnemonic = match fs::metadata(&seed_path) { + Ok(_) => Mnemonic::from_str(&fs::read_to_string(&seed_path)?)?, + Err(_) => { + let mnemonic = Mnemonic::generate(12)?; + tracing::info!("Creating new Cashu wallet seed"); + fs::write(&seed_path, mnemonic.to_string())?; + mnemonic + } + }; + + let seed = mnemonic.to_seed_normalized(""); + let wallet = Wallet::new( + DEFAULT_MINT_URL, + CurrencyUnit::Sat, + Arc::new(wallet_db), + seed, + None, + )?; + + if let Err(e) = wallet.recover_incomplete_sagas().await { + tracing::warn!("recover_incomplete_sagas failed: {e}"); + } + + Ok(wallet) +} + +pub async fn wallet_status() -> Result { + let wallet = open_wallet().await?; + let balance: Amount = wallet.total_balance().await?; + Ok(LocalWalletStatus { + balance_sats: u64::from(balance), + mint_url: DEFAULT_MINT_URL.to_string(), + seed_path: wallet_dir()?.join("seed"), + }) +} + +/// Withdraw `amount` sats from the local wallet and return the encoded +/// Cashu token. Helper for the `goose routstr topup` flow. +pub async fn withdraw_to_token(wallet: &Wallet, amount: Amount) -> Result { + let balance: Amount = wallet.total_balance().await?; + if balance < amount { + bail!( + "Local wallet has {} sats, need {}.", + u64::from(balance), + u64::from(amount) + ); + } + let prep_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = prep_send.confirm(None).await?; + Ok(token.to_string()) +} + +/// Receive a Cashu token returned by `/v1/balance/refund` into the local +/// wallet. Returns the amount of sats added. +pub async fn receive_into_wallet(wallet: &Wallet, token: &str) -> Result { + let amount = wallet + .receive(token.trim(), ReceiveOptions::default()) + .await?; + Ok(u64::from(amount)) +} + +/// Best-effort split for a desired top-up amount. Caps at the available +/// local balance. +pub fn capped_topup(desired: Amount, available: Amount) -> Amount { + if available < desired { + available + } else { + desired + } +} + +fn wallet_dir() -> Result { + let home = home_dir().ok_or_else(|| anyhow::anyhow!("Could not resolve home directory"))?; + Ok(home.join(".cdk-gooose")) +} + +/// Use SplitTarget::default() for prepare_send. Re-exported for the routstr +/// subcommand so it can keep parity if it ever needs to call swap directly. +pub fn default_split_target() -> SplitTarget { + SplitTarget::default() +} diff --git a/crates/goose/src/providers/errors.rs b/crates/goose/src/providers/errors.rs index 46a2aa4ca051..bc095e385ce1 100644 --- a/crates/goose/src/providers/errors.rs +++ b/crates/goose/src/providers/errors.rs @@ -42,6 +42,9 @@ pub enum ProviderError { details: String, top_up_url: Option, }, + + #[error("Insufficient balance: {0} sats required. Please top up your balance to continue.")] + InsufficientBalance(f64), } impl ProviderError { @@ -58,6 +61,7 @@ impl ProviderError { ProviderError::NotImplemented(_) => "not_implemented", ProviderError::EndpointNotFound(_) => "endpoint_not_found", ProviderError::CreditsExhausted { .. } => "credits_exhausted", + ProviderError::InsufficientBalance(_) => "insufficient_balance", } } @@ -159,3 +163,122 @@ impl GoogleErrorCode { } } } + +#[derive(serde::Deserialize, Debug)] +pub struct OpenAIError { + #[serde(deserialize_with = "code_as_string")] + pub code: Option, + pub message: Option, + #[serde(rename = "type")] + pub error_type: Option, +} + +fn code_as_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + use std::fmt; + + struct CodeVisitor; + + impl<'de> Visitor<'de> for CodeVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string, a number, null, or none for the code field") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(Some(value.to_string())) + } + + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(Some(value.to_string())) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(None) + } + + fn visit_some(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(CodeVisitor) + } + } + + deserializer.deserialize_option(CodeVisitor) +} + +impl OpenAIError { + pub fn is_context_length_exceeded(&self) -> bool { + if let Some(code) = &self.code { + code == "context_length_exceeded" || code == "string_above_max_length" + } else { + false + } + } + + pub fn get_insufficient_balance(&self) -> Option { + if let Some(code) = &self.code { + if code == "insufficient_balance" { + if let Some(message) = &self.message { + if let Some(sats_str) = message + .split_whitespace() + .find(|word| word.parse::().is_ok()) + { + return sats_str.parse::().ok(); + } + } + } + } + None + } +} + +impl std::fmt::Display for OpenAIError { + /// Format the error for display. + /// E.g. {"message": "Invalid API key", "code": "invalid_api_key", "type": "client_error"} + /// would be formatted as "Invalid API key (code: invalid_api_key, type: client_error)" + /// and {"message": "Foo"} as just "Foo", etc. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(message) = &self.message { + write!(f, "{}", message)?; + } + let mut in_parenthesis = false; + if let Some(code) = &self.code { + write!(f, " (code: {}", code)?; + in_parenthesis = true; + } + if let Some(typ) = &self.error_type { + if in_parenthesis { + write!(f, ", type: {}", typ)?; + } else { + write!(f, " (type: {}", typ)?; + in_parenthesis = true; + } + } + if in_parenthesis { + write!(f, ")")?; + } + Ok(()) + } +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index b9189a99f11f..4ab75c3a20b6 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -33,6 +33,7 @@ use super::{ openrouter::OpenRouterProvider, pi_acp::PiAcpProvider, provider_registry::ProviderRegistry, + routstr::RoutstrProvider, snowflake::SnowflakeProvider, tetrate::TetrateProvider, venice::VeniceProvider, @@ -80,6 +81,7 @@ async fn init_registry() -> RwLock { registry.register::(true); registry.register::(true); registry.register::(false); + registry.register::(false); #[cfg(feature = "aws-providers")] registry.register::(false); registry.register::(false); diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 6781c2a986da..45186acdabfc 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -46,6 +46,8 @@ pub mod pi_acp; pub mod provider_registry; pub mod provider_test; mod retry; +pub mod routstr; +pub mod routstr_api; #[cfg(feature = "aws-providers")] pub mod sagemaker_tgi; pub mod snowflake; diff --git a/crates/goose/src/providers/routstr.rs b/crates/goose/src/providers/routstr.rs new file mode 100644 index 000000000000..dc149460ecfe --- /dev/null +++ b/crates/goose/src/providers/routstr.rs @@ -0,0 +1,544 @@ +use anyhow::Result; +use async_trait::async_trait; +use futures::future::BoxFuture; +use reqwest::{Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use super::api_client::{ApiClient, AuthMethod}; +use super::base::{MessageStream, Provider, ProviderDef, ProviderMetadata}; +use super::errors::{OpenAIError, ProviderError}; +use super::http_status::map_http_error_to_provider_error; +use super::openai_compatible::stream_openai_compat; +use super::retry::ProviderRetry; +#[cfg(test)] +use super::routstr_api::ROUTSTR_DEFAULT_PROFILE; +use super::routstr_api::{active_profile_name, load_profile, ROUTSTR_DEFAULT_HOST}; +use super::utils::{ImageFormat, RequestLog}; +use crate::conversation::message::Message; +use crate::model::ModelConfig; +use crate::providers::formats::openai::create_request; +use rmcp::model::Tool; + +const ROUTSTR_PROVIDER_NAME: &str = "routstr"; +pub const ROUTSTR_HOST: &str = ROUTSTR_DEFAULT_HOST; +pub const ROUTSTR_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4"; +pub const ROUTSTR_MODEL_PREFIX_ANTHROPIC: &str = "anthropic"; + +pub const ROUTSTR_KNOWN_MODELS: &[&str] = &[ + "meta-llama/llama-3.2-1b-instruct", + "deepseek/deepseek-r1-0528-qwen3-8b", + "anthropic/claude-3.5-sonnet", + "anthropic/claude-sonnet-4", +]; +pub const ROUTSTR_DOC_URL: &str = "https://routstr.com/docs"; +pub const ROUTSTR_DEFAULT_MINT_URL: &str = "https://mint.minibits.cash/Bitcoin"; +pub const ROUTSTR_DEFAULT_CURRENCY_UNIT: &str = "sat"; + +/// Pricing information for a model. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelPricing { + pub prompt: f64, + pub completion: f64, +} + +/// Individual model information returned by `/v1/models`. Fields are kept +/// optional because different Routstr instances expose different shapes — +/// upstream `api.routstr.com` returns a richer schema (`name`, `description`, +/// `architecture`, `sats_pricing`, …) without `object`/`owned_by`/`permission`, +/// while `routstr.otrta.me` returns the OpenAI-compatible minimum. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + pub id: String, + #[serde(default)] + pub object: Option, + #[serde(default)] + pub created: Option, + #[serde(default)] + pub owned_by: Option, + #[serde(default)] + pub permission: Option>, + #[serde(default)] + pub pricing: Option, + #[serde(default)] + pub context_length: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelsResponse { + #[serde(default)] + pub object: Option, + pub data: Vec, +} + +#[derive(serde::Serialize)] +pub struct RoutstrProvider { + #[serde(skip)] + api_client: ApiClient, + model: ModelConfig, + #[serde(skip)] + name: String, + /// Whether the active profile had a non-empty `api_key` when the + /// provider was constructed. Used to surface a "fund this profile + /// first" hint when chat / /v1/models is called against an unfunded + /// profile. + has_api_key: bool, + /// Name of the active profile this instance was built from. Surfaced + /// in error messages so the user knows which profile to fund. + #[serde(skip)] + profile_name: String, +} + +impl RoutstrProvider { + pub async fn from_env(model: ModelConfig) -> Result { + let config = crate::config::Config::global(); + + // Resolve the active profile. + // + // ROUTSTR_HOST (env var or top-level config key) is honoured as a + // legacy single-host override: if set, it forces this run to use the + // given URL with whatever api_key the active profile has stored. + // Otherwise the `{ROUTSTR_PROFILES, ROUTSTR_ACTIVE}` pair is the + // source of truth. + let env_host_override: Option = std::env::var("ROUTSTR_HOST") + .ok() + .or_else(|| config.get_param::("ROUTSTR_HOST").ok()); + + let active = active_profile_name(config); + let profile = load_profile(config, Some(&active)) + .map(|(_, p)| p) + .unwrap_or_default(); + + let host = env_host_override + .filter(|h| !h.trim().is_empty()) + .or_else(|| { + if profile.url.trim().is_empty() { + None + } else { + Some(profile.url.clone()) + } + }) + .unwrap_or_else(|| ROUTSTR_DEFAULT_HOST.to_string()); + + let api_key = profile.api_key.clone(); + let has_api_key = !api_key.trim().is_empty(); + let profile_name = active; + + let api_client = ApiClient::new(host, AuthMethod::BearerToken(api_key))?; + + Ok(Self { + api_client, + model, + name: ROUTSTR_PROVIDER_NAME.to_string(), + has_api_key, + profile_name, + }) + } + + fn require_api_key(&self) -> Result<(), ProviderError> { + if !self.has_api_key { + return Err(ProviderError::Authentication(format!( + "Routstr profile {:?} has no api_key yet. \ + Top up the local Cashu wallet with `goose wallet topup `, \ + then re-run `goose configure → Configure Providers → Routstr` against the same URL — \ + configure will auto-fund this profile from the local wallet.", + self.profile_name + ))); + } + Ok(()) + } + + async fn fetch_models_info(&self) -> Result { + // `/v1/models` is public on every Routstr instance we've tested + // (api.routstr.com and routstr.otrta.me both serve it without + // auth). Don't gate this on `has_api_key` — letting the user + // browse the catalogue before topping up a profile is a feature. + let response = self + .api_client + .response_get(None, "v1/models") + .await + .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {e}")))?; + let response = handle_routstr_status(response).await?; + + response.json::().await.map_err(|e| { + ProviderError::RequestFailed(format!("Failed to parse models response: {e}")) + }) + } +} + +/// Inspect the response and convert non-2xx replies into the right +/// `ProviderError`. Routstr signals an out-of-balance wallet via +/// `code = "insufficient_balance"` in the response body — we surface that as +/// `InsufficientBalance(sats)` so the CLI can prompt the user to top up. +/// +/// Different Routstr instances disagree on the exact envelope: +/// - upstream `api.routstr.com`: HTTP 400, body `{"error": {...}}` +/// - `routstr.otrta.me` and similar: HTTP 402 (Payment Required), body +/// `{"detail": {"error": {...}}}`, with the required amount expressed in +/// **mSats** (millisats) rather than sats. +/// +/// Match both shapes before falling through to the shared HTTP mapper. If +/// the proxy reports the required amount in mSats, normalise to sats so the +/// user-facing error message shows the actual top-up amount. +async fn handle_routstr_status(response: Response) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + + let body = response.text().await.unwrap_or_default(); + let payload: Option = serde_json::from_str(&body).ok(); + + if matches!( + status, + StatusCode::BAD_REQUEST | StatusCode::PAYMENT_REQUIRED + ) { + if let Some(sats) = parse_insufficient_balance(payload.as_ref()) { + return Err(ProviderError::InsufficientBalance(sats)); + } + } + + Err(map_http_error_to_provider_error(status, payload)) +} + +/// Try to extract a Routstr `insufficient_balance` error from a JSON +/// payload. Returns the required amount in **sats**, normalising mSats when +/// the upstream message reports milli-sats (otrta envelope). Returns `None` +/// if the payload doesn't carry an OpenAI-shaped `code = "insufficient_balance"` +/// error. +fn parse_insufficient_balance(payload: Option<&Value>) -> Option { + let p = payload?; + let error_obj = p + .get("error") + .or_else(|| p.get("detail").and_then(|d| d.get("error")))?; + let err: OpenAIError = serde_json::from_value(error_obj.clone()).ok()?; + let value = err.get_insufficient_balance()?; + let in_msats = err + .message + .as_deref() + .map(|m| m.to_lowercase().contains("msat")) + .unwrap_or(false); + Some(if in_msats { value / 1000.0 } else { value }) +} + +/// Apply Anthropic prompt-caching markers to an OpenAI-shaped payload. +/// Mirrors the OpenRouter helper: cache-control on the system message, +/// the last two user messages, and the final tool spec. +fn update_request_for_anthropic(original_payload: &Value) -> Value { + let mut payload = original_payload.clone(); + + if let Some(messages_spec) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut("messages")) + .and_then(|messages| messages.as_array_mut()) + { + let mut user_count = 0; + for message in messages_spec.iter_mut().rev() { + if message.get("role") == Some(&json!("user")) { + if let Some(content) = message.get_mut("content") { + if let Some(content_str) = content.as_str() { + *content = json!([{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }]); + } + } + user_count += 1; + if user_count >= 2 { + break; + } + } + } + + if let Some(system_message) = messages_spec + .iter_mut() + .find(|msg| msg.get("role") == Some(&json!("system"))) + { + if let Some(content) = system_message.get_mut("content") { + if let Some(content_str) = content.as_str() { + *system_message = json!({ + "role": "system", + "content": [{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }] + }); + } + } + } + } + + if let Some(tools_spec) = payload + .as_object_mut() + .and_then(|obj| obj.get_mut("tools")) + .and_then(|tools| tools.as_array_mut()) + { + if let Some(last_tool) = tools_spec.last_mut() { + if let Some(function) = last_tool.get_mut("function") { + if let Some(obj) = function.as_object_mut() { + obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" })); + } + } + } + } + + payload +} + +impl ProviderDef for RoutstrProvider { + type Provider = Self; + + fn metadata() -> ProviderMetadata { + ProviderMetadata::new( + ROUTSTR_PROVIDER_NAME, + "Routstr", + "LLM provider with Cashu (CDK) wallet payment integration", + ROUTSTR_DEFAULT_MODEL, + ROUTSTR_KNOWN_MODELS.to_vec(), + ROUTSTR_DOC_URL, + // No config keys are advertised: Routstr setup is driven by + // `goose routstr profile {add,use,topup}` against the Cashu + // wallet, not by configure-time prompts. The active profile's + // `{url, api_key}` lives under ROUTSTR_PROFILES/ROUTSTR_ACTIVE + // and is wholly owned by those subcommands. ROUTSTR_HOST is + // still honoured as a per-shell override at runtime. + vec![], + ) + .with_setup_steps(vec![ + "Top up the local Cashu wallet: `goose wallet topup `.", + "Add a Routstr profile: `goose routstr profile add default --url https://api.routstr.com`.", + "Fund the active profile from the local wallet: `goose routstr topup` (default 2000 sats).", + "List models against the proxy: `goose configure → Configure Providers → Routstr`.", + ]) + } + + fn from_env( + model: ModelConfig, + _extensions: Vec, + ) -> BoxFuture<'static, Result> { + Box::pin(Self::from_env(model)) + } +} + +#[async_trait] +impl Provider for RoutstrProvider { + fn get_name(&self) -> &str { + &self.name + } + + fn get_model_config(&self) -> ModelConfig { + self.model.clone() + } + + async fn fetch_supported_models(&self) -> Result, ProviderError> { + let models = self.fetch_models_info().await?; + let mut ids: Vec = models.data.into_iter().map(|m| m.id).collect(); + ids.sort(); + Ok(ids) + } + + /// Routstr is an aggregator — a single host fronts dozens of upstream + /// providers (OpenAI, Anthropic, Google, GLM, DeepSeek, Llama, …). The + /// shared canonical-model registry only lists "official" entries from + /// known providers, so applying it to a Routstr response would drop + /// every model that isn't already in the registry (e.g. all of + /// `routstr.otrta.me`'s gpt-5.5-* / claude-sonnet-4.6 / gemini-3.1-* + /// catalogue). Disable the filter and surface everything the proxy + /// returns; users can use the picker's search to narrow it down. + fn skip_canonical_filtering(&self) -> bool { + true + } + + async fn supports_cache_control(&self) -> bool { + self.model + .model_name + .starts_with(ROUTSTR_MODEL_PREFIX_ANTHROPIC) + } + + async fn stream( + &self, + model_config: &ModelConfig, + session_id: &str, + system: &str, + messages: &[Message], + tools: &[Tool], + ) -> Result { + self.require_api_key()?; + + let mut payload = create_request( + model_config, + system, + messages, + tools, + &ImageFormat::OpenAi, + true, + )?; + + if self.supports_cache_control().await { + payload = update_request_for_anthropic(&payload); + } + + let mut log = RequestLog::start(model_config, &payload)?; + + let response = self + .with_retry(|| async { + let resp = self + .api_client + .response_post(Some(session_id), "v1/chat/completions", &payload) + .await?; + handle_routstr_status(resp).await + }) + .await + .inspect_err(|e| { + let _ = log.error(e); + })?; + + stream_openai_compat(response, log) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_insufficient_balance_root_envelope_in_sats() { + // upstream api.routstr.com style: HTTP 400, root error, sats + let payload = json!({ + "error": { + "message": "Insufficient balance: 500 sats required for this request.", + "code": "insufficient_balance", + "type": "insufficient_quota" + } + }); + assert_eq!(parse_insufficient_balance(Some(&payload)), Some(500.0)); + } + + #[test] + fn parse_insufficient_balance_otrta_envelope_in_msats() { + // routstr.otrta.me style: HTTP 402, nested detail.error, mSats + let payload = json!({ + "detail": { + "error": { + "message": "Insufficient balance: 3379439 mSats required for this model. 1976000 available.", + "type": "insufficient_quota", + "code": "insufficient_balance" + } + }, + "request_id": "abc" + }); + // 3379439 mSats / 1000 = 3379.439 sats + assert_eq!(parse_insufficient_balance(Some(&payload)), Some(3379.439)); + } + + #[test] + fn parse_insufficient_balance_ignores_other_codes() { + let payload = json!({ + "error": { + "message": "model not available", + "code": "model_not_found" + } + }); + assert_eq!(parse_insufficient_balance(Some(&payload)), None); + } + + #[test] + fn parse_insufficient_balance_ignores_unrelated_payload() { + let payload = json!({"detail": "something else"}); + assert_eq!(parse_insufficient_balance(Some(&payload)), None); + + assert_eq!(parse_insufficient_balance(None), None); + } + + #[test] + fn models_response_parses_minimal_openai_shape() { + // The minimal "OpenAI-compatible" shape some Routstr instances + // serve: every model has only `id`, no extra fields. + let payload = json!({ + "object": "list", + "data": [ + {"id": "anthropic/claude-sonnet-4"}, + {"id": "google/gemini-2.5-pro"} + ] + }); + let parsed: ModelsResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(parsed.data.len(), 2); + assert_eq!(parsed.data[0].id, "anthropic/claude-sonnet-4"); + assert_eq!(parsed.data[1].id, "google/gemini-2.5-pro"); + } + + #[test] + fn models_response_parses_richer_routstr_shape() { + // Richer shape served by api.routstr.com / routstr.otrta.me — extra + // fields like name/description/architecture/sats_pricing must not + // break the parser, and pricing carries extra entries beyond the + // canonical prompt/completion pair. + let payload = json!({ + "data": [ + { + "id": "gpt-5.5-openai", + "name": "OpenAI: GPT-5.5", + "created": 1773863703, + "description": "OpenAI flagship multimodal model.", + "context_length": 1048576, + "architecture": {"modality": "text+image->text"}, + "pricing": { + "prompt": 3.5e-06, + "completion": 2.1e-05, + "request": 0.0, + "image": 0.0 + }, + "sats_pricing": {"prompt": 0.0035, "completion": 0.021}, + "per_request_limits": {}, + "top_provider": {}, + "enabled": true, + "upstream_provider_id": "openai" + } + ] + }); + let parsed: ModelsResponse = serde_json::from_value(payload).unwrap(); + assert_eq!(parsed.data.len(), 1); + assert_eq!(parsed.data[0].id, "gpt-5.5-openai"); + assert_eq!(parsed.data[0].context_length, Some(1048576)); + assert!(parsed.data[0].pricing.is_some()); + } + + #[test] + fn require_api_key_blocks_when_unset() { + let provider = RoutstrProvider { + api_client: ApiClient::new( + ROUTSTR_HOST.to_string(), + AuthMethod::BearerToken(String::new()), + ) + .unwrap(), + model: ModelConfig::new_or_fail(ROUTSTR_DEFAULT_MODEL), + name: ROUTSTR_PROVIDER_NAME.to_string(), + has_api_key: false, + profile_name: ROUTSTR_DEFAULT_PROFILE.to_string(), + }; + let err = provider.require_api_key().unwrap_err(); + assert!( + matches!(err, ProviderError::Authentication(ref msg) if msg.contains("goose wallet topup") && msg.contains("goose configure")), + "expected wallet-topup + configure hint, got: {err:?}" + ); + } + + #[test] + fn require_api_key_passes_when_set() { + let provider = RoutstrProvider { + api_client: ApiClient::new( + ROUTSTR_HOST.to_string(), + AuthMethod::BearerToken("sk-deadbeef".to_string()), + ) + .unwrap(), + model: ModelConfig::new_or_fail(ROUTSTR_DEFAULT_MODEL), + name: ROUTSTR_PROVIDER_NAME.to_string(), + has_api_key: true, + profile_name: ROUTSTR_DEFAULT_PROFILE.to_string(), + }; + assert!(provider.require_api_key().is_ok()); + } +} diff --git a/crates/goose/src/providers/routstr_api.rs b/crates/goose/src/providers/routstr_api.rs new file mode 100644 index 000000000000..878e231c5dda --- /dev/null +++ b/crates/goose/src/providers/routstr_api.rs @@ -0,0 +1,449 @@ +//! Routstr profile config + balance-API client. +//! +//! The Routstr provider stores its credentials as a set of *profiles*. Each +//! profile is `{url, api_key}` where `api_key` is an `sk-...` bearer issued +//! by the proxy (via `GET /v1/balance/create`), **not** a Cashu token. Sats +//! belong to the local CDK wallet (managed by `goose wallet`); they only +//! move to a Routstr instance via `/v1/balance/topup`, and they come back +//! via `/v1/balance/refund`. + +use anyhow::{anyhow, Context, Result}; +use indexmap::IndexMap; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +pub const ROUTSTR_DEFAULT_HOST: &str = "https://api.routstr.com"; +pub const ROUTSTR_DEFAULT_PROFILE: &str = "default"; + +/// Active-profile pointer. Stored at the top level of `~/.config/goose/config.yaml`. +pub const ROUTSTR_ACTIVE_KEY: &str = "ROUTSTR_ACTIVE"; +/// Per-profile config map. Stored at the top level of `~/.config/goose/config.yaml`. +pub const ROUTSTR_PROFILES_KEY: &str = "ROUTSTR_PROFILES"; + +/// One Routstr profile's persistent state. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RoutstrProfile { + /// Base URL of the Routstr proxy, e.g. `https://routstr.otrta.me`. + pub url: String, + /// `sk-...` bearer token issued by the proxy. Empty until the first + /// successful `/v1/balance/create` or `/v1/balance/topup`. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub api_key: String, +} + +impl RoutstrProfile { + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + api_key: String::new(), + } + } +} + +/// Load the named profile; if `name` is `None`, load whichever profile is +/// currently active. Falls back to `default` (and creates it on save) when +/// `ROUTSTR_ACTIVE` is unset. +pub fn load_profile( + config: &crate::config::Config, + name: Option<&str>, +) -> Result<(String, RoutstrProfile)> { + let profiles = load_profiles(config)?; + let name = match name { + Some(n) => n.to_string(), + None => active_profile_name(config), + }; + let profile = profiles + .get(&name) + .cloned() + .ok_or_else(|| anyhow!("Routstr profile {name:?} not found"))?; + Ok((name, profile)) +} + +/// Read the `ROUTSTR_PROFILES` map from config. Returns an empty map if +/// nothing is set. Profiles are kept in insertion order. +pub fn load_profiles(config: &crate::config::Config) -> Result> { + match config.get_param::>(ROUTSTR_PROFILES_KEY) { + Ok(p) => Ok(p), + Err(_) => Ok(IndexMap::new()), + } +} + +/// Persist the full `ROUTSTR_PROFILES` map. +pub fn save_profiles( + config: &crate::config::Config, + profiles: &IndexMap, +) -> Result<()> { + let value = serde_json::to_value(profiles) + .context("failed to serialise Routstr profiles to JSON value")?; + config + .set_param(ROUTSTR_PROFILES_KEY, &value) + .context("failed to write ROUTSTR_PROFILES into config")?; + Ok(()) +} + +/// Active-profile name, defaulting to `default` if unset. +pub fn active_profile_name(config: &crate::config::Config) -> String { + config + .get_param::(ROUTSTR_ACTIVE_KEY) + .unwrap_or_else(|_| ROUTSTR_DEFAULT_PROFILE.to_string()) +} + +/// Set the active-profile name. The profile must already exist in the +/// `ROUTSTR_PROFILES` map; we don't enforce that here because the caller +/// usually saves both in one go. +pub fn set_active_profile(config: &crate::config::Config, name: &str) -> Result<()> { + config + .set_param( + ROUTSTR_ACTIVE_KEY, + serde_json::Value::String(name.to_string()), + ) + .context("failed to write ROUTSTR_ACTIVE into config")?; + Ok(()) +} + +/// Insert (or update) a profile and write the map back. Does not change the +/// active-profile pointer. +pub fn upsert_profile( + config: &crate::config::Config, + name: &str, + profile: RoutstrProfile, +) -> Result<()> { + let mut profiles = load_profiles(config)?; + profiles.insert(name.to_string(), profile); + save_profiles(config, &profiles) +} + +/// Drop a profile from the map. If it was the active one, fall back to the +/// first remaining profile (or unset `ROUTSTR_ACTIVE` if none are left). +pub fn remove_profile(config: &crate::config::Config, name: &str) -> Result { + let mut profiles = load_profiles(config)?; + let removed = profiles.shift_remove(name).is_some(); + if !removed { + return Ok(false); + } + save_profiles(config, &profiles)?; + + if active_profile_name(config) == name { + if let Some((next, _)) = profiles.iter().next() { + set_active_profile(config, next)?; + } else { + // No profiles left; clear the active pointer so future operations + // don't reference a missing profile. + let _ = config.delete(ROUTSTR_ACTIVE_KEY); + } + } + + Ok(true) +} + +// ============================================================================ +// Routstr balance-API client +// ============================================================================ + +/// Response from `GET /v1/balance/create?initial_balance_token=cashuB...`. +#[derive(Debug, Clone, Deserialize)] +pub struct BalanceCreateResponse { + pub api_key: String, + /// Balance, in **mSats** (millisats). + pub balance: i64, +} + +/// Response from `GET /v1/balance/info` (`Authorization: Bearer sk-...`). +#[derive(Debug, Clone, Deserialize)] +pub struct BalanceInfoResponse { + pub api_key: String, + /// Balance, in **mSats** (millisats). + pub balance: i64, + #[serde(default)] + pub reserved: i64, + #[serde(default)] + pub total_requests: i64, + #[serde(default)] + pub total_spent: i64, +} + +/// Response from `POST /v1/balance/refund` (`Authorization: Bearer sk-...`). +#[derive(Debug, Clone, Deserialize)] +pub struct BalanceRefundResponse { + /// Cashu token encoding the refunded sats. + pub token: String, + /// Refunded amount. Routstr instances disagree on the unit and the + /// JSON type: + /// - `routstr.otrta.me` returns `"sats": "976"` (string) + /// - upstream `api.routstr.com` returns `"msats": 450000` (integer) + /// + /// `RefundAmount` accepts both shapes and normalises to integer sats + /// via [`RefundAmount::as_sats`]. + #[serde(flatten)] + pub amount: RefundAmount, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum RefundAmount { + Sats { + #[serde(deserialize_with = "deserialize_int_or_string")] + sats: i64, + }, + MSats { + #[serde(deserialize_with = "deserialize_int_or_string")] + msats: i64, + }, +} + +impl RefundAmount { + pub fn as_sats(&self) -> i64 { + match self { + RefundAmount::Sats { sats } => *sats, + RefundAmount::MSats { msats } => msats / 1000, + } + } +} + +/// Accept an integer in either JSON-number or quoted-string form. Routstr's +/// `/v1/balance/refund` returns the amount as a stringified number on some +/// instances (`{"sats": "976"}`). +fn deserialize_int_or_string<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + use std::fmt; + + struct V; + impl<'de> Visitor<'de> for V { + type Value = i64; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("an integer or numeric string") + } + + fn visit_i64(self, v: i64) -> Result { + Ok(v) + } + fn visit_u64(self, v: u64) -> Result { + Ok(v as i64) + } + fn visit_str(self, v: &str) -> Result { + v.parse::().map_err(de::Error::custom) + } + fn visit_string(self, v: String) -> Result { + self.visit_str(&v) + } + } + + deserializer.deserialize_any(V) +} + +fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .context("failed to build HTTP client for Routstr balance API") +} + +fn balance_url(host: &str, path: &str) -> Result { + let mut u = url::Url::parse(host).with_context(|| format!("invalid Routstr URL: {host}"))?; + if !u.path().ends_with('/') { + let p = format!("{}/", u.path()); + u.set_path(&p); + } + Ok(u.join(path)?) +} + +/// `GET /v1/balance/create?initial_balance_token=` +/// +/// Exchanges a Cashu token for a fresh `sk-...` bearer with a tracked +/// balance on the proxy. Used both for first-time setup and for +/// re-funding a profile whose previous key was refunded. +pub async fn create_balance( + host: &str, + cashu_token: &str, +) -> Result { + let mut url = balance_url(host, "v1/balance/create").map_err(ProviderApiError::Request)?; + url.query_pairs_mut() + .append_pair("initial_balance_token", cashu_token); + let client = http_client().map_err(ProviderApiError::Request)?; + let response: reqwest::Response = client + .get(url) + .send() + .await + .map_err(|e| ProviderApiError::Request(anyhow!(e)))?; + parse_response(response).await +} + +/// `POST /v1/balance/topup?cashu_token=` with `Authorization: Bearer sk-...`. +/// +/// Adds the value of `cashu_token` to the existing api_key's balance. +/// Returns the proxy's response (typically the new balance, but the schema +/// is undocumented at the time of writing — we surface the raw JSON). +pub async fn topup_balance( + host: &str, + api_key: &str, + cashu_token: &str, +) -> Result { + let mut url = balance_url(host, "v1/balance/topup").map_err(ProviderApiError::Request)?; + url.query_pairs_mut() + .append_pair("cashu_token", cashu_token); + let client = http_client().map_err(ProviderApiError::Request)?; + let response: reqwest::Response = client + .post(url) + .header("Authorization", format!("Bearer {api_key}")) + .send() + .await + .map_err(|e| ProviderApiError::Request(anyhow!(e)))?; + parse_response(response).await +} + +/// `POST /v1/balance/refund` with `Authorization: Bearer sk-...`. +/// +/// Returns a Cashu token encoding all unspent sats associated with this +/// api_key. After a successful refund the api_key's balance is zero (and in +/// practice the key is consumed — call `create_balance` again with the +/// returned token to start a fresh tracked balance). +pub async fn refund_balance( + host: &str, + api_key: &str, +) -> Result { + let url = balance_url(host, "v1/balance/refund").map_err(ProviderApiError::Request)?; + let client = http_client().map_err(ProviderApiError::Request)?; + let response: reqwest::Response = client + .post(url) + .header("Authorization", format!("Bearer {api_key}")) + .send() + .await + .map_err(|e| ProviderApiError::Request(anyhow!(e)))?; + parse_response(response).await +} + +/// `GET /v1/balance/info` with `Authorization: Bearer sk-...`. +pub async fn balance_info( + host: &str, + api_key: &str, +) -> Result { + let url = balance_url(host, "v1/balance/info").map_err(ProviderApiError::Request)?; + let client = http_client().map_err(ProviderApiError::Request)?; + let response: reqwest::Response = client + .get(url) + .header("Authorization", format!("Bearer {api_key}")) + .send() + .await + .map_err(|e| ProviderApiError::Request(anyhow!(e)))?; + parse_response(response).await +} + +/// Errors surfaced by the Routstr balance-API client. Distinguishes +/// network/IO problems from proxy-reported errors so callers can decide +/// whether to retry. +#[derive(thiserror::Error, Debug)] +pub enum ProviderApiError { + /// Underlying request setup or transport error (network down, bad URL, + /// unparseable response body, …). + #[error("routstr balance api request failed: {0}")] + Request(#[from] anyhow::Error), + /// The proxy returned a non-2xx status with an error body. + #[error("routstr balance api returned HTTP {status}: {message}")] + Proxy { + status: reqwest::StatusCode, + message: String, + }, +} + +async fn parse_response Deserialize<'de>>( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if !status.is_success() { + // Try to extract an error message from the standard envelopes: + // {"detail": "..."} or {"detail": {"error": {"message": "..."}}} + // or {"error": {"message": "..."}} + let payload = serde_json::from_str::(&body).ok(); + let message = payload + .as_ref() + .and_then(|p| { + p.get("detail") + .and_then(|d| { + d.as_str() + .map(String::from) + .or_else(|| d.get("error").and_then(extract_message)) + }) + .or_else(|| p.get("error").and_then(extract_message)) + }) + .unwrap_or_else(|| body.clone()); + return Err(ProviderApiError::Proxy { status, message }); + } + + serde_json::from_str::(&body) + .with_context(|| format!("decoding {body}")) + .map_err(ProviderApiError::Request) +} + +fn extract_message(v: &serde_json::Value) -> Option { + v.get("message").and_then(|m| m.as_str().map(String::from)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn refund_amount_normalises_msats_and_sats() { + let sats: BalanceRefundResponse = + serde_json::from_value(json!({"token": "x", "sats": 1000})).unwrap(); + assert_eq!(sats.amount.as_sats(), 1000); + + let msats: BalanceRefundResponse = + serde_json::from_value(json!({"token": "x", "msats": 1234567})).unwrap(); + assert_eq!(msats.amount.as_sats(), 1234); + } + + #[test] + fn refund_amount_accepts_stringified_sats() { + // routstr.otrta.me returns `"sats": "976"` (string), which broke + // refund parsing in our first end-to-end smoke test and silently + // dropped the Cashu token. Make sure both string and int forms + // survive. + let parsed: BalanceRefundResponse = + serde_json::from_value(json!({"token": "cashuB...", "sats": "976"})).unwrap(); + assert_eq!(parsed.amount.as_sats(), 976); + + let parsed: BalanceRefundResponse = + serde_json::from_value(json!({"token": "cashuB...", "msats": "1500"})).unwrap(); + assert_eq!(parsed.amount.as_sats(), 1); + } + + #[test] + fn balance_create_response_parses_real_routstr_payload() { + let raw = json!({ + "api_key": "sk-deadbeef", + "balance": 1000000 + }); + let parsed: BalanceCreateResponse = serde_json::from_value(raw).unwrap(); + assert_eq!(parsed.api_key, "sk-deadbeef"); + assert_eq!(parsed.balance, 1000000); + } + + #[test] + fn balance_info_response_tolerates_extra_fields() { + let raw = json!({ + "api_key": "sk-x", + "balance": 500, + "reserved": 0, + "is_child": false, + "parent_key": null, + "total_requests": 3, + "total_spent": 17, + "balance_limit": null, + "balance_limit_reset": null, + "validity_date": null + }); + let parsed: BalanceInfoResponse = serde_json::from_value(raw).unwrap(); + assert_eq!(parsed.balance, 500); + assert_eq!(parsed.total_requests, 3); + assert_eq!(parsed.total_spent, 17); + } +} diff --git a/documentation/docs/guides/routstr.md b/documentation/docs/guides/routstr.md new file mode 100644 index 000000000000..58234422fc02 --- /dev/null +++ b/documentation/docs/guides/routstr.md @@ -0,0 +1,159 @@ +--- +sidebar_position: 50 +title: Routstr (pay-per-request via Cashu) +sidebar_label: Routstr +description: Pay for LLM requests in Bitcoin sats through any Routstr proxy, with a local Cashu wallet +--- + +# Routstr + +[Routstr](https://routstr.com/docs) is an OpenAI-compatible LLM proxy that +bills per request in Bitcoin sats via [Cashu](https://cashu.space/) ecash +tokens. goose ships: + +- A `routstr` provider that talks to any Routstr instance. +- A local Cashu wallet (`goose wallet`) that holds your sats. Built on + [`cdk`](https://github.com/cashubtc/cdk), the Rust Cashu development kit. +- A `goose configure → Configure Providers → Routstr` flow that wires + everything together — picking a Routstr URL, exchanging some local sats + for a per-host `sk-...` API key, and refunding the proxy back to your + wallet when you switch hosts. + +## Two layers of state + +There are two places sats can live: + +1. **Local Cashu wallet** at `~/.cdk-gooose/`. One BIP-39 seed + (`~/.cdk-gooose/seed`), one redb proof store + (`~/.cdk-gooose/cdk-goose.redb`), one mint + (`https://mint.minibits.cash/Bitcoin`, hardcoded). This is your + source of truth for sats. Funded with `goose wallet topup + `. + +2. **Per-profile balance on a Routstr instance**, identified by an + `sk-...` API key the proxy issues in exchange for a Cashu token. Each + profile is `{url, api_key}` stored in + `~/.config/goose/config.yaml` under `ROUTSTR_PROFILES.`. The + active profile is named by `ROUTSTR_ACTIVE`. + +`goose configure → Routstr → URL` moves sats between these two layers +automatically (refund the old profile, fund the new one) — you don't +need to touch `ROUTSTR_PROFILES` by hand. + +## Quick start + +```bash +# 1. Receive a Cashu token into the local wallet. Get the token from any +# wallet minting against Minibits (the Minibits app, cashu.me, etc.). +goose wallet topup cashuB... +goose wallet balance +# → local wallet: 2000 sats + +# 2. Pick a Routstr instance and a model. +goose configure +# → Configure Providers → Routstr +# → Routstr URL: https://api.routstr.com (or any Routstr instance) +# → pick a model from the proxy's catalogue + +# 3. Chat. +goose run --provider routstr --model anthropic/claude-sonnet-4 \ + --text "Hi from a paid Cashu wallet" +``` + +The configure URL prompt: + +- **Same URL as the active profile, no api_key yet** — auto-funds the + active profile from the local wallet (default 2000 sats, capped at + your local balance). +- **URL matches a different existing profile** — switches to it: refund + the previously active profile back into the local wallet, auto-fund + the new one. +- **New URL** — refunds the previously active profile, creates a profile + named `default` pointing at the new URL, makes it active, and auto-funds + it. + +After the URL prompt, configure fetches `/v1/models` and +presents an interactive picker against every model the proxy serves. +Type to filter (`claude`, `gemini`, `llama`, …). + +## Refilling and switching + +```bash +# Refill the local wallet, then re-fund the active profile: +goose wallet topup +goose configure # → Routstr → same URL → auto-fund + +# Switch hosts (refunds the old profile, funds the new one): +goose configure # → Routstr → + +# Drain the local wallet to a Cashu token (e.g. to migrate machines): +goose wallet withdraw [N] +``` + +`goose wallet balance/topup/withdraw` also drain a queue of failed +refunds the next time they run (see [Offline refunds](#offline-refunds)). + +## Insufficient balance + +If a chat request returns +`Insufficient balance: sats required`, that's the model's per-request +**reservation minimum** on the proxy — not your debt. The model needs +`` sats reserved up-front to start the request; refund what's left +after the response. Some models (e.g. `gpt-5.5-openai`) reserve a few +thousand sats per call. + +Top up to at least that much: + +```bash +goose wallet topup cashuB... # local +goose configure # → Routstr → same URL → auto-tops the active profile +``` + +…or pick a cheaper model in the picker (`claude-haiku-4.5`, +`glm-5.1`, `gemini-3.1-flash-lite-preview`). + +## Offline refunds + +When `goose configure → Routstr → ` tries to refund the +previously active profile and the proxy is unreachable (offline, +rate-limited, etc.), the `(url, api_key)` pair is appended to +`~/.cdk-gooose/pending-refunds.json` instead of being lost. + +The next `goose wallet balance/topup/withdraw` retries every queued +refund, removes successes, keeps failures for next time: + +```text +$ goose wallet topup cashuB... +↻ draining 1 pending Routstr refund(s)… + ✓ refunded 976 sats from https://routstr.otrta.me +Received 1000 sats. Local wallet balance: 1976 sats. +``` + +You don't need to do anything special — just run any wallet command +after the proxy comes back. + +## Where things live + +| Path | Holds | +| --- | --- | +| `~/.cdk-gooose/seed` | BIP-39 mnemonic for the local wallet. **Back this up.** | +| `~/.cdk-gooose/cdk-goose.redb` | Local Cashu proofs. | +| `~/.cdk-gooose/pending-refunds.json` | Queue of `(url, sk-...)` pairs whose refund POST failed. Drained on the next `goose wallet *` call. | +| `~/.config/goose/config.yaml` (`ROUTSTR_PROFILES`) | Per-profile `{url, api_key}` map. The `api_key` is `sk-...` issued by the proxy, not a Cashu token. | +| `~/.config/goose/config.yaml` (`ROUTSTR_ACTIVE`) | Active profile name. | + +## Limitations + +- **Minibits is the only supported mint** today — `https://mint.minibits.cash/Bitcoin`, + hardcoded. Tokens minted at any other mint won't receive into the + local wallet, and Routstr instances trusting a different mint will + reject keys created with our wallet's tokens. +- **`sk-...` keys are stored in plaintext** in + `~/.config/goose/config.yaml`, not the OS keychain. The seed at + `~/.cdk-gooose/seed` is similarly plaintext. File permissions are + your only barrier on a shared machine. +- **Tool-use coverage varies by model.** The configure flow's final + test step calls the picked model with a tool spec; some models in the + large proxy catalogue (e.g. `aion-1.0`) don't accept `tools=[]` and + the test fails. Re-run configure and pick a tool-aware model — the + Claude / Gemini / GLM / GPT-5 families are all known to work.