Feat routstr - #9095
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 475040f3a2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| 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))?; |
There was a problem hiding this comment.
Preserve Cashu token when profile top-up fails
When auto-topup reaches this withdrawal and the subsequent /v1/balance/create or /v1/balance/topup request fails because the Routstr host is down or rejects the token, the local wallet has already confirmed the send but the generated Cashu token is only held in memory and is then dropped. That leaves the user's sats neither in the local wallet nor in the Routstr balance, with no printed token or pending queue entry to recover them; persist/restore the token or only finalize the send once the proxy accepts it.
Useful? React with 👍 / 👎.
| 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)) |
There was a problem hiding this comment.
Preserve refund token before redeeming locally
If refund_balance succeeds but receive_into_wallet fails during a profile switch, this helper returns an error after the proxy has already returned and likely consumed/zeroed the old api_key; the caller then queues that stale key while the actual Cashu token in resp.token is dropped. In any wallet receive/network/database failure after a successful refund, retrying the queued key may not recover the funds, so the returned token needs to be persisted or surfaced before attempting local redemption.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e376a0b6c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| 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()); |
There was a problem hiding this comment.
Avoid overwriting a funded default profile
When the active Routstr profile is not default and the existing default profile still has an api_key, entering a new URL overwrites that stored profile without refunding or queuing its key. That strands any sats tracked under the old default key because only the active profile is refunded above this block; preserve/refund the existing default entry before replacing it or choose a fresh profile name.
Useful? React with 👍 / 👎.
| let _ = cliclack::log::info(format!( | ||
| "routstr profile {active:?} already points at {entered}; nothing to do." | ||
| )); | ||
| return Ok(()); |
There was a problem hiding this comment.
Refill funded profiles on same-URL configure
If the active profile already has an api_key but its proxy balance has been drained below the auto-topup target, re-running goose configure with the same URL hits this branch and returns without calling autotopup_after_switch. I checked cli.rs and there is no exposed goose routstr topup command, so after users add sats to the local wallet they still have no normal path to refill the active proxy balance unless they switch profiles or clear the key manually.
Useful? React with 👍 / 👎.
| Err(_) => { | ||
| let mnemonic = Mnemonic::generate(12)?; | ||
| tracing::info!("Creating new Cashu wallet seed"); | ||
| fs::write(&seed_path, mnemonic.to_string())?; |
There was a problem hiding this comment.
Restrict the wallet seed file permissions
On first wallet use this writes the Cashu mnemonic with fs::write, which creates the file using the process umask (commonly 0644). On multi-user machines or shared home directories, another local user can read ~/.cdk-gooose/seed and recover the wallet funds; create the seed with owner-only permissions before writing it.
Useful? React with 👍 / 👎.
|
Thanks for the contribution. I think the scope of this is a bit large to land as it is. The routstr provider could be landed as a declarative OpenAI-compatible provider (without the balance/wallet/pending-refunds stuff) and then that could be used with an API key provided externally (after the payment part has been handled outside of goose). Integrating a wallet could then be considered/reviewed separately. |
|
we have always wanted a wallet abstraction for things to plug in as (well I have) so this could be really cool, I like it. |
|
I merged #9175 We can follow up for pluggable wallets. I would be interested to see it attempted as an https://open-plugins.com which we're landing support for in pieces. Perhaps a skill + a bin cli? This way goose can make full use of it, but it doesn't need to be in goose. |
Summary
Add the
routstrprovider so goose can pay for LLM requests in Bitcoinsats via Routstr.
What this PR adds:
routstrprovider (crates/goose/src/providers/routstr.rs) —OpenAI-compatible chat-completions client. Anthropic prompt-caching
for
anthropic/*models. Surfaces the proxy'scode = "insufficient_balance"(HTTP 400 or 402,erroror nesteddetail.error, sats or stringified mSats) asProviderError::InsufficientBalance(<sats>)so the user sees theactual reservation needed.
crates/goose/src/providers/routstr_api.rs) —create_balance/topup_balance/refund_balance/balance_infofor the proxy's
/v1/balance/*endpoints, plus the per-profileconfig schema (
ROUTSTR_PROFILES.<name>.{url, api_key}+ROUTSTR_ACTIVE). Theapi_keyis thesk-...Routstr issues from afunded Cashu token, not the Cashu token itself.
crates/goose-cli/src/commands/wallet.rs) —one shared CDK wallet at
~/.cdk-gooose/.goose wallet topup | balance | withdraw. Thebalancesubcommand also calls/v1/balance/infoon the active profile and prints the proxy'stracked balance + spent / request count alongside the local sats.
(
crates/goose-cli/src/commands/configure.rs+crates/goose-cli/src/commands/routstr.rs) —goose configure → Configure Providers → Routstrasks for a URL and reconciles it withthe profile system: same URL → auto-fund from local; matching
existing profile → switch (refund + auto-topup); new URL → create
default(refund + auto-topup). Model picker fetches the fullcatalogue via
/v1/models(canonical-registry filter disabled —Routstr is an aggregator, all models should be visible).
(
crates/goose-cli/src/commands/routstr_pending.rs) — a JSON fileat
~/.cdk-gooose/pending-refunds.jsonthat catches(url, api_key)pairs whose refund POST failed during a switch. The nextgoose wallet topup | balance | withdrawcall automatically retriesevery queued entry, so an offline / unreachable proxy at switch time
can't strand sats.
documentation/docs/guides/routstr.md) coveringsetup, switching, refilling, the
pending-refunds.jsonqueue,filesystem layout, and limitations (Minibits-only mint, plaintext
sk-storage, tool-use coverage varies by model).The branch preserves the original cdk-wallet prototype's commit
history from thesimplekid.
Testing
12 unit tests in
providers::routstrandproviders::routstr_apicovering both
/v1/modelsschemas (minimal OpenAI-compat + richapi.routstr.com-style with extra pricing fields), both
insufficient-balance error envelopes (
erroranddetail.error,sats and stringified mSats),
RefundAmount::as_satsfor both unitforms,
BalanceCreateResponse/BalanceInfoResponseparsing withextra fields, and the
require_api_keygate.End-to-end smoke against
https://routstr.otrta.me: drained a2000-sat Cashu top-up across 468 chat requests via four models
(claude-sonnet-4.5, claude-opus-4.6-fast, claude-haiku-4.5,
glm-5.1), exercising the full create → topup → chat → refund cycle
including the InsufficientBalance fast-fail. 99.15% of sats spent
on actual chat traffic; the residual ~17 sats refunded back to the
local wallet via
POST /v1/balance/refund. Pending-refund queueverified by injecting a
(url, sk-)pair pointing at anunreachable host, confirming a
goose wallet balancecall retriedand kept the entry queued, then injecting a real-but-low-balance
sk-and confirming the next call drained it.Build matrix:
cargo build -p goose-cli --no-default-features --features "code-mode,aws-providers,telemetry,otel,rustls-tls"passes on the goose-CLI feature set used by CI.
Related Issues
Relates to #3113
Discussion: #3113
(Please file a tracking issue or discussion before merging — the
maintainers' contribution guide asks large changes to start with a
discussion to align on direction.)
Screenshots/Demos (for UX changes)
Not applicable — CLI-only feature, no UI changes.
Notes