Skip to content

Feat routstr - #9095

Closed
9qeklajc wants to merge 16 commits into
aaif-goose:mainfrom
9qeklajc:feat-routstr
Closed

Feat routstr#9095
9qeklajc wants to merge 16 commits into
aaif-goose:mainfrom
9qeklajc:feat-routstr

Conversation

@9qeklajc

@9qeklajc 9qeklajc commented May 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Add the routstr provider so goose can pay for LLM requests in Bitcoin
sats via Routstr.

What this PR adds:

  • routstr provider (crates/goose/src/providers/routstr.rs) —
    OpenAI-compatible chat-completions client. Anthropic prompt-caching
    for anthropic/* models. Surfaces the proxy's
    code = "insufficient_balance" (HTTP 400 or 402, error or nested
    detail.error, sats or stringified mSats) as
    ProviderError::InsufficientBalance(<sats>) so the user sees the
    actual reservation needed.
  • Routstr balance API client (crates/goose/src/providers/routstr_api.rs) —
    create_balance / topup_balance / refund_balance / balance_info
    for the proxy's /v1/balance/* endpoints, plus the per-profile
    config schema (ROUTSTR_PROFILES.<name>.{url, api_key} +
    ROUTSTR_ACTIVE). The api_key is the sk-... Routstr issues from a
    funded Cashu token, not the Cashu token itself.
  • Local Cashu wallet (crates/goose-cli/src/commands/wallet.rs) —
    one shared CDK wallet at ~/.cdk-gooose/. goose wallet topup | balance | withdraw. The balance subcommand also calls
    /v1/balance/info on the active profile and prints the proxy's
    tracked balance + spent / request count alongside the local sats.
  • Configure-time profile management
    (crates/goose-cli/src/commands/configure.rs +
    crates/goose-cli/src/commands/routstr.rs) — goose configure → Configure Providers → Routstr asks for a URL and reconciles it with
    the 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 full
    catalogue via /v1/models (canonical-registry filter disabled —
    Routstr is an aggregator, all models should be visible).
  • Pending-refund queue
    (crates/goose-cli/src/commands/routstr_pending.rs) — a JSON file
    at ~/.cdk-gooose/pending-refunds.json that catches (url, api_key) pairs whose refund POST failed during a switch. The next
    goose wallet topup | balance | withdraw call automatically retries
    every queued entry, so an offline / unreachable proxy at switch time
    can't strand sats.
  • One user guide (documentation/docs/guides/routstr.md) covering
    setup, switching, refilling, the pending-refunds.json queue,
    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::routstr and providers::routstr_api
    covering both /v1/models schemas (minimal OpenAI-compat + rich
    api.routstr.com-style with extra pricing fields), both
    insufficient-balance error envelopes (error and detail.error,
    sats and stringified mSats), RefundAmount::as_sats for both unit
    forms, BalanceCreateResponse / BalanceInfoResponse parsing with
    extra fields, and the require_api_key gate.

    cargo test -p goose --no-default-features --features rustls-tls \
      --lib providers::routstr providers::routstr_api
    → 12 passed
    
  • End-to-end smoke against https://routstr.otrta.me: drained a
    2000-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 queue
    verified by injecting a (url, sk-) pair pointing at an
    unreachable host, confirming a goose wallet balance call retried
    and 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +327 to +333
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))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +275 to +280
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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +174 to +177
let _ = cliclack::log::info(format!(
"routstr profile {active:?} already points at {entered}; nothing to do."
));
return Ok(());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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())?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@jh-block

Copy link
Copy Markdown
Collaborator

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.

@9qeklajc

Copy link
Copy Markdown
Contributor Author

@jh-block yeah sure #9175

@DOsinga DOsinga added the needs_human label to set when a robot looks at a PR and can't handle it label May 12, 2026
@michaelneale

Copy link
Copy Markdown
Collaborator

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.

@alexhancock

Copy link
Copy Markdown
Collaborator

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs_human label to set when a robot looks at a PR and can't handle it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants