From 5b4ddf79c4acd65827e60f1e49535d3af4442b4c Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 9 Sep 2026 14:49:25 -0500 Subject: [PATCH 1/5] chore(docs): remove shipped planning docs, refresh CLI reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the repo's non-source documentation and ad-hoc scripts. Deleted (work already shipped, or scratch files): - IMPLEMENTATION.md — the ~/.cocod → ~/.routstrd/wallet migration plan; shipped as src/daemon/wallet/{migration,paths}.ts. - src/TUI refactor.md — plan to move src/cli/usage-tui.ts into src/tui/; done, src/cli/ no longer exists. - v1-messages-format-report.md — the Messages-API passthrough bug it describes is fixed; http/index.ts now forwards path: url.pathname. - routstr-cost-logging.md — note about the routstr proxy's cost fields vs pi-ai; not about this codebase. - refund.js, refund_new.js — one-off scripts with hardcoded Cashu tokens. - test_box.ts, test_split_box.ts, test_split_box2.ts — manual renderBox eyeball checks that asserted nothing and bun test never ran. Moved: - COCO-2.0.0-MIGRATION-PLAN.md → docs/plans/coco-2.0.0-migration.md, with a status header. This one is genuinely unexecuted: package.json is still on coco-core 1.0.1 / coco-cashu-core 1.1.2-rc.50, and its NPC compatibility gate is unresolved. Added: - src/tui/usage/render.test.ts — real assertions for what the three deleted scratch scripts checked by eye (box width with and without a title, ANSI padding, side-by-side composition including unequal heights). renderBox had no coverage before. Updated: - SKILL.md — ships in the npm package as the CLI reference but was missing ~15 commands (wallet doctor/cleanup/npc, nwc and auto-refill, history, ping, providers reviews, service install/uninstall/logs, daemon, local, update, refund, top, send/receive shortcuts). Also dropped the claim that onboard installs cocod (the wallet is in-process), removed two orphaned tables misfiled under `routstrd refresh`, and completed the config and env-var tables. - README.md — same cocod correction, points at SKILL.md for the full command reference, fixes the stale Project Structure tree and the POST / endpoint description. - SECURITY.md — drop the stray `## SECURITY.md` line above the real heading. - package.json — add a `smoke` script so scripts/smoke/chat-completions.sh stops looking orphaned. Deliberately not wired into CI: it needs a funded API key. Note: the Cashu tokens in the deleted refund scripts remain in git history. They date from March/April 2026 and were being POSTed to refund endpoints, so they are almost certainly spent — flagging rather than rewriting history. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UW5RBn7pxSuHbDQjtHfQVW --- IMPLEMENTATION.md | 253 --------------- README.md | 47 ++- SECURITY.md | 1 - SKILL.md | 288 ++++++++++++++---- .../plans/coco-2.0.0-migration.md | 6 + package.json | 1 + refund.js | 33 -- refund_new.js | 20 -- routstr-cost-logging.md | 71 ----- src/TUI refactor.md | 113 ------- src/tui/usage/render.test.ts | 80 +++++ test_box.ts | 15 - test_split_box.ts | 17 -- test_split_box2.ts | 23 -- v1-messages-format-report.md | 223 -------------- 15 files changed, 357 insertions(+), 834 deletions(-) delete mode 100644 IMPLEMENTATION.md rename COCO-2.0.0-MIGRATION-PLAN.md => docs/plans/coco-2.0.0-migration.md (98%) delete mode 100644 refund.js delete mode 100644 refund_new.js delete mode 100644 routstr-cost-logging.md delete mode 100644 src/TUI refactor.md create mode 100644 src/tui/usage/render.test.ts delete mode 100644 test_box.ts delete mode 100644 test_split_box.ts delete mode 100644 test_split_box2.ts delete mode 100644 v1-messages-format-report.md diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md deleted file mode 100644 index 9e1f571..0000000 --- a/IMPLEMENTATION.md +++ /dev/null @@ -1,253 +0,0 @@ -# Migrate wallet storage from `~/.cocod` to `~/.routstrd/wallet` - -Task: [`routstrd-migrate-cocod-to-routstrd-files`](https://github.com/nodestrich/routstrd) -Event ID: `2f420644005154c5b106017c4a753854bb2660ba834cde7e6e267532cb5448e9` -Priority: 997 · Status: open - ---- - -## Baseline - -This plan is based on `coco-integration` at `86ac688` (after the default-mint, NPC/npubx.cash, and Windows-support changes). - -The task branch is currently based on `04f8c22`; rebase it onto the latest `coco-integration` before implementing this plan. - -## Goal - -Move the in-process Cashu wallet's persistent data from: - -```text -~/.cocod/config.json -~/.cocod/coco.db -``` - -to: - -```text -~/.routstrd/wallet/config.json -~/.routstrd/wallet/coco.db -``` - -The migration must preserve the mnemonic, proofs, default mint, and NPC identity. Existing users must not accidentally get a new wallet, and `cocod` and routstrd must never open the same database concurrently. - -Fresh installs must use `~/.routstrd/wallet` immediately. Existing installs must be migrated automatically and safely; merely continuing to run from `~/.cocod` is not sufficient for this task. - ---- - -## Important corrections to the previous plan - -### 1. Do not rename or migrate `cocod.sock` - -The in-process `coco-core` wallet does not expose a Unix socket. `cocod.sock` belongs to the legacy external `cocod` daemon and is used only to determine whether that daemon is still alive. - -There should therefore be no `wallet.sock`. Do not copy a socket inode into the new directory. - -### 2. Keep legacy-process paths separate from wallet-data paths - -On the current base, `src/daemon/wallet/coco-client.ts` derives all of these from one `CONFIG_DIR`: - -- wallet config and database; -- legacy cocod socket; -- legacy cocod PID/process lock. - -That coupling must be removed. After migration: - -- wallet data comes from `~/.routstrd/wallet`; -- probes and shutdown logic for an external legacy cocod continue to use `~/.cocod/cocod.sock` and `~/.cocod/cocod.pid`; -- routstrd uses `~/.routstrd/wallet/wallet.pid` as the in-process wallet lock. - -While routstrd owns the migrated wallet, it should also retain the existing legacy-cocod exclusion mechanism (claiming the legacy `cocod.pid`) so an old cocod cannot be started against a stale or partially migrated legacy wallet. Acquisition and release of both locks must be rollback-safe. - -### 3. Do not rename `cocodPath` - -`RoutstrdConfig.cocodPath` is an executable path for the external compatibility client, not a wallet storage path. Renaming it to `walletPath` would change its meaning and would not help this migration. - -The current daemon creates `createCocoClient()` directly and injects it into `createWalletAdapter()`, so `cocodPath` is effectively bypassed on the normal in-process path. Cleanup or removal of that compatibility setting is a separate task. - -### 4. Do not implement fallback as the steady state - -A resolver that permanently falls back to `~/.cocod` does not move the data. Legacy detection is needed to initiate migration, but successful startup should resolve to the canonical directory afterward. - -### 5. Account for the updated base - -The latest `coco-integration` adds: - -- `defaultMintUrl` persistence in the wallet's `config.json`; -- the NPC plugin, whose Nostr identity is derived from the same mnemonic; -- `src/daemon/wallet/coco-client.npc.test.ts`; -- Windows support and `USERPROFILE` fallback. - -Migration must preserve the complete config JSON rather than reconstructing selected fields. Path construction must use `path.join` rather than hard-coded `/` separators. - ---- - -## Current runtime path references on the latest base - -### Wallet data and legacy process coordination - -| File | Current responsibility | Required change | -|---|---|---| -| `src/daemon/wallet/coco-client.ts` | Uses one `.cocod` directory for config, DB, socket, and PID; owns the in-process wallet and legacy-cocod guard | Split canonical data paths from legacy process paths; migrate before opening the DB; use `wallet.pid` for the in-process lock | -| `src/daemon/wallet/cocod-client.ts` | External cocod compatibility client; defaults to `.cocod/cocod.sock` | Keep legacy defaults and clarify that they are external-cocod paths | -| `src/cli.ts` | Initializes `.cocod`; two restart paths wait on `.cocod/cocod.pid` | Initialize/migrate the canonical wallet and wait on `wallet.pid` | -| `src/utils/config.ts` | Defines `~/.routstrd`, but no wallet subdirectory | Export canonical and legacy wallet/process path helpers or constants | -| `src/daemon/index.ts` | Calls `createCocoClient()` before building the adapter | Ensure migration occurs before the client opens the DB | - -### Tests and documentation - -| File | Required update | -|---|---| -| `src/cli.test.ts` | Change fresh-wallet expectations and add migration coverage | -| `src/daemon/wallet/coco-client.test.ts` | Update data/lock paths and add migration/dual-lock tests | -| `src/daemon/wallet/coco-client.npc.test.ts` | Use the canonical wallet layout in fixtures; verify migrated mnemonic/config still drives NPC identity | -| `README.md` | Document the new location and migration behavior; retain legacy cocod terminology only where discussing compatibility | -| `SKILL.md` | Update wallet storage and environment-variable documentation | - -The fixture `src/daemon/wallet/fixtures/cocod-0.0.24-wallet.db.gz` keeps its name because it records the fixture's provenance. - ---- - -## Target path model - -Define the path model in one dependency-light module (for example `src/daemon/wallet/paths.ts`, or in `src/utils/config.ts` if that does not introduce a cycle): - -```text -ROUTSTRD config root process.env.ROUTSTRD_DIR || ~/.routstrd -canonical wallet directory process.env.ROUTSTRD_WALLET_DIR || /wallet -canonical wallet config /config.json -canonical wallet database /coco.db -canonical wallet lock process.env.ROUTSTRD_WALLET_PID || /wallet.pid - -legacy cocod directory process.env.COCOD_DIR || ~/.cocod -legacy cocod socket process.env.COCOD_SOCKET || /cocod.sock -legacy cocod PID process.env.COCOD_PID || /cocod.pid -``` - -Use `HOME` with `USERPROFILE` fallback, matching the updated Windows-support base. Use functions or injectable path objects where tests need to change environment variables after module import. - -`COCOD_DIR`, `COCOD_SOCKET`, and `COCOD_PID` remain compatibility controls for locating an old external cocod. They must not redirect the new in-process wallet away from `~/.routstrd/wallet`. `ROUTSTRD_WALLET_DIR` is the new explicit data-directory override. - ---- - -## Implementation plan - -### Phase 1 — Centralize and separate paths - -1. Add the canonical wallet and legacy cocod path definitions above. -2. Remove the local `.cocod` path construction from `coco-client.ts` and `cli.ts`. -3. Change `CreateCocoClientOptions` so tests/tooling can independently override: - - wallet data directory; - - wallet lock path; - - legacy cocod socket and PID paths. -4. Keep `cocod-client.ts` pointed at the legacy socket. Do not make it import a resolver that prefers the new wallet directory. - -### Phase 2 — Add a safe automatic migration primitive - -Add an idempotent `migrateLegacyWallet()` helper with injectable paths/filesystem operations for tests. - -Preconditions and behavior: - -1. If the canonical wallet already contains both `config.json` and `coco.db`, return `already-current` and do not touch legacy data. -2. If neither canonical nor legacy wallet data exists, return `fresh`; initialization will create the canonical directory. -3. If only the legacy wallet exists: - - first verify that legacy cocod is not running using the existing PID and socket guard; - - create a private staging directory under `~/.routstrd` with mode `0700`; - - copy the complete `config.json` and `coco.db` into staging without parsing or rewriting them; - - apply `0600` to both files; - - validate that the staged files exist and have the expected byte sizes; - - atomically rename the staged directory to `wallet`; - - only after the canonical directory is committed, remove the legacy `config.json` and `coco.db`; - - never copy `cocod.sock` or `cocod.pid`. -4. If canonical storage is partial, legacy storage is partial, both contain wallet data, or files conflict, stop with an actionable error. Never merge databases and never generate a new mnemonic over an ambiguous state. -5. Clean up an uncommitted staging directory after failure. A committed canonical wallet remains authoritative if cleanup of the old files fails; report that cleanup warning clearly. - -The migration should preserve unknown config fields, including `defaultMintUrl`, and preserve the database byte-for-byte. This also preserves the NPC identity because that identity is derived from the mnemonic. - -A staging copy plus atomic directory rename is preferred over two independent file renames: a crash must not expose a half-created canonical wallet. Copying also permits a clear rollback before commit and supports a legacy directory located on another filesystem through `COCOD_DIR`. - -### Phase 3 — Run migration from every wallet-opening path - -1. **CLI onboarding/init:** run migration before `initializeWallet()`. Only initialize a new mnemonic when migration reports `fresh`. -2. **Daemon direct startup:** run migration before `createCocoClient()` opens `coco.db`. This covers users who invoke the daemon without rerunning onboarding. -3. **Start/restart/update/service paths:** retain `stopLegacyCocod()` before migration/startup where those paths already stop legacy cocod. Direct daemon startup should refuse with the existing actionable error rather than silently running two wallet engines. -4. Print a concise success message showing old and new directories, but never print config contents or the mnemonic during migration. - -A separate `routstrd wallet migrate` command is optional as a manual recovery/preview entry point, but it must call the same migration primitive. It is not a substitute for automatic migration. - -### Phase 4 — Separate process locking - -Refactor the current `claimLegacyCocodPidFile()` behavior into explicit responsibilities: - -1. Claim `wallet.pid` for the lifetime of the in-process wallet to prevent two routstrd wallet instances from opening `coco.db`. -2. Continue claiming the legacy `cocod.pid` while routstrd is active, after verifying no real cocod owns it, to prevent an old cocod from starting. -3. If either claim fails, release any claim already acquired before returning the error. -4. On `CocodClient.dispose()`, startup failure, and daemon shutdown, release only PID files still owned by the current process. -5. Keep `stopLegacyCocod()` and `assertLegacyCocodNotRunning()` operating only on legacy cocod paths. -6. Update both PID-release waits in `src/cli.ts` (`restartDaemonsAfterUpdate` and `restart`) to wait for the canonical `wallet.pid` rather than `.cocod/cocod.pid`. - -There is no canonical wallet socket. - -### Phase 5 — Update initialization and client defaults - -1. Make `initializeWallet()` default to the canonical wallet directory. -2. Preserve directory mode `0700` and config mode `0600` on both migrated and fresh wallets. -3. In `createCocoClient()`, derive `config.json` and `coco.db` from the canonical wallet directory, but take legacy guard paths separately. -4. Update the wallet-access comment near `unlock()` to reference `~/.routstrd/wallet/config.json`. -5. Leave `CocodClient`, `resolveCocodExecutable()`, and `cocodPath` compatibility behavior unchanged. - -### Phase 6 — Tests - -Add or update tests for: - -- fresh initialization creates `~/.routstrd/wallet`, not `.cocod`; -- a legacy config and database migrate byte-for-byte; -- `defaultMintUrl` and unknown config fields survive migration; -- the NPC-derived identity is unchanged after migration; -- migration never copies socket or PID files; -- migration refuses while a real legacy cocod is running; -- stale legacy socket/PID handling remains safe; -- an existing complete canonical wallet wins without modifying it; -- partial canonical, partial legacy, and conflicting dual-wallet states fail without generating a mnemonic; -- staging cleanup and retry after an interrupted migration; -- custom `ROUTSTRD_DIR`, `ROUTSTRD_WALLET_DIR`, and legacy `COCOD_DIR` paths; -- Windows-compatible path construction; -- acquiring the second process lock rolls back the first on failure; -- `dispose()` releases both owned locks and does not unlink another process's lock; -- both CLI restart paths wait for `wallet.pid`. - -Keep `cocod-0.0.24-wallet.db.gz` unchanged and continue using it to prove the migrated database opens successfully with the current in-process wallet. - -### Phase 7 — Documentation - -Update `README.md` and `SKILL.md` to state: - -- wallet data is stored in `~/.routstrd/wallet`; -- existing `~/.cocod` data is migrated automatically on first startup; -- users must back up the mnemonic before migration; -- `ROUTSTRD_WALLET_DIR` overrides the canonical wallet directory; -- `COCOD_DIR`, `COCOD_SOCKET`, `COCOD_PID`, and `cocodPath` refer only to legacy external-cocod compatibility. - -Do not replace every use of the word `cocod`: references to the external daemon, compatibility client, legacy guard, package, and fixture are still accurate. - ---- - -## Acceptance criteria - -- A fresh install creates wallet data only under `~/.routstrd/wallet`. -- Starting from a valid `.cocod` wallet results in the same config and database under the canonical directory without changing balances, mnemonic-derived NPC identity, or default mint. -- No startup path silently creates a new mnemonic when recoverable legacy data exists. -- A running legacy cocod blocks migration and database opening. -- The new wallet directory contains no copied Unix socket and uses `wallet.pid` only as an in-process lock. -- Legacy cocod probing and exclusion continue to use `.cocod/cocod.sock` and `.cocod/cocod.pid`. -- The implementation works with `USERPROFILE`/Windows path construction and custom directory overrides. -- All wallet, CLI, typecheck, and build tests pass on the rebased `coco-integration` branch. - ---- - -## Suggested commit sequence - -1. `refactor(wallet): separate routstrd wallet paths from legacy cocod paths` -2. `feat(wallet): atomically migrate legacy cocod wallet data` -3. `fix(wallet): use independent routstrd and legacy cocod process locks` -4. `test(wallet): cover migration, conflicts, NPC identity, and lock rollback` -5. `docs: document routstrd wallet storage and legacy migration` diff --git a/README.md b/README.md index 0d490cb..841cf32 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # routstrd -Routstr daemon - A CLI tool for managing routstr processes, similar to `cocod` (a Cashu wallet daemon). +Routstr daemon - A CLI tool for managing routstr processes, with a built-in Cashu wallet. ## Overview -routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It integrates with `cocod` for wallet management and uses the Routstr SDK to handle provider routing and model discovery. +routstrd is a Bun-based CLI tool that provides a background daemon for the Routstr protocol. It carries an in-process Cashu wallet (coco) for payments and uses the Routstr SDK to handle provider routing and model discovery. ## Routstr for Teams @@ -13,7 +13,7 @@ For team-based routing, see [routstrd-auth](https://github.com/Routstr/routstrd- ## Features - **Daemon Mode**: Run routstrd as a background HTTP server -- **Wallet Integration**: Works with cocod for Cashu token management +- **Wallet Integration**: In-process Cashu wallet, with Lightning, NWC, and NPC support - **Provider Routing**: Automatically discovers and routes requests to available providers - **Config Management**: Stores configuration in `~/.routstrd/` @@ -74,6 +74,9 @@ routstrd clients add --claude-code # or --pi-agent / --opencode > **Tip:** You can also install the [routstrd skill](https://github.com/Routstr/routstrd/blob/main/SKILL.md) so the agent can manage routstrd for you. ## More Commands + +[`SKILL.md`](SKILL.md) is the complete per-command reference — every command, +subcommand, and flag. The sections below cover the common ones. ### Start Daemon Start the background daemon: @@ -179,9 +182,13 @@ daemon restart is required. #### Route Request ``` -POST / +POST /v1/chat/completions ``` +Any unmatched `POST` path is proxied to the selected provider with the incoming +path preserved, so `POST /v1/messages` (Anthropic Messages API) and +`POST /v1/responses` (OpenAI Responses API) work in their own formats too. + Request body: ```json { @@ -241,6 +248,8 @@ overrides the 21-minute interval. - `ROUTSTRD_DIR` - Config directory (default: `~/.routstrd`) - `ROUTSTRD_SOCKET` - Socket path (default: `~/.routstrd/routstrd.sock`) - `ROUTSTRD_PID` - PID file path (default: `~/.routstrd/routstrd.pid`) +- `ROUTSTRD_WALLET_DIR` - Wallet data directory (default: `~/.routstrd/wallet`) +- `COCOD_DIR` - Legacy external cocod directory, used only for migration and exclusion (default: `~/.cocod`) ## Development @@ -249,14 +258,19 @@ Install dependencies: bun install ``` -Run CLI: +Run the CLI from source: +```sh +bun src/index.ts +``` + +Run the daemon: ```sh bun run start ``` -Run daemon: +Run the tests: ```sh -bun run start +bun test ``` Build a standalone executable for the current platform: @@ -298,7 +312,7 @@ more current model IDs to the smoke script: ```sh routstrd clients add --name smoke-test -ROUTSTRD_API_KEY= scripts/smoke/chat-completions.sh [model ...] +ROUTSTRD_API_KEY= bun run smoke [model ...] ``` Set `ROUTSTRD_BASE_URL` to test a daemon at a different address. The script @@ -327,12 +341,17 @@ not part of `bun test`. ``` routstrd/ ├── src/ -│ ├── index.ts # Entry point with shebang -│ ├── cli.ts # Commander CLI commands -│ ├── cli-shared.ts # IPC utilities -│ ├── daemon.ts # HTTP server daemon -│ └── utils/ -│ └── config.ts # Path configuration +│ ├── index.ts # CLI entry point with shebang +│ ├── cli.ts # Commander CLI commands +│ ├── daemon.ts # Compatibility daemon entrypoint (legacy PM2 registrations) +│ ├── start-daemon.ts # Daemon process launcher +│ ├── daemon/ # HTTP server, wallet, provider routing +│ ├── integrations/ # Client integrations (Claude Code, pi, OpenCode, ...) +│ ├── tui/ # Interactive usage monitor (`routstrd monitor`) +│ └── utils/ # Config, paths, daemon client, update checker +├── tests/ # Integration tests (unit tests sit beside their source) +├── scripts/smoke/ # Manual end-to-end smoke test +├── docs/plans/ # Design/migration plans not yet executed ├── package.json └── tsconfig.json ``` diff --git a/SECURITY.md b/SECURITY.md index ad9097b..11dbffa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,4 +1,3 @@ -## SECURITY.md # Security Policy ## Reporting a Vulnerability diff --git a/SKILL.md b/SKILL.md index 3d0a0b8..988a14a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,27 +1,45 @@ # routstrd CLI Reference -Routstr daemon — a Bun-based CLI tool that runs a background HTTP server for the Routstr protocol. It integrates with `cocod` for Cashu wallet management and routes LLM requests to available providers. +Routstr daemon — a Bun-based CLI tool that runs a background HTTP server for the +Routstr protocol. It carries an in-process Cashu wallet (coco) for payments and +routes LLM requests to available providers. ## Quick Start ```sh -routstrd onboard # Initialize (creates config, sets up cocod) +routstrd onboard # Initialize (creates config, sets up the wallet) +routstrd receive 2100 # Top up over Lightning routstrd start # Start the daemon routstrd stop # Stop the daemon ``` -After onboarding, the daemon listens at `http://localhost:8008` and exposes an OpenAI-compatible API. +After onboarding, the daemon listens at `http://localhost:8008` and exposes an +OpenAI-compatible API. ## Commands ### `routstrd onboard` Initialize routstrd for the first time: -- Creates `~/.routstrd/` config directory -- Creates `~/.routstrd/config.json` with defaults (port 8008, apikeys mode) -- Installs `cocod` globally via bun if not present -- Runs `cocod init` to set up the wallet -- Starts the daemon and configures integrations +- Creates `~/.routstrd/` (mode 0700) and `~/.routstrd/config.json` (mode 0600) + with defaults (port 8008, host 127.0.0.1, apikeys mode) +- Generates a Nostr identity (`nsec`) for NIP-98 authentication +- Migrates a legacy `~/.cocod` wallet to `~/.routstrd/wallet` if one is present, + stopping any running external `cocod` first +- Initializes the in-process Cashu wallet +- Starts the daemon and configures a client integration + +| Option | Description | +|--------|-------------| +| `--opencode` | Set up OpenCode integration (non-interactive) | +| `--openclaw` | Set up OpenClaw integration (non-interactive) | +| `--pi-agent` | Set up Pi Agent integration (non-interactive) | +| `--claude-code` | Set up Claude Code integration (non-interactive) | +| `--hermes` | Set up Hermes integration (non-interactive) | +| `--skip-integration` | Skip integration setup | + +Use at most one integration flag. For several clients, run `routstrd clients add` +afterwards. `--skip-integration` cannot be combined with an integration flag. ### `routstrd start` @@ -30,28 +48,50 @@ Start the background daemon process. | Option | Description | |--------|-------------| | `--port ` | Port to listen on (default: 8008) | +| `--host ` | Bind address (default: 127.0.0.1) | | `-p, --provider ` | Default provider to use | +### `routstrd daemon` + +Run the daemon in the foreground (same options as `start`). Useful for debugging +and for process supervisors that expect a non-forking process. + ### `routstrd stop` Stop the background daemon. ### `routstrd restart` -Restart the daemon (stops if running, then starts). - -| Option | Description | -|--------|-------------| -| `--port ` | Port to listen on | -| `-p, --provider ` | Default provider to use | +Restart the daemon (stops if running, then starts). Same options as `start`. ### `routstrd status` Check daemon and wallet status. Returns JSON with current state. +### `routstrd ping` + +Test connectivity to the daemon. + ### `routstrd balance` -Get wallet and API key balances. Shows per-mint wallet balances, per-key API balances, and a grand total (all in sats). +Get wallet and API key balances. Shows per-mint wallet balances, per-key API +balances, and a grand total (all in sats). + +| Option | Description | +|--------|-------------| +| `--api-keys` | List all stored API keys (baseUrl + key + balance) | +| `--delete-api-keys ` | Delete the API key stored for a provider base URL (refunds its balance first) | +| `--mint-url ` | Mint to refund the deleted API key balance to (defaults to the active wallet mint) | + +### `routstrd refund` + +Refund pending tokens and API keys to a mint. + +| Option | Default | Description | +|--------|---------|-------------| +| `-m, --mint-url ` | active wallet mint | Mint URL to refund to | +| `-y, --yes` | false | Skip confirmation prompt | +| `--xcashu` | false | Refund xcashu tokens only | ### `routstrd models` @@ -60,6 +100,7 @@ List available routstr21 models (discovered via Nostr). | Option | Description | |--------|-------------| | `-r, --refresh` | Force refresh models from Nostr | +| `-m, --model ` | Show the providers serving a specific model | ### `routstrd usage` @@ -69,7 +110,19 @@ Show recent usage logs and total sats cost. |--------|---------|-------------| | `-n, --limit ` | 10 | Number of recent entries (max 1000) | -Shows timestamp, model, provider, sats cost, token counts, and request ID for each entry. +Shows timestamp, model, provider, sats cost, token counts, and request ID for +each entry. + +### `routstrd history` + +Show wallet transaction history. + +| Option | Default | Description | +|--------|---------|-------------| +| `-n, --limit ` | 50 | Number of entries to show | +| `--offset ` | 0 | Number of entries to skip | +| `-v, --verbose` | false | Show full details including encoded Cashu tokens | +| `--json` | false | Output raw JSON with token objects (no encoding) | ### `routstrd providers` @@ -77,7 +130,12 @@ List and manage providers (subcommand required). #### `routstrd providers list` -List all providers with their enabled/disabled status. Shows index, status, and base URL. +List all providers with their enabled/disabled status. Shows index, status, and +base URL. + +| Option | Description | +|--------|-------------| +| `--refresh` | Force re-fetch all Nostr events and refresh models from every enabled provider | ``` Providers (12 total, 2 disabled): @@ -103,6 +161,10 @@ Enable providers by their index numbers. routstrd providers enable 0 2 5 ``` +#### `routstrd providers reviews` + +Show all known providers with their stored review events and event IDs. + ### `routstrd clients` List and manage API clients (subcommand required). @@ -113,7 +175,11 @@ List and manage API clients (subcommand required). | `--disable-automatic-refresh` | Disable the daemon's scheduled refresh job | | `--enable-automatic-refresh` | Re-enable the daemon's scheduled refresh job | -The daemon refreshes models and client integrations on a schedule (every 21 minutes by default). Use `--manual-refresh` to do it on demand, and `--disable-automatic-refresh` to stop the scheduled job — the setting is stored in the daemon's `config.json` (`autoRefresh.enabled`) and takes effect without a restart. +The daemon refreshes models and client integrations on a schedule (every 21 +minutes by default). Use `--manual-refresh` to do it on demand, and +`--disable-automatic-refresh` to stop the scheduled job — the setting is stored +in the daemon's `config.json` (`autoRefresh.enabled`) and takes effect without a +restart. ```sh routstrd clients --manual-refresh # refresh models + integrations now @@ -125,7 +191,6 @@ routstrd clients --enable-automatic-refresh # scheduled refresh back on List all registered clients with their ID, name, API key, and creation date. - #### `routstrd clients add` Add a new client or set up a client integration. @@ -137,10 +202,11 @@ Add a new client or set up a client integration. | `--openclaw` | Set up OpenClaw integration | | `--pi-agent` | Set up Pi Agent integration | | `--claude-code` | Set up Claude Code integration | +| `--hermes` | Set up Hermes integration | ```sh routstrd clients add --opencode --pi-agent --claude-code # multiple integrations -routstrd clients add -n "My App" # generic client +routstrd clients add -n "My App" # generic client ``` Returns the client ID and API key for use with the OpenAI-compatible API. @@ -151,56 +217,58 @@ Delete a registered client by its ID. ### `routstrd npubs` -Manage registered npubs and their roles/names (subcommand required). Management commands route through the auth proxy (`--auth-url`) and use NIP-98 auth. +Manage registered npubs and their roles/names (subcommand required). Management +commands route through the auth proxy (`--auth-url`) and use NIP-98 auth. | Command | Description | |---------|-------------| | `routstrd npubs list` | List registered npubs with role and display name | | `routstrd npubs register [--name ]` | Register yourself as the first admin (bootstrap only) | -| `routstrd npubs add [--role ] [--name ]` | Add an npub (accepts hex or npub1...) | +| `routstrd npubs add [--role ] [--name ]` | Add an npub (accepts hex or npub1...); defaults to the `user` role | | `routstrd npubs update [--role ] [--name ]` | Update role and/or name (admin only) | | `routstrd npubs delete ` | Delete an npub | -### `routstrd remote ` +### `routstrd remote [url]` -Configure a remote daemon URL. Generates a Nostr identity (nsec/npub) for NIP-98 authentication automatically. +With no URL, print the configured remote daemon. Pass a URL to configure one — a +Nostr identity (nsec/npub) is generated automatically for NIP-98 authentication. + +| Option | Description | +|--------|-------------| +| `--auth-url ` | URL of the auth proxy used by management commands (`npubs`, `clients`, `usage`) | ```sh -routstrd remote https://your-remote-daemon.com +routstrd remote # show current remote +routstrd remote https://your-remote-daemon.com # configure one ``` +### `routstrd local` + +Switch back to local daemon mode (clears the configured remote daemon URL). + ### `routstrd refresh` -Refresh routstr21 models from Nostr and re-run integrations for all registered clients. Equivalent to `routstrd clients --manual-refresh`. +Refresh routstr21 models from Nostr and re-run integrations for all registered +clients. Equivalent to `routstrd clients --manual-refresh`. -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `port` | number | 8008 | Daemon HTTP port | -| `provider` | string\|null | null | Default provider URL | -| `daemonUrl` | string\|null | null | Remote daemon URL | -| `nsec` | string\|null | null | Nostr secret key for NIP-98 auth | -| `cocodPath` | string\|null | null | Custom path to cocod executable | -| `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | -| `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) | +### `routstrd update` -| Variable | Default | Description | -|----------|---------|-------------| -| `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | -| `ROUTSTRD_WALLET_DIR` | `~/.routstrd/wallet` | In-process Cashu wallet data directory | -| `ROUTSTRD_WALLET_PID` | `/wallet.pid` | In-process wallet lock path | -| `COCOD_DIR` | `~/.cocod` | Legacy external cocod compatibility directory | +Update routstrd to the latest version. Standalone-binary installs update +in place; npm/bun installs are updated through the package manager. ### `routstrd mode` Interactive prompt to set the client mode: -1. **lazyrefund/apikeys** (default) — Pseudonymous accounts kept with Routstr nodes, refunded after 5 mins if unused. -2. **xcashu** (coming soon) — Balances never kept with nodes, all refunded in response. +1. **lazyrefund/apikeys** (default) — Pseudonymous accounts kept with Routstr + nodes, refunded after 5 mins if unused. +2. **xcashu** (coming soon) — Balances never kept with nodes, all refunded in + response. Changing mode restarts the daemon automatically. -### `routstrd monitor` +### `routstrd monitor` / `routstrd top` -Open an interactive TUI (htop-like) for usage monitoring. +Open an interactive TUI (htop-like) for usage monitoring. `top` is an alias. ### `routstrd logs` @@ -211,17 +279,50 @@ View daemon logs. | `-f, --follow` | false | Follow log output (like `tail -f`) | | `-c, --coco` | false | Show Cashu wallet-engine (coco) logs instead of daemon logs | | `-n, --lines ` | 50 | Number of lines to show | +| `-r, --recent` | false | List recent request IDs with their model | +| `-i, --request-id ` | | Only show log lines for a specific request ID | -Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. Wallet-engine (Cashu/coco) diagnostics go to a separate `~/.routstrd/coco-logs/YYYY-MM-DD.log` so they don't pollute the main daemon logs. +Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. Wallet-engine +(Cashu/coco) diagnostics go to a separate `~/.routstrd/coco-logs/YYYY-MM-DD.log` +so they don't pollute the main daemon logs. + +### `routstrd service` + +Manage routstrd as a system service using PM2, so it survives reboots. + +| Command | Description | +|---------|-------------| +| `routstrd service install` | Install and start routstrd under PM2 | +| `routstrd service uninstall` | Stop and remove routstrd from PM2 | +| `routstrd service logs` | View PM2 logs for routstrd | ## Wallet Commands -New wallets automatically trust `https://mint.cubabitcoin.org` as their default mint. The default is used when a wallet command does not include `--mint-url`. +New wallets automatically trust `https://mint.cubabitcoin.org` as their default +mint. The default is used when a wallet command does not include `--mint-url`. + +### `routstrd send ` / `routstrd receive ` + +Shortcuts for the common wallet operations: + +| Command | Behaviour | +|---------|-----------| +| `routstrd send 2100` | Numeric target → create a Cashu token for that many sats | +| `routstrd send lnbc1...` | Non-numeric target → pay that Lightning invoice | +| `routstrd receive 2100` | Numeric value → create a Lightning invoice for that many sats and wait for payment | +| `routstrd receive cashuB...` | Non-numeric value → receive that Cashu token | + +Both accept `--mint-url `. ### `routstrd wallet status` Check wallet status. +### `routstrd wallet doctor` + +Diagnose conflicting wallets — the current routstrd wallet versus a legacy +`cocod` wallet. + ### `routstrd wallet unlock ` Unlock the wallet with a passphrase. @@ -230,6 +331,17 @@ Unlock the wallet with a passphrase. Get wallet balance. +### `routstrd wallet cleanup` + +Clear stuck pending/in-flight wallet operations. + +| Option | Default | Description | +|--------|---------|-------------| +| `--mint-url ` | all mints | Only clean up operations for this mint URL | +| `--min-age ` | 168 | Minimum age for reclaiming sends and cancelling melts (expired mint quotes are always failed) | +| `--dry-run` | false | Report what would be cleaned without applying changes | +| `-y, --yes` | false | Skip confirmation prompt | + ### `routstrd wallet receive cashu ` Receive funds via a Cashu token. @@ -274,6 +386,41 @@ Set the persistent default mint. If necessary, the mint is added as trusted firs Get info about a specific mint. +### `routstrd wallet npc` + +NPC (npubx.cash) Lightning address operations. + +| Command | Description | +|---------|-------------| +| `routstrd wallet npc address` | Show this wallet's NPC Lightning address | +| `routstrd wallet npc username [--confirm]` | Claim an NPC username; `--confirm` pays the claim fee | +| `routstrd wallet npc sync` | Manually sync paid NPC quotes into the wallet | + +## NWC (Nostr Wallet Connect) + +Connect an external Lightning wallet and let it fund the Cashu wallet. + +| Command | Description | +|---------|-------------| +| `routstrd nwc connect [connection-string]` | Connect via `nostr+walletconnect://...` (prompts if omitted) | +| `routstrd nwc disconnect` | Disconnect from the NWC wallet | +| `routstrd nwc status` | Show connection status and wallet info | +| `routstrd nwc fund ` | Manually fund the Cashu wallet from the connected NWC wallet | + +### `routstrd nwc auto-refill on` + +Enable automatic wallet refill from NWC. + +| Option | Default | Description | +|--------|---------|-------------| +| `--threshold ` | 500 | Refill when the Cashu balance drops below this | +| `--amount ` | 1000 | Refill this many sats at a time | +| `--cooldown ` | 300 | Minimum time between refills | + +### `routstrd nwc auto-refill off` + +Disable auto-refill. + ## Daemon API The daemon exposes an OpenAI-compatible HTTP API at `http://localhost:8008`: @@ -298,6 +445,10 @@ Route a chat completion request. } ``` +The incoming request path is forwarded to the provider, so the Anthropic +Messages API (`POST /v1/messages`) and the OpenAI Responses API +(`POST /v1/responses`) are proxied in their own formats as well. + ## Configuration Config file: `~/.routstrd/config.json` @@ -305,10 +456,20 @@ Config file: `~/.routstrd/config.json` | Field | Type | Default | Description | |-------|------|---------|-------------| | `port` | number | 8008 | Daemon HTTP port | +| `host` | string | `"127.0.0.1"` | Bind address | | `provider` | string\|null | null | Default provider URL | -| `cocodPath` | string\|null | null | Custom path to cocod executable | +| `cocodPath` | string\|null | null | Custom path to a legacy cocod executable | | `mode` | string | `"apikeys"` | Client mode (`apikeys` or `xcashu`) | +| `maxTokens` | number | 64000 | Completion budget applied when a client sets no output-token limit | +| `daemonUrl` | string | — | Remote daemon URL (set by `routstrd remote`) | +| `authUrl` | string | — | Auth proxy URL for management commands | +| `nsec` | string | — | Nostr secret key for NIP-98 auth | +| `relays` | string[] | — | Nostr relays to use for discovery | +| `routstrPubkey` | string | — | Override the Routstr announcement pubkey | +| `routstrModelsPubkey` | string | — | Override the routstr21 models pubkey | +| `nwc` | object | — | NWC settings (`mode`, `connectionString`, `autoRefill`) | | `autoRefresh` | object | `{ enabled: true }` | Scheduled refresh job settings (`enabled`, `intervalMs`) | +| `requestResponseLogging` | object | — | Request/response log sink settings | ### Environment Variables @@ -317,17 +478,26 @@ Config file: `~/.routstrd/config.json` | `ROUTSTRD_DIR` | `~/.routstrd` | Config directory | | `ROUTSTRD_SOCKET` | `~/.routstrd/routstrd.sock` | IPC socket path | | `ROUTSTRD_PID` | `~/.routstrd/routstrd.pid` | PID file path | +| `ROUTSTRD_WALLET_DIR` | `~/.routstrd/wallet` | In-process Cashu wallet data directory | +| `ROUTSTRD_WALLET_PID` | `/wallet.pid` | In-process wallet lock path | +| `COCOD_DIR` | `~/.cocod` | Legacy external cocod compatibility directory | ## Remote Mode -When `daemonUrl` is configured, commands connect to a remote daemon instead of a local one: +When `daemonUrl` is configured, commands connect to a remote daemon instead of a +local one: - Client names are suffixed with the last 7 chars of your npub - All requests are automatically NIP-98 signed using your local nsec -- Local-only commands (`onboard`, `start`, `restart`, `mode`, `logs`, `service`) are disabled +- Local-only commands (`onboard`, `start`, `restart`, `mode`, `logs`, `service`) + are disabled + +Run `routstrd local` to switch back. ## Pi Integration -When `routstrd onboard` runs, it automatically configures a `routstr` provider in `pi`'s `models.json` with an OpenAI-compatible base URL and API key. This allows pi (the AI coding agent) to use Routstr providers seamlessly. +When `routstrd onboard` runs, it automatically configures a `routstr` provider in +`pi`'s `models.json` with an OpenAI-compatible base URL and API key. This allows +pi (the AI coding agent) to use Routstr providers seamlessly. ## File Locations @@ -337,5 +507,21 @@ When `routstrd onboard` runs, it automatically configures a `routstr` provider i | `~/.routstrd/routstr.db` | SQLite database | | `~/.routstrd/routstrd.sock` | IPC socket | | `~/.routstrd/routstrd.pid` | PID file | +| `~/.routstrd/wallet/` | In-process Cashu wallet data (`config.json`, `coco.db`, `wallet.pid`) | | `~/.routstrd/logs/YYYY-MM-DD.log` | Daily daemon log files | | `~/.routstrd/coco-logs/YYYY-MM-DD.log` | Daily Cashu wallet-engine (coco) log files | + +## Development + +```sh +bun install +bun run lint # tsc --noEmit +bun test +bun run build # bundles dist/index.js and dist/daemon/index.js +``` + +End-to-end smoke test against a running daemon (needs a funded client API key): + +```sh +ROUTSTRD_API_KEY= bun run smoke [model-id ...] +``` diff --git a/COCO-2.0.0-MIGRATION-PLAN.md b/docs/plans/coco-2.0.0-migration.md similarity index 98% rename from COCO-2.0.0-MIGRATION-PLAN.md rename to docs/plans/coco-2.0.0-migration.md index b4e277b..f8f4f71 100644 --- a/COCO-2.0.0-MIGRATION-PLAN.md +++ b/docs/plans/coco-2.0.0-migration.md @@ -1,5 +1,11 @@ # Coco 2.0.0 migration plan +> **Status:** not started as of routstrd v0.4.9. +> Verified against `package.json`: still on `@cashu/coco-core ^1.0.1`, +> `@cashu/coco-sqlite-bun ^1.0.1`, `coco-cashu-core 1.1.2-rc.50`, +> `coco-cashu-plugin-npc 2.4.1`. The NPC compatibility gate in section 1 is +> unresolved. + ## Objectives The migration must accomplish four things safely: diff --git a/package.json b/package.json index 2c9d130..45a8acb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "monitor": "bun src/index.ts monitor", "lint": "tsc --noEmit", "test": "bun test", + "smoke": "scripts/smoke/chat-completions.sh", "build": "bun build src/index.ts --target=bun --outfile=dist/index.js --external better-sqlite3 && bun build src/daemon/index.ts --target=bun --outfile=dist/daemon/index.js --external better-sqlite3", "build:binary": "bun build --compile --no-compile-autoload-dotenv --no-compile-autoload-bunfig src/index.ts --outfile=dist/routstrd", "prepublishOnly": "bun run build" diff --git a/refund.js b/refund.js deleted file mode 100644 index 22d549f..0000000 --- a/refund.js +++ /dev/null @@ -1,33 +0,0 @@ -import crypto from "crypto"; - -const token = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCFpGFhGIBhc3hAMDJkZmYwZjUzZGYyYmM1NTJmNmVlZjdmMzhmOWMzZTU5MTY4MGY5ODM5M2I0MjA1YWQ0ZDA5YzlmMTUyNmVkZGFjWCED0Ln-y1Sx8ukCNDoKK4zepISUXAbIMnAj1yuyJGj3hVFhZKNhZVggk4ymtXIrLMRJf2bV7BOo5wmGfPeH1GlYRnG-mPKg-5phc1ggXf-FYynNa0xYgjub6aGIr_mFM-OEAwyx5VlXtWw7NXlhclggM9X2fnOOW8M-cL5Yvn_iAcb7uAcWremL2ZBZi1-TEu2kYWEBYXN4QGQxODY3MTQ4YWJlYTRhMmI5ODIzZDYyNmQ5ZWIwMmJiM2FiYmJlMjNiMzg0Y2U5M2U4N2MyMjAxOTU3YWNiZDVhY1ghA0S4fM1z2KiJKBmGk6qHVvGwnC6t78dcxX0A8Loy8_72YWSjYWVYIKVwButD6sTWCuMrdLGKj0SSdkO_Jl_sP6bAmnL65otzYXNYINBF5s_e9_WmXrI7GZ3n1KliaZk5Q9i4xN2N1JBUlVEdYXJYIGCJVxKUoIUDdtXLz7HFQg7nrBRIb3DJXt6eiveL03EqpGFhAmFzeEAxNTU3NmE4YTQ5Y2RhMzhhZjQ4N2YxYjhlYzk4ZWYwM2Q0MmZlZWY4NThjYzI3YWJlYjlmYmNlNzhlZTBlZWUxYWNYIQKLaUbivnGndI-kvs_u8KvUH0Sd3Mn7fP1iF2MfQPHgvWFko2FlWCCCCI_auq8R_eFrumwLYRHD_Nab7qe4_Ok8hTmGjkQ_FWFzWCANCP4IAbAw38HJktVCeZMScqYR-BMq-1VxIYRLbI-mXmFyWCDe3L3omLIxu02vqDYsQkIjIoy5HVwV5nz-bmB4F32VzaRhYRhAYXN4QDY1ODk1ZjViYTJkOTA4MjJmMzhmZjkyNWJlZDk4Y2Q1ZTM5NDgxNGJjZGI2MDA1ODUyMThlM2EzY2Y0MDM4ZWJhY1ghA0orpNWfYQihhMLqFuqYVdiCRnvzUs0R2LNbmxK0uivLYWSjYWVYIEKL2qFpFy00hsymiyfIpaKoRTbKexGHVIrO4MlF3FjzYXNYILzd6WqS2IDZrmPi2fFC3_w-LQc6aH0Z_-gr_PUL4vtpYXJYIATNPgMmsbuhLQYaWG-c9Fk0QmHrmxZap5SChukvmbM6pGFhEGFzeEA0MWE5OGY2NTlkM2E5YzgwNjIyN2I0Njg0ODljMTAzOGQxNzE5ZGI5ZTIxOTYwMWRkYmQ3ZGRkNTk1YmQ1ZjkwYWNYIQPMufMoE6Of8fyD2MEhPd3Y3KeKhWzepqOZXKYMQewQeGFko2FlWCCCdcrtZzqfHlXkF4Cm_23dztbXX92YiT8qmotuAxoRt2FzWCBTwbmVsAmQraHkJqsrobFhD3WZ8Uc8g8VBMKHlQBwHGmFyWCDOIIFn71ydyJGOeBuxrWc87hCWV1w_6KxzCLhLFIznDQ"; -const token2 = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCEpGFhGIBhc3hAMWE2YTEzZWFmMWY5Y2FiZDdjYzJhZDZhOTM0MmU4ZTE1MzJhMzljYmFhMjRiYmEzMTVmYzA0ZDM1ZGY5YzVjZmFjWCEDxc6EeUBD1ykxGkr5se8B6M0KIaoanysabCXFM3kTsTlhZKNhZVgggsoE5igqfQrqxxpuA4h7IOlu58rTPd5fgSDbt0PsvJ5hc1ggN1c8DaDHAKP7JeskTBCKMYod8Hbc1UASWMb1PdNYiithclggiFuUJSZ4xQKXpUSF3i56M3wMNfbAOoime-NEAJNvbzukYWEYQGFzeEBkZmNhNDJmNDYyMDcyOTFmNGRjNjY5NzE3MzVmMWY1NmRhNmVhODg3OGZhYTZjMzJkOGZhODEyMWI3YzU5NWE5YWNYIQPvTgwE_lEc4y5AvoR4afrvVeBg8aFaZbX-J2pi1ODQD2Fko2FlWCCKHJQPvkqTBzXSlGi7IohfC7dmZYKnJstkrh090EAAJmFzWCCL6_TLTYOtlPtb7E5zpJrlAwmVoaEeasDbWFdHq8bts2FyWCAkwP2KY06reSkqAre6S0eNp6tH6MOamrBYnMIdkTKCD6RhYRBhc3hAOTYwOTBhOWRhNDM0NzgwM2YyODMyM2MyZTQwNTJmMzE4MjBmMGVmZGMzMTBkMzYwNzZkYzc1NzU1MDRkNzgyZGFjWCECLAnqF1xnlwRHKkgcEpl1w73wXSco_TrqvHzvADRur-NhZKNhZVggs79rTYHAsMXduTS8VRFr17wY8BeS76I2w0plRSzGBdNhc1ggn5YnBEqQhPH7WbiQOIVmVoUL6DuOjXghnH6jsqEiVJphclggl5PftuvVrNaO-FaqHB_iALFFtAlccnG87PIWaTxW7NqkYWECYXN4QDcwNjQ5NWU4YWJkMDIyNjY2ZTc5ZjkwNWU2ZTk5NzU2MmVkZmJkZDU1NDA5NDMwMjNlYjA2ZjU5NDAzNjE2YTlhY1ghA_5PsdXzk5DXHulsXP6n9IadnIYcgqBiKo2ssr7APBFsYWSjYWVYIBj6uJBqZ5tJTqxDgPqhrBCJH3WsU8W73LZITtF_MhM8YXNYIDOLWwQWZ6S8y6Tr3IkuP7rfUSbjw2yP1ut5PNIif6KhYXJYIE-CACyXELfybso_kRN4w1uNKSJO7tc4Ke560ZnbLgFq"; -const token3 = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCFpGFhGIBhc3hAOGNhMTRlMjc1ZGM3ZTU2ZjgzMmNiMWQzMzg3MWQ1NTA0ZmY4ZDdjNDExYTlmMWIzZmY4NDliMDVlMWYwYjdkZWFjWCECqkWTlYeBbMjf9Gu9EvXuwMZSwahjlLdtoKYVUjGvMGphZKNhZVggZAlbzaB-f1W9V6DCXx9-UBR3Wrx6q5YPkdPzqdl2NLhhc1gg0iX6AuofKqUpxtYzTS8RK7dDSOhNeuqo4d-OAyEOY7thclggZJpwNkrTQmDQdvPgPuMz1UZoxJB2ntuAB_BJ3B8V_-ykYWEIYXN4QDAwZGUzNjc0OWI5OTMxNzUzODIwMWRiN2FmYmFkZGQyYmVkYWIwYzE4MGQ3YTk4YmQwMTU5ODhhNmU2NzNkN2ZhY1ghArRuuYHPk0zZDarPLFVKXKsWJdrOoVrX2cGOmJHlzj1fYWSjYWVYIA3tdTlmP3AWgUm67_8OGecU__-Bznp_5Z4gxdog4j-BYXNYIC4v6A0OeJ1ftOGaJ5xyDaRoeUcvxZC50k1zmptwca2uYXJYIKwoi2amxdoo6DmakRNC9o1BNBbcVLRovVLTZ_HWrwz6pGFhAmFzeEBlZTJjMDdjNDIyNWY1ZDc5NjUxOGEyN2VkYmVhMjVlNWNiODNlMTYyOTBlYzQ1MTA3MmZhMDMyNDZlZjMzYmZjYWNYIQLnKi-IHFdCqOzKcejPC325v_q7DDBWG4kGbEeEfYj1wmFko2FlWCDJFzWJNhHqQVocBT34rokTvd5MNc2l0yd-22mlrA_J42FzWCCnrH5rsQBu8M5xzz0jdh6iDw798PKXwIjjThsBrJmJ5GFyWCAP5YPIf8NRVQACOX8qTmCFJLly6MZdrPpLKW9crxX3TKRhYRhAYXN4QGU1YTVkODczY2JiN2ZiYjkwZjBhNWJhMjcwODZlNWUwNzczYjhhZmM2MjkxYjkyZDZjNmRjNWUyOWIwNjBmYThhY1ghAsOHo3BVNDkz9aexnLCBGC7fbjAt9qrkMLd5MFSwlkxDYWSjYWVYILVffHeqv2Wj3XBCAvVs5XMizFyO6QXKJX-WkgYejDbhYXNYIHwpn3nGOU8qIU05mvUoLjsQPC50iSp-m0pQc5h_TojGYXJYIFqpEyKa6zmx-es7MGHrBjS34unW7gHirPiWBtNhOYPjpGFhCGFzeEBjZTdjNWQwYTdhMTA1YjFhMGFlODdmNjQ5YjgxY2Y5MDUzNDBkZDg2ZWRmY2EyZDg5ZGI3ZmIwMGU3ZTEwNmFjYWNYIQKHOiRDX17csadc2W9ApXSC9z9OA9Ow_B_0Z3t-LjXlVWFko2FlWCA_BVVJUfd_8aqq6wyc2hna274nMlFJVcPUap8jfLHdZ2FzWCBf7TMpb7gy4H5p2JU4QU9aftsngHLoNRT03e-9IxpdtWFyWCAONKjqsMSZODlyVgbtHJH2Hs3R25mlK3tbM-GsR5KGJQ"; -const token4 = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCFpGFhGEBhc3hAM2JiY2M1OTk4MzA3YWQ2MDcyMTUxYzgyMmQ0N2ZmZGMzYTA4MGQ3MDIwZmM2NGM5MGY0NWYyNDA3ZDYwY2Y3Y2FjWCED1kLTzhP_9Z-emLn9p6ypf5WJnu37aXntJiLGczkuJIhhZKNhZVgg0gm2HX3PRE1e73hD53ETXD9K2sa7g74KME6OPHFyQ2xhc1ggD9yp5h77Lw5iVSeEM0pnvx3iOYi2KZ7USzzupHZkUNRhclggp5x96k0_bpDlYzUpsSS_YejP5xdbRKppx2ryUFkJQzakYWEBYXN4QGY5YzFmMjlkM2Q1MzgxODRjYmViNDA5Yjg4ZTNjM2MzNjU5M2I4NDVjMTA1N2RkNGI4MjY3N2M4NzQ1OWRiZjNhY1ghAzUJP4iEl3rzII-lBI6SD5BgsNPPIyb0otqvCjjeRMEKYWSjYWVYIBB_v8-hl5kgLCUuetZyS8bKYR0qDct9FatrHmktjtIPYXNYIDpeyARyUcMX8VI-aitlYYRrDZwnI0i2JK32h6vW7lNmYXJYIIw64U0Fnur7GKglqxmZLhqGwilg7EEvw7CF59ZpPrXepGFhGIBhc3hAOGZmOGYwODA1Zjg2NWFlMDUzNzY3NGZmMmQ0MTk2YjczNWViZWVkMDYxY2Q4NmQ5MmFkOTVkNjIxNGIyNmFkOWFjWCECJSfVy1j8HGKlIW8WPjaL7laDkh8S74bHgpo827L_yw5hZKNhZVggg24YJokE1Xz_g8rv1Lvs_YtoEHpbNNHnIE1pjJrYYo1hc1ggz5P19DDGdG3V_zNOdMoeBe-OlkhBcIiFFQ1h0ayfatphclgg6__UEWXTYs5oI4_VPh16pa6AYuqNf9IQjT_a4WDK8yKkYWEQYXN4QGMxNmMzM2ZjZTI4YmZhNzc5OWUyYmMwNDA3MzVkNmQzZmFlMTUyYjZkZTE0OTZhNjU5NzlmZjBmYzkwOWJjZDBhY1ghApQQGNGslVLEqkkT-lDnRo2aYqyXoT-_McAz4oS4sa-WYWSjYWVYIHFK50vYiJblhpmrkxfN-p0pyylK2dEinSuobMdJt8rRYXNYIBrTahdM6XVoJ02F9G7L9sLxdDX-I15k9rysVA33bF7iYXJYIIkE3phWIy8O1F-_7uiw8MbyVSvzmnipHdMV572pynoPpGFhAWFzeEA1NGNjM2UyODM1MGM4YzIzNjdlZjFkZjg0NGQ3NzRhMjM0NzYxZTA1OTJkNjg2OTRiMGQzOGMyZWU5MTEzNWQ3YWNYIQNmdhuY0oNhnb4QMBKtyP3QXXpUkmBDD9zTetSgK9op0mFko2FlWCCh5r-bIQFTdyFtpOU6YZoJW3cMDf3ruAdNgElSUPm9JmFzWCCiIT_E7W1gJjuATLUJVd7t1qhkPX94SJRd1f2kmvF1uGFyWCAr4psTvW75ntrNFghNvw1VBJ-Qt6elhDCSGpRcLXNN_Q"; -const token5 = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCEpGFhGIBhc3hAMzgxMTViZWY0NzA3MjNmZjczZGJhOTk5YWY2M2MwZDlhMmViMzQ3OTA1MWUzMjM3NjQ4YWFjYTVkMWFhN2Y1MGFjWCECw0MPnt_M_lwq5ZkmMvgtJHVa2A8PYTtbM6-C0do3NF5hZKNhZVggONdGnCnTzAOKKL9v1CnruOayiVpWDwGV9zRRHhh6KX9hc1ggSdj6FOCa1-ub6eI8hF42RbdMonb0-CYwRoWm0fSH7_phclggeQXZy7n8e7RzL1rjqwhYDjcqrUxj1v3aPAM_4cEaauqkYWEYQGFzeEA5NTg5YTFkODEyOTg3MzE0ZDFiYTE5ZDdjZmM1MzZlMTlmNGVkNGVlNTg3MDA4NjRmNjg4NjA0MjI2Mjk5YzFlYWNYIQNWML_D2vtlMYtimuEgIkGNYvaFZFWjyWwkZdyrT2ijeGFko2FlWCD1N0B9J5B91SxMhTg1PhWytUjWJPFOWvlm1UpXJ7UhqWFzWCASNE9dGijBP9jHIgyjnHnHI0G8a7qWsPd8Uhc0PwxS4mFyWCA3vukTqXvkF0Yit9w8wkyPWTIYLxHefjSHzaOuyxI6XaRhYRBhc3hANWJhOWM1MTA3ZTc1MWM4MGE2NzBiMjI2MTUxYjMyZDIzNzNiZjU1M2EwM2VlNTk2ZWQ1NTEyOTFiY2UwMWZmMWFjWCEC6f7l5IXXSaP9eRZmdUYs9liU2dVxNTaNPgPlPgnmzs1hZKNhZVggQaxRZNwg_cPymPLpgWLetqAMuUd5kRfMjNID22snM5dhc1gg0CDW54cjN5HF3FIogQAB3TqowQiH5aJ5ZbHIIKbX59Fhclggolu8liKdOYxCrwmU4p7VsPAflDJecMiUePmPLRmUwS6kYWECYXN4QDY5MDRjM2RkZmJlNjRmOGRmY2NkOWI0NWFmNWEyZTMzYzcyMzIzZTU5N2VkMGNiM2NmZTJhMWIxYzRmOGE4YjZhY1ghA5wv4H1I4NBMUy9qQWavrG6AW3pduPm-F7JUxYt0KC9kYWSjYWVYIIthil2Aj1kiIVmRlziK9NjEI_Dk1JKDn0pazj4Z8qVQYXNYIMNyB7-TbSR_9gNyO_YKohy3D79KhcsbAxV6MLi_RAnEYXJYIM4g3a130UC94KjWYufe-DWVGAQsGNTgd2fLT-DZlrS9"; - -// Hash the token with SHA256 -const hash = crypto.createHash("sha256").update(token).digest("hex"); -console.log("SHA256 hash:", hash); - -// Make the POST request -const url = `https://llm.satsandsports.cash/v1/cashu-refund/${hash}`; - -fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, -}) - .then((res) => res.json()) - .then((data) => { - console.log("Response:", JSON.stringify(data, null, 2)); - }) - .catch((err) => { - console.error("Error:", err.message); - }); diff --git a/refund_new.js b/refund_new.js deleted file mode 100644 index fcc6921..0000000 --- a/refund_new.js +++ /dev/null @@ -1,20 +0,0 @@ -const token = - "cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSAAQeTfbDMhlYXCGpGFhGIBhc3hAYzNmMDFjNDYxYjMxNmEwM2E2NGMzNjBmZDJhNjk1OThmMzE2MmM4NGMzZjlmZWExYWYxYTNjN2EyNWY3OTdmNGFjWCEC8iVrY11X2Hs_cYpWG7iD8Xt4DPcIZbVO0RAYoemP7udhZKNhZVggD1RQud6FdadMsbgJ_JB6h-qsJX0qLY83AIig3ocSZdNhc1ggOJIqxJpCn7dIqUO4lLLQE4VMs4TDu-bXWr0Kb3W8cYNhclgg_ZB2BEjqNIemFZ3GGGt-ZROgKNntpcyLX6eAJMYM6p2kYWECYXN4QGRlZjdhMjc5ZmNlZjY2ZmY5YzMyZDczODljYmJmNjUyNGIzY2M0OTQzYmM3MWJkNjA2ZDZiNzdhMjIzODIyMzNhY1ghA591WOMW3zhSXMajVUsgcXzoVReBsnxvq_xuy0gmRSI_YWSjYWVYIASUNdNthr7DNLBsCuIMisKt_KAUNXKVbcvHuUXVrxWCYXNYIBB5rsypISo_LcFKnaqEqbdEBi1SpWRgRLZfbFxqAo1IYXJYIMp7sk2t2k9vh1ZmhnILSlfo2245EqEvjgw8BryNY8QapGFhCGFzeEBhMWY2MzgwYjA2ZmE2NDhlMTY1NmQ5OWNmMThiYWNiYTdhZDVlMmNmNTY1MDAxMTc3NWQ3YjJkNTg5ODkwNzI1YWNYIQKSoj8-ipFq_s6Jes9APqaC5sNUz1icoZ0R6B3HCWoQ3GFko2FlWCAoiS9cHLbzHD9RWU6osZDCfo-czzEidp-pea9ejCEeSGFzWCA_YnzFgPvmXJX65r4QUJQICBcThdC7ZvdZlKqba598pmFyWCBHEneehhb0PxiFrwr6DgSZXWmWKft9WJwXlxr05HHmWqRhYRhAYXN4QDQ3NDkxMWZmYTk4ZTAzYTBkYWM5ODdjMjBjZWI5ZjlkMGE1MGNkNDM1ZjkwMDBhZWFhMjRkMGJlODgzMGM4ZjFhY1ghA0V01nVSIT46umSZL1XABGFGT4nAPnSYEOHUK5eT5xa-YWSjYWVYIHLOM7_1xe30zcNFFQEXgijF8joTo6yFLFDovHXWCr-sYXNYIILMBt2l053_K8HRRj5P-OWrVhg3oUF9NRYyzFQixEW3YXJYIIOu8UCudw8LMvm87O9X6A2VOS_AtDbXNGHEe2krTPTmpGFhCGFzeEA1N2I0OGNlNTNjMDI2MDM4ZmFlMTcxZjYzM2M5NzYzY2U3ZTA0M2E5MTk0N2VhZDM0Y2JhZDQwZGNmMTI5NmMwYWNYIQJ8KXhq9MuwGEw3xf4CFVpSuLmlMHohE4DByPpeW9NYqWFko2FlWCCHLILey0LgSBMe79A0f8gc4vzoI2AZ5vv53jWH9GUcFmFzWCBB7PcuhuKazJYtRXKMWtmQbRv66qkgdt3I5qetd7DfNGFyWCDBj4R1dWGOulvmQxsbpVJwAAM1RkOdSlgztd1ZOUxHfKRhYQRhc3hAMDJjZWQ4NzZjNGU1MGRjYmNmNDViN2U2MGZjNGRiYThiNzMzNzRkMDU2MWFiMTc0Nzc2ZDNjMmE5MjliOWU0NWFjWCED4OBpEfpelcMak8IAzXO8xRjon5bj5VC3dMjRF1js-gJhZKNhZVggpN8QqMUwG52q-t7xrwkDE6lS8Cw67-_68ERu0X2QuAphc1ggrYyoFeGIT73AT-3gd9tCK_1NYh84s9f63oBytxKTv2Vhclggvi2atyfhijg7QLbdEkNQkrHcW0ocJElhPrNmND9_so8"; - -// Make the POST request -const url = `https://routstr.otrta.me/v1/balance/refund`; - -fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Cashu": `${token}`, - }, -}) - .then((res) => res.json()) - .then((data) => { - console.log("Response:", JSON.stringify(data, null, 2)); - }) - .catch((err) => { - console.error("Error:", err.message); - }); diff --git a/routstr-cost-logging.md b/routstr-cost-logging.md deleted file mode 100644 index 11847f0..0000000 --- a/routstr-cost-logging.md +++ /dev/null @@ -1,71 +0,0 @@ -# routstr Proxy Cost Logging Issue - -## Problem - -The routstr proxy at `localhost:8009` returns usage data that differs slightly from what the pi-ai `openai-completions` provider expects. - -## What routstr Returns - -**Streaming response (last chunk):** -```json -{ - "usage": { - "prompt_tokens": 9, - "completion_tokens": 9, - "total_tokens": 18, - "cost": 0.000018, - "prompt_tokens_details": { - "cached_tokens": 0, - "cache_write_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - }, - "cost": { - "total_usd": 0.000018 - } -} -``` - -## What pi-ai Expects - -The `openai-completions` provider in pi-ai expects the standard OpenAI format: - -```json -{ - "usage": { - "prompt_tokens": 9, - "completion_tokens": 9, - "prompt_tokens_details": { - "cached_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0 - } - } -} -``` - -## Current Handling - -The pi-ai provider already handles the standard format correctly via `parseChunkUsage()`: -- `input` ← `prompt_tokens - cached_tokens` -- `output` ← `completion_tokens + reasoning_tokens` -- `cacheRead` ← `prompt_tokens_details.cached_tokens` -- `cacheWrite` ← **NOT CURRENTLY PARSED** (hardcoded to 0) - -The provider then calculates cost using `calculateCost()` based on the model's configured cost per million tokens, ignoring any `cost` field from the response. - -## Gap - -The `parseChunkUsage()` function in `packages/ai/src/providers/openai-completions.ts` does not currently extract: -1. `cache_write_tokens` from `prompt_tokens_details` (routstr-specific field) - -Currently `cacheWrite` is hardcoded to 0. - -## Resolution - -The existing pi-ai `openai-completions` provider should work with routstr as-is since routstr returns the standard OpenAI format fields. The usage should be logged correctly if: -1. `stream_options: { include_usage: true }` is passed -2. The model has a `cost` configuration in the registry diff --git a/src/TUI refactor.md b/src/TUI refactor.md deleted file mode 100644 index ff7547a..0000000 --- a/src/TUI refactor.md +++ /dev/null @@ -1,113 +0,0 @@ -# TUI refactor plan - -## Goals -- Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder. -- Reduce the size and responsibility of the current monolithic file. -- Keep the existing CLI entrypoint stable so current usage does not break. -- Preserve behavior while making future TUI work easier. - -## Current state -`src/cli/usage-tui.ts` currently mixes several concerns in one file: -- TUI-specific types and constants -- ANSI/terminal helpers -- scroll/search/vim navigation state -- data fetching from the daemon -- usage aggregation/stat helpers -- rendering for all tabs -- app lifecycle and keyboard event handling - -This makes the file hard to extend safely. - -## Refactor strategy -Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`. - -### Target structure -- `src/tui/usage/index.ts` - - public entrypoint: `runUsageTui()` -- `src/tui/usage/types.ts` - - `UsageStats`, tab ids, tab metadata, derived stat types -- `src/tui/usage/constants.ts` - - tabs, colors, model/client color maps -- `src/tui/usage/terminal.ts` - - ANSI helpers, width/height helpers, `stripAnsi` -- `src/tui/usage/state.ts` - - vim/search/scroll state and state mutation helpers -- `src/tui/usage/data.ts` - - `fetchUsage()` and usage aggregation helpers -- `src/tui/usage/render.ts` - - shared render helpers and tab renderers -- `src/tui/usage/app.ts` - - main loop, render orchestration, input handling, cleanup -- `src/cli/usage-tui.ts` - - compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage` - -## Design choices -### 1. Keep CLI path compatibility -Do not delete the CLI file outright. Turn it into a tiny wrapper: -- minimal import from `../tui/usage/index.ts` -- export `runUsageTui()` - -This avoids breaking any existing imports or scripts. - -### 2. Separate pure logic from side effects -Keep these pure where possible: -- aggregation helpers -- formatting helpers -- render helpers that return strings -- scroll clamping logic - -Keep side effects isolated in the app layer: -- reading terminal size -- writing to stdout -- raw mode setup -- signal handling -- interval scheduling - -### 3. Avoid over-engineering -This should be a pragmatic refactor, not a framework: -- no unnecessary classes -- keep function-based design -- only extract modules around clear responsibility boundaries - -### 4. Preserve behavior first -No UX changes unless needed to support the extraction. -That means: -- same tabs -- same keybindings -- same output format -- same fetch cadence -- same search/scroll behavior - -## Implementation steps -1. Create `src/tui/usage/`. -2. Extract types/constants first. -3. Extract terminal helpers. -4. Extract data fetching + aggregation helpers. -5. Extract state/search/scroll logic. -6. Extract rendering helpers + tab renderers. -7. Build `app.ts` using the extracted modules. -8. Replace `src/cli/usage-tui.ts` with a thin wrapper. -9. Run a TypeScript/bun check and fix imports. -10. Smoke-test keyboard handling and rendering behavior. - -## Risks -- circular imports between render/state/constants -- broken relative import paths during extraction -- subtle behavior regressions in scroll/search state -- terminal escape handling differences if helpers are split carelessly - -## Validation checklist -- `src/cli/usage-tui.ts` still exposes `runUsageTui()` -- TUI starts from the same CLI path -- scroll still works for long content -- vim keys still work -- arrow keys still work -- tab switching still resets scroll -- search mode still works -- cleanup still restores cursor and alternate screen - -## Non-goals -- redesigning the UI -- changing tab contents -- introducing tests unless needed for safety -- adding new features unrelated to the refactor diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts new file mode 100644 index 0000000..1a949aa --- /dev/null +++ b/src/tui/usage/render.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "bun:test"; +import { renderBox } from "./render"; +import { stripAnsi } from "./terminal"; + +/** Visible column count of a rendered line, ignoring ANSI colour codes. */ +function visibleWidths(box: string): number[] { + return box.split("\n").map((line) => stripAnsi(line).length); +} + +describe("renderBox", () => { + it("renders every line at the requested width when given a title", () => { + const box = renderBox(["Hello World", "Line 2"], 40, "Title"); + + expect(box.split("\n")).toHaveLength(4); // top, 2 content rows, bottom + for (const width of visibleWidths(box)) { + expect(width).toBe(40); + } + }); + + it("renders every line at the requested width without a title", () => { + const box = renderBox(["Hello World", "Line 2"], 40); + + for (const width of visibleWidths(box)) { + expect(width).toBe(40); + } + }); + + it("puts the title in the top border", () => { + const [topBorder] = renderBox(["body"], 40, "Stats of Sats").split("\n"); + + expect(topBorder).toContain("Stats of Sats"); + }); + + it("pads content rows so ANSI-coloured lines still align", () => { + const colored = "\x1b[32mgreen\x1b[0m"; + const box = renderBox([colored, "plain"], 30); + + for (const width of visibleWidths(box)) { + expect(width).toBe(30); + } + }); + + it("composes two half-width boxes into rows of the full width", () => { + const width = 80; + const halfWidth1 = Math.floor(width / 2); + const halfWidth2 = width - halfWidth1; + + const left = renderBox( + ["Total Spent: 12.78k sats", "Total Requests: 1.0k"], + halfWidth1, + "Stats of Sats", + ).split("\n"); + const right = renderBox( + ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"], + halfWidth2, + "Token Stats", + ).split("\n"); + + expect(left).toHaveLength(right.length); + for (let i = 0; i < left.length; i++) { + expect(stripAnsi(left[i]! + right[i]!).length).toBe(width); + } + }); + + it("pads the shorter side when the two boxes have unequal heights", () => { + const width = 80; + const halfWidth1 = Math.floor(width / 2); + const halfWidth2 = width - halfWidth1; + + const left = renderBox(["a", "b", "c"], halfWidth1, "Left").split("\n"); + const right = renderBox(["x"], halfWidth2, "Right").split("\n"); + + const rows = Math.max(left.length, right.length); + for (let i = 0; i < rows; i++) { + const l = left[i] ?? " ".repeat(halfWidth1); + const r = right[i] ?? " ".repeat(halfWidth2); + expect(stripAnsi(l + r).length).toBe(width); + } + }); +}); diff --git a/test_box.ts b/test_box.ts deleted file mode 100644 index 0617cf2..0000000 --- a/test_box.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { renderBox } from "./src/tui/usage/render.ts"; -import { stripAnsi } from "./src/tui/usage/terminal.ts"; - -const w = 40; -const testBox = renderBox(["Hello World", "Line 2"], w, "Title"); -console.log(testBox); -const lines = testBox.split("\n"); -lines.forEach((l, i) => console.log(`Line ${i} length: ${stripAnsi(l).length}`)); - -console.log("---"); - -const testBoxNoTitle = renderBox(["Hello World", "Line 2"], w); -console.log(testBoxNoTitle); -const lines2 = testBoxNoTitle.split("\n"); -lines2.forEach((l, i) => console.log(`Line ${i} length: ${stripAnsi(l).length}`)); diff --git a/test_split_box.ts b/test_split_box.ts deleted file mode 100644 index 538a7cc..0000000 --- a/test_split_box.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { renderBox } from "./src/tui/usage/render.ts"; - -const width = 80; -const halfWidth1 = Math.floor(width / 2); -const halfWidth2 = width - halfWidth1; - -const leftBox = ["Total Spent: 12.78k sats", "Total Requests: 1.0k"]; -const rightBox = ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"]; - -const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats"); -const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats"); - -const leftLines = leftBoxStr.split("\n"); -const rightLines = rightBoxStr.split("\n"); - -const combinedContent = leftLines.map((l, i) => l + (rightLines[i] || " ".repeat(halfWidth2))).join("\n"); -console.log(combinedContent); diff --git a/test_split_box2.ts b/test_split_box2.ts deleted file mode 100644 index 9b08c14..0000000 --- a/test_split_box2.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { renderBox } from "./src/tui/usage/render.ts"; - -const width = 80; -const halfWidth1 = Math.floor(width / 2); -const halfWidth2 = width - halfWidth1; - -const leftBox = ["Total Spent: 12.78k sats", "Total Requests: 1.0k"]; -const rightBox = ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"]; - -const leftBoxStr = renderBox(leftBox, halfWidth1, "Stats of Sats"); -const rightBoxStr = renderBox(rightBox, halfWidth2, "Token Stats"); - -const leftLines = leftBoxStr.split("\n"); -const rightLines = rightBoxStr.split("\n"); - -const maxLines = Math.max(leftLines.length, rightLines.length); -const combinedLines: string[] = []; -for (let i = 0; i < maxLines; i++) { - const l = leftLines[i] || " ".repeat(Math.floor(width / 2)); - const r = rightLines[i] || " ".repeat(Math.ceil(width / 2)); - combinedLines.push(l + r); -} -console.log(combinedLines.join("\n")); diff --git a/v1-messages-format-report.md b/v1-messages-format-report.md deleted file mode 100644 index 43c09c0..0000000 --- a/v1-messages-format-report.md +++ /dev/null @@ -1,223 +0,0 @@ -# Report: why `POST /v1/messages` on port 8008 returns chat-completions chunks while port 8009 preserves Messages API format - -Date: 2026-03-28 - -## Summary - -I tested the same request against both local daemons using `scripts/test-direct-local.ts` semantics (`POST /v1/messages`, `stream: true`). - -- `localhost:8009` preserves the Anthropic/OpenAI Messages-style stream as expected: - - `message_start` - - `content_block_start` - - `content_block_delta` - - `message_delta` - - `message_stop` -- `localhost:8008` returns OpenAI chat-completions streaming chunks instead: - - `object: "chat.completion.chunk"` - - `choices[].delta` - -This is **not** because the SDK itself is incapable of preserving `/v1/messages`. -It happens because the two daemons call the SDK differently. - ---- - -## What I tested - -### 8008 (`../routstrd/`) - -Listener: -- `../routstrd/src/daemon/index.ts` -- request handler in `../routstrd/src/daemon/http/index.ts` - -Observed result for `POST http://localhost:8008/v1/messages`: -- response `content-type: text/event-stream` -- SSE payload is OpenAI chat-completions chunks (`chat.completion.chunk`) - -### 8009 (`routstr-chat/scripts/routstr-daemon.ts`) - -Listener: -- `scripts/routstr-daemon.ts` - -Observed result for `POST http://localhost:8009/v1/messages`: -- response `content-type: text/event-stream` -- SSE payload preserves Messages API events (`message_start`, `content_block_delta`, etc.) - ---- - -## Direct cause - -### 8009 forwards the incoming path to the SDK - -In `routstr-chat/scripts/routstr-daemon.ts`, the request is routed with: - -```ts -await routeRequestsToNodeResponse({ - modelId, - requestBody, - path: url.pathname, - headers: forwardedHeaders, - ... -}); -``` - -Because it passes: - -```ts -path: url.pathname -``` - -an incoming request to `/v1/messages` stays `/v1/messages` all the way through the SDK and upstream provider routing. - -### 8008 does **not** forward the incoming path - -In `../routstrd/src/daemon/http/index.ts`, the request is routed with: - -```ts -const response = await routeRequests({ - modelId, - requestBody, - forcedProvider, - headers: incomingHeaders, - walletAdapter: deps.walletAdapter, - storageAdapter: deps.storageAdapter, - providerRegistry: deps.providerRegistry, - discoveryAdapter: deps.discoveryAdapter, - modelManager: deps.modelManager, - debugLevel: "DEBUG", - mode: deps.mode, - usageTrackingDriver: deps.usageTrackingDriver, - sdkStore: deps.store, -}); -``` - -Notice: **no `path` is passed**. - -In the SDK, `routeRequests()` defaults the path to: - -```ts -path = "/v1/chat/completions" -``` - -from: -- `routstr-chat/sdk/routeRequests.ts` - -So even when the client calls: - -```http -POST /v1/messages -``` - -on port 8008, the daemon internally re-routes it as: - -```http -POST /v1/chat/completions -``` - -That is why the upstream/provider response is converted into chat-completions format. - ---- - -## Why the behavior differs even though both are built on the same SDK - -Both daemons use the same SDK primitives, but: - -- **8009** uses `routeRequestsToNodeResponse(...)` and explicitly passes `path: url.pathname` -- **8008** uses `routeRequests(...)` and relies on the SDK default path, which is `/v1/chat/completions` - -So the format difference is caused by **daemon integration code**, not by a provider-specific quirk and not by an unavoidable SDK conversion. - ---- - -## Important implementation detail - -The SDK helper itself documents this default: - -- `routstr-chat/sdk/routeRequests.ts` - -```ts -/** Optional: API path (defaults to /v1/chat/completions) */ -``` - -and in `resolveRouteRequestContext(...)`: - -```ts -path = "/v1/chat/completions" -``` - -Therefore any caller that omits `path` will get chat-completions semantics by default. - ---- - -## Evidence from runtime tests - -### Port 8008 - -Observed streamed chunks included: - -- `object: "chat.completion.chunk"` -- `choices[0].delta.content` -- final `[DONE]` - -### Port 8009 - -Observed streamed events included: - -- `event: message_start` -- `event: content_block_start` -- `event: content_block_delta` -- `event: message_delta` -- `event: message_stop` -- final `[DONE]` - -This matches the code-path difference above. - ---- - -## Recommended fix - -In `../routstrd/src/daemon/http/index.ts`, pass the incoming path through to the SDK: - -```ts -const response = await routeRequests({ - modelId, - requestBody, - path: url.pathname, - forcedProvider, - headers: incomingHeaders, - walletAdapter: deps.walletAdapter, - storageAdapter: deps.storageAdapter, - providerRegistry: deps.providerRegistry, - discoveryAdapter: deps.discoveryAdapter, - modelManager: deps.modelManager, - debugLevel: "DEBUG", - mode: deps.mode, - usageTrackingDriver: deps.usageTrackingDriver, - sdkStore: deps.store, -}); -``` - -This should make `POST /v1/messages` on port 8008 preserve the Messages API format, matching port 8009. - ---- - -## Secondary note - -Port 8008 currently uses `routeRequests(...)` and then manually streams the returned response body to `res`. -Port 8009 uses `routeRequestsToNodeResponse(...)` directly. - -That difference is probably not the root cause here. -The root cause is specifically that **8008 drops the incoming request path and falls back to the SDK default of `/v1/chat/completions`**. - ---- - -## Conclusion - -The reason `localhost:8008/v1/messages` appears to "convert to chat completions" is: - -1. `../routstrd` receives `/v1/messages` -2. its handler calls `routeRequests(...)` without `path` -3. the SDK defaults `path` to `/v1/chat/completions` -4. the upstream request is therefore made against chat completions -5. the stream returned is chat-completions SSE, not Messages API SSE - -Port 8009 works because it explicitly forwards `url.pathname` into the SDK call. From 489f094405160e40da8e8515311574d9e5d30782 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:44:45 +0200 Subject: [PATCH 2/5] chore(docs): drop renderBox tests from this PR --- src/tui/usage/render.test.ts | 80 ------------------------------------ 1 file changed, 80 deletions(-) delete mode 100644 src/tui/usage/render.test.ts diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts deleted file mode 100644 index 1a949aa..0000000 --- a/src/tui/usage/render.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { renderBox } from "./render"; -import { stripAnsi } from "./terminal"; - -/** Visible column count of a rendered line, ignoring ANSI colour codes. */ -function visibleWidths(box: string): number[] { - return box.split("\n").map((line) => stripAnsi(line).length); -} - -describe("renderBox", () => { - it("renders every line at the requested width when given a title", () => { - const box = renderBox(["Hello World", "Line 2"], 40, "Title"); - - expect(box.split("\n")).toHaveLength(4); // top, 2 content rows, bottom - for (const width of visibleWidths(box)) { - expect(width).toBe(40); - } - }); - - it("renders every line at the requested width without a title", () => { - const box = renderBox(["Hello World", "Line 2"], 40); - - for (const width of visibleWidths(box)) { - expect(width).toBe(40); - } - }); - - it("puts the title in the top border", () => { - const [topBorder] = renderBox(["body"], 40, "Stats of Sats").split("\n"); - - expect(topBorder).toContain("Stats of Sats"); - }); - - it("pads content rows so ANSI-coloured lines still align", () => { - const colored = "\x1b[32mgreen\x1b[0m"; - const box = renderBox([colored, "plain"], 30); - - for (const width of visibleWidths(box)) { - expect(width).toBe(30); - } - }); - - it("composes two half-width boxes into rows of the full width", () => { - const width = 80; - const halfWidth1 = Math.floor(width / 2); - const halfWidth2 = width - halfWidth1; - - const left = renderBox( - ["Total Spent: 12.78k sats", "Total Requests: 1.0k"], - halfWidth1, - "Stats of Sats", - ).split("\n"); - const right = renderBox( - ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"], - halfWidth2, - "Token Stats", - ).split("\n"); - - expect(left).toHaveLength(right.length); - for (let i = 0; i < left.length; i++) { - expect(stripAnsi(left[i]! + right[i]!).length).toBe(width); - } - }); - - it("pads the shorter side when the two boxes have unequal heights", () => { - const width = 80; - const halfWidth1 = Math.floor(width / 2); - const halfWidth2 = width - halfWidth1; - - const left = renderBox(["a", "b", "c"], halfWidth1, "Left").split("\n"); - const right = renderBox(["x"], halfWidth2, "Right").split("\n"); - - const rows = Math.max(left.length, right.length); - for (let i = 0; i < rows; i++) { - const l = left[i] ?? " ".repeat(halfWidth1); - const r = right[i] ?? " ".repeat(halfWidth2); - expect(stripAnsi(l + r).length).toBe(width); - } - }); -}); From 319585e67b5619919475b0ae536735ec4ba48d2d Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:48:28 +0200 Subject: [PATCH 3/5] test(tui): cover renderBox borders, padding and side-by-side composition Rescued from the deleted root-level scratch scripts test_box.ts, test_split_box.ts and test_split_box2.ts, which were never run by bun test and were never typechecked (tsconfig only includes src/**/*). Adds onto the existing src/tui/usage/render.test.ts instead of replacing it, so the npub/client-label/stacked-bar coverage stays intact. --- src/tui/usage/render.test.ts | 79 +++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/src/tui/usage/render.test.ts b/src/tui/usage/render.test.ts index 1a1a864..239a837 100644 --- a/src/tui/usage/render.test.ts +++ b/src/tui/usage/render.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { renderNpubs, renderRecent, renderStackedBar, tokenSegments } from "./render.ts"; +import { renderBox, renderNpubs, renderRecent, renderStackedBar, tokenSegments } from "./render.ts"; import { stripAnsi } from "./terminal.ts"; import { COLORS } from "./constants.ts"; import { buildClientNaming, resolveClientLabel, type ClientInfo, type NpubEntry } from "./data.ts"; @@ -254,3 +254,80 @@ describe("renderRecent token bars", () => { } }); }); + +/** Visible column count of a rendered line, ignoring ANSI colour codes. */ +function visibleWidths(box: string): number[] { + return box.split("\n").map((line) => stripAnsi(line).length); +} + +describe("renderBox", () => { + test("renders every line at the requested width when given a title", () => { + const box = renderBox(["Hello World", "Line 2"], 40, "Title"); + + expect(box.split("\n")).toHaveLength(4); // top, 2 content rows, bottom + for (const width of visibleWidths(box)) { + expect(width).toBe(40); + } + }); + + test("renders every line at the requested width without a title", () => { + const box = renderBox(["Hello World", "Line 2"], 40); + + for (const width of visibleWidths(box)) { + expect(width).toBe(40); + } + }); + + test("puts the title in the top border", () => { + const [topBorder] = renderBox(["body"], 40, "Stats of Sats").split("\n"); + + expect(topBorder).toContain("Stats of Sats"); + }); + + test("pads content rows so ANSI-coloured lines still align", () => { + const colored = "\x1b[32mgreen\x1b[0m"; + const box = renderBox([colored, "plain"], 30); + + for (const width of visibleWidths(box)) { + expect(width).toBe(30); + } + }); + + test("composes two half-width boxes into rows of the full width", () => { + const width = 80; + const halfWidth1 = Math.floor(width / 2); + const halfWidth2 = width - halfWidth1; + + const left = renderBox( + ["Total Spent: 12.78k sats", "Total Requests: 1.0k"], + halfWidth1, + "Stats of Sats", + ).split("\n"); + const right = renderBox( + ["Total Tokens: 25.8M", "Avg Tokens/Req: 25.8K"], + halfWidth2, + "Token Stats", + ).split("\n"); + + expect(left).toHaveLength(right.length); + for (let i = 0; i < left.length; i++) { + expect(stripAnsi(left[i]! + right[i]!).length).toBe(width); + } + }); + + test("pads the shorter side when the two boxes have unequal heights", () => { + const width = 80; + const halfWidth1 = Math.floor(width / 2); + const halfWidth2 = width - halfWidth1; + + const left = renderBox(["a", "b", "c"], halfWidth1, "Left").split("\n"); + const right = renderBox(["x"], halfWidth2, "Right").split("\n"); + + const rows = Math.max(left.length, right.length); + for (let i = 0; i < rows; i++) { + const l = left[i] ?? " ".repeat(halfWidth1); + const r = right[i] ?? " ".repeat(halfWidth2); + expect(stripAnsi(l + r).length).toBe(width); + } + }); +}); From f9dc60a9b849d1ee5cc8718c7452e42875a72b8e Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:46:04 +0200 Subject: [PATCH 4/5] feat(wallet): trust cuba and minibits by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New wallets only trusted https://mint.cubabitcoin.org, so receiving from any other mint failed with "Mint ... is not trusted" until the user ran `wallet mints add`. Seed a second shipped mint so a fresh wallet can receive from minibits out of the box: - `DEFAULT_TRUSTED_MINT_URLS` lists the mints routstrd trusts on startup, with `DEFAULT_MINT_URL` (cuba) first so it keeps the default slot. - `seedTrustedMints` owns the startup seeding. The default mint stays strict — a failed fetch still aborts startup so config can never point at an unusable mint — while extra seeds are best-effort and only log a warning, so one unreachable mint cannot keep the daemon down. Existing wallets keep their configured default and only gain the extra trusted mint. The seeding logic is extracted into its own module so the strict-default/best-effort-extra rules are testable without a network. --- SKILL.md | 4 +- src/daemon/wallet/coco-client.test.ts | 8 +- src/daemon/wallet/coco-client.ts | 25 ++++- src/daemon/wallet/trusted-mints.test.ts | 139 ++++++++++++++++++++++++ src/daemon/wallet/trusted-mints.ts | 87 +++++++++++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 src/daemon/wallet/trusted-mints.test.ts create mode 100644 src/daemon/wallet/trusted-mints.ts diff --git a/SKILL.md b/SKILL.md index 3d0a0b8..2dce6f6 100644 --- a/SKILL.md +++ b/SKILL.md @@ -216,7 +216,7 @@ Log files are stored at `~/.routstrd/logs/YYYY-MM-DD.log`. Wallet-engine (Cashu/ ## Wallet Commands -New wallets automatically trust `https://mint.cubabitcoin.org` as their default mint. The default is used when a wallet command does not include `--mint-url`. +New wallets trust two mints out of the box: `https://mint.cubabitcoin.org` and `https://mint.minibits.cash/Bitcoin`. `https://mint.cubabitcoin.org` is the default mint, and the default is used when a wallet command does not include `--mint-url`. An existing wallet keeps whatever default it already has; the shipped mints are only added as trusted, never as the default. Use `routstrd wallet mints add ` to trust another mint. ### `routstrd wallet status` @@ -260,7 +260,7 @@ Pay a Lightning invoice. ### `routstrd wallet mints list` -List configured wallet mints. +List configured wallet mints. Includes the mints trusted by default (`https://mint.cubabitcoin.org`, `https://mint.minibits.cash/Bitcoin`) plus any added manually. ### `routstrd wallet mints add ` diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index 2a44170..5c11927 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -14,6 +14,7 @@ import { assertLegacyCocodNotRunning, claimLegacyCocodPidFile, createCocoClient, + DEFAULT_TRUSTED_MINT_URLS, isZombieProcess, settleExpiredMintQuotes, settlePendingMintQuotes, @@ -64,7 +65,7 @@ async function waitForWalletUnlocked( } describe("default mint functionality", () => { - it("automatically adds default mint when no mints exist", async () => { + it("automatically adds the shipped trusted mints when no mints exist", async () => { const walletDir = join(makeTempDir(), "wallet"); mkdirSync(walletDir, { recursive: true }); writeFileSync( @@ -90,9 +91,12 @@ describe("default mint functionality", () => { String(process.pid), ); + // Every shipped mint is trusted, so each one is usable without an + // explicit `wallet mints add`. const mints = await client.listMints(); - expect(mints).toContain("https://mint.cubabitcoin.org"); + expect(mints).toEqual(expect.arrayContaining([...DEFAULT_TRUSTED_MINT_URLS])); + // Cuba still owns the default slot even though minibits is trusted too. const defaultMint = await client.getDefaultMint(); expect(defaultMint).toBe("https://mint.cubabitcoin.org"); } finally { diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 53c0141..c8077c8 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -58,6 +58,9 @@ import { walletDir as defaultWalletDir, walletPidPath as defaultWalletPidPath, } from "./paths"; +import { DEFAULT_MINT_URL, seedTrustedMints } from "./trusted-mints"; + +export { DEFAULT_MINT_URL, DEFAULT_TRUSTED_MINT_URLS } from "./trusted-mints"; const NPC_DEFAULT_BASE_URL = "https://npubx.cash"; @@ -117,7 +120,6 @@ interface CocodConfig { } const STARTUP_LOG_PREFIX = "[routstrd:start]"; -export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org"; function startupProgress(message: string): void { logger.info(message); @@ -1293,10 +1295,23 @@ export async function createCocoClient( configuredDefault || trustedMints[0]?.mintUrl || DEFAULT_MINT_URL, ); - if (!trustedMints.some((mint) => mint.mintUrl === defaultMintUrl)) { - startupProgress(`Adding default mint: ${defaultMintUrl}`); - await coco.mint.addMint(defaultMintUrl, { trusted: true }); - } + // Seeds the mints we ship as trusted. The default mint is strict (see + // seedTrustedMints); extra seeds only warn, so an unreachable mint that is + // not the default cannot stop the daemon from starting. + await seedTrustedMints( + { + trustedMints: trustedMints.map((mint) => mint.mintUrl), + addMint: (mintUrl) => coco!.mint.addMint(mintUrl, { trusted: true }), + }, + defaultMintUrl, + { + onProgress: startupProgress, + onError: (message, error) => + logger.warn(message, { + error: error instanceof Error ? error.message : String(error), + }), + }, + ); // Persist only after the mint was successfully fetched and trusted. A failed // network request must not leave config pointing at an unusable default. diff --git a/src/daemon/wallet/trusted-mints.test.ts b/src/daemon/wallet/trusted-mints.test.ts new file mode 100644 index 0000000..90ac4b8 --- /dev/null +++ b/src/daemon/wallet/trusted-mints.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "bun:test"; +import { + DEFAULT_MINT_URL, + DEFAULT_TRUSTED_MINT_URLS, + seedTrustedMints, + type TrustedMintSeeder, +} from "./trusted-mints"; + +interface Harness { + wallet: TrustedMintSeeder; + added: string[]; + progress: string[]; + errors: { message: string; error: unknown }[]; +} + +function makeHarness( + trustedMints: string[] = [], + failing: string[] = [], +): Harness { + const added: string[] = []; + const progress: string[] = []; + const errors: { message: string; error: unknown }[] = []; + const wallet: TrustedMintSeeder = { + trustedMints, + addMint: async (mintUrl) => { + if (failing.includes(mintUrl)) { + throw new Error(`Failed to fetch mint ${mintUrl}`); + } + added.push(mintUrl); + }, + }; + return { wallet, added, progress, errors }; +} + +describe("seedTrustedMints", () => { + it("seeds every shipped mint for a wallet that trusts none", async () => { + const { wallet, added, progress } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onProgress: (message) => progress.push(message), + }); + + expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]); + expect(progress).toEqual([ + `Adding default mint: ${DEFAULT_MINT_URL}`, + "Adding trusted mint: https://mint.minibits.cash/Bitcoin", + ]); + }); + + it("preserves the casing and path of seeded mint URLs", async () => { + const { wallet, added } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + // The Bitcoin path segment of the minibits mint is case sensitive; + // lowercasing it breaks the mint. + expect(added).toContain("https://mint.minibits.cash/Bitcoin"); + }); + + it("skips mints that are already trusted", async () => { + const { wallet, added } = makeHarness([ + DEFAULT_MINT_URL, + "https://mint.minibits.cash/Bitcoin", + ]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + expect(added).toEqual([]); + }); + + it("treats a trailing slash on a stored mint as already trusted", async () => { + const { wallet, added } = makeHarness([ + `${DEFAULT_MINT_URL}/`, + "https://mint.minibits.cash/Bitcoin/", + ]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL); + + expect(added).toEqual([]); + }); + + it("deduplicates the default mint when it also appears in the seeds", async () => { + const { wallet, added } = makeHarness(); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + seeds: [DEFAULT_MINT_URL, DEFAULT_MINT_URL], + }); + + expect(added).toEqual([DEFAULT_MINT_URL]); + }); + + it("seeds the extras even when the wallet has a different default", async () => { + const { wallet, added } = makeHarness(); + const customDefault = "https://mint.example.com"; + + await seedTrustedMints(wallet, customDefault); + + // The wallet's own default stays first; the shipped seeds are added after + // it and never take the default slot. + expect(added).toEqual([ + customDefault, + ...DEFAULT_TRUSTED_MINT_URLS.filter((url) => url !== customDefault), + ]); + }); + + it("fails startup when the default mint cannot be fetched", async () => { + const { wallet, added } = makeHarness([], [DEFAULT_MINT_URL]); + + await expect( + seedTrustedMints(wallet, `${DEFAULT_MINT_URL}/`), + ).rejects.toThrow(`Failed to fetch mint ${DEFAULT_MINT_URL}`); + // The default is seeded first, so the extras were never attempted. + expect(added).toEqual([]); + }); + + it("keeps going when a non-default mint cannot be fetched", async () => { + const unavailable = "https://mint.minibits.cash/Bitcoin"; + const { wallet, added, errors } = makeHarness([], [unavailable]); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onError: (message, error) => errors.push({ message, error }), + }); + + expect(added).toEqual([DEFAULT_MINT_URL]); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toBe(`Could not add trusted mint ${unavailable}`); + }); + + it("ignores stored mint URLs that cannot be normalized", async () => { + const { wallet, added, errors } = makeHarness(["not a mint url"], []); + + await seedTrustedMints(wallet, DEFAULT_MINT_URL, { + onError: (message, error) => errors.push({ message, error }), + }); + + expect(added).toEqual([...DEFAULT_TRUSTED_MINT_URLS]); + expect(errors).toEqual([]); + }); +}); \ No newline at end of file diff --git a/src/daemon/wallet/trusted-mints.ts b/src/daemon/wallet/trusted-mints.ts new file mode 100644 index 0000000..9db640c --- /dev/null +++ b/src/daemon/wallet/trusted-mints.ts @@ -0,0 +1,87 @@ +import { normalizeMintUrl } from "@cashu/coco-core"; + +/** + * Mint used as the default for wallets that have no configured default. It is + * always trusted, and a wallet is never allowed to point its default at a mint + * it could not fetch. + */ +export const DEFAULT_MINT_URL = "https://mint.cubabitcoin.org"; + +/** + * Mints routstrd trusts out of the box. Every entry is added as a trusted mint + * on startup so users can send/receive without an explicit + * `wallet mints add`. `DEFAULT_MINT_URL` is listed first because it seeds the + * default mint of a fresh wallet; extra entries never change an existing + * default. + */ +export const DEFAULT_TRUSTED_MINT_URLS: readonly string[] = [ + DEFAULT_MINT_URL, + "https://mint.minibits.cash/Bitcoin", +]; + +export interface TrustedMintSeeder { + /** Mint URLs the wallet currently trusts. */ + trustedMints: readonly string[]; + /** Trust a mint, fetching its info and keysets from the mint itself. */ + addMint: (mintUrl: string) => Promise; +} + +export interface SeedTrustedMintsOptions { + /** Mints to ensure are trusted, in order. Defaults to the shipped seeds. */ + seeds?: readonly string[]; + /** Called before each mint fetch with a user-facing progress message. */ + onProgress?: (message: string) => void; + /** Called when a non-default seed could not be added. */ + onError?: (message: string, error: unknown) => void; +} + +// Stored and configured mint URLs come from SQLite and JSON, so a malformed +// value must never crash startup. Normalization only strips the default port +// and a trailing slash, so falling back to the raw string keeps comparisons +// meaningful. +function safeNormalizeMintUrl(mintUrl: string): string { + try { + return normalizeMintUrl(mintUrl); + } catch { + return mintUrl; + } +} + +/** + * Ensure the mint seeds routstrd ships are trusted, without ever moving a + * wallet's default away from `defaultMintUrl`. + * + * The default mint is seeded strictly: if it cannot be fetched the error is + * rethrown, because persisting an unusable default is worse than failing + * startup. Every other seed is best-effort — sending the mint fetch failure to + * `onError` — so a single unreachable mint cannot keep the daemon down. + */ +export async function seedTrustedMints( + wallet: TrustedMintSeeder, + defaultMintUrl: string, + options: SeedTrustedMintsOptions = {}, +): Promise { + const seeds = options.seeds ?? DEFAULT_TRUSTED_MINT_URLS; + const target = safeNormalizeMintUrl(defaultMintUrl); + const trusted = new Set(wallet.trustedMints.map(safeNormalizeMintUrl)); + const attempted = new Set(); + + for (const seed of [defaultMintUrl, ...seeds]) { + const mintUrl = safeNormalizeMintUrl(seed); + if (attempted.has(mintUrl)) continue; + attempted.add(mintUrl); + if (trusted.has(mintUrl)) continue; + + const isDefault = mintUrl === target; + try { + options.onProgress?.( + `Adding ${isDefault ? "default" : "trusted"} mint: ${mintUrl}`, + ); + await wallet.addMint(mintUrl); + trusted.add(mintUrl); + } catch (error) { + if (isDefault) throw error; + options.onError?.(`Could not add trusted mint ${mintUrl}`, error); + } + } +} \ No newline at end of file From b503f1d01b315dd4df303355c4a229f1b4cbce1d Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:57:59 +0200 Subject: [PATCH 5/5] fix: pin supportsDeveloperRole=false for deepseek* models in pi integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi sends the system prompt as role "developer" (OpenAI's newer spelling) for reasoning models on unrecognized providers — its heuristics only see routstrd's local URL and can't know DeepSeek sits behind it. Strict upstream deserializers reject the whole chat body with a hard 400: messages[0].role: unknown variant `developer`, expected one of `system`, `user`, `assistant`, `tool`, ... installPiIntegration rebuilds the routstr provider on every refresh, so the pin must be generated: deepseek* models always get compat.supportsDeveloperRole=false, with any other user-curated compat keys preserved. Non-deepseek models keep the previous preserve-as-is behavior. --- src/integrations/pi.ts | 19 +++++- tests/integrations/pi.test.ts | 112 ++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/integrations/pi.test.ts diff --git a/src/integrations/pi.ts b/src/integrations/pi.ts index 9f13045..3dd9ea1 100644 --- a/src/integrations/pi.ts +++ b/src/integrations/pi.ts @@ -70,11 +70,20 @@ export async function installPiIntegration( // Rebuild every model entry from scratch from the daemon, so the generated // models.json is always a faithful projection of the daemon's state. The only // exception is thinking/reasoning config (reasoning, thinkingLevelMap, compat), - // which the daemon does not provide and the user curates by hand — preserve it. + // which the daemon does not provide and the user curates by hand — preserve it + // (except for the deepseek* compat pin below, which is managed for the user). const existingModels = new Map( (piConfig.providers["routstr"]?.models ?? []).map((m) => [m.id, m]), ); + // DeepSeek-backed models reject the `developer` role (OpenAI's newer + // spelling of `system`) on strict upstreams with a hard 400. Pi sends + // `developer` for reasoning models on unrecognized providers because its + // provider heuristics only see the local daemon URL and can't know + // DeepSeek sits behind it — force the universally-accepted `system` + // spelling for every deepseek* model. + const isDeepSeekModel = (id: string): boolean => id.startsWith("deepseek"); + const providerModels: PiModelEntry[] = models.map((model) => { const previous = existingModels.get(model.id); const entry: PiModelEntry = { id: model.id }; @@ -97,7 +106,13 @@ export async function installPiIntegration( // Preserve user-curated thinking fields from the previous entry. if (previous?.reasoning !== undefined) entry.reasoning = previous.reasoning; if (previous?.thinkingLevelMap !== undefined) entry.thinkingLevelMap = previous.thinkingLevelMap; - if (previous?.compat !== undefined) entry.compat = previous.compat; + if (isDeepSeekModel(model.id)) { + // Authoritative for deepseek* models: keep any other user-set compat + // keys, but always pin supportsDeveloperRole to false. + entry.compat = { ...(previous?.compat ?? {}), supportsDeveloperRole: false }; + } else if (previous?.compat !== undefined) { + entry.compat = previous.compat; + } return entry; }); diff --git a/tests/integrations/pi.test.ts b/tests/integrations/pi.test.ts new file mode 100644 index 0000000..b82b0cf --- /dev/null +++ b/tests/integrations/pi.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, mock } from "bun:test"; +import { mkdtempSync, readFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import type { IntegrationConfig } from "../../src/integrations/registry"; +import { installPiIntegration } from "../../src/integrations/pi"; +import type { RoutstrdConfig } from "../../src/utils/config"; + +// Install the mock before importing modules that pull it in transitively. +mock.module("../../src/utils/daemon-client", () => ({ + callDaemon: async () => ({ + output: { + models: [ + { + id: "deepseek-v4.1-flash", + name: "DeepSeek V4.1 Flash", + context_length: 1048576, + architecture: { input_modalities: ["text"] }, + }, + { + id: "glm-5.3", + name: "GLM 5.3", + context_length: 262144, + architecture: { input_modalities: ["text", "image"] }, + }, + ], + }, + }), + getDaemonBaseUrl: (config: RoutstrdConfig) => + `http://127.0.0.1:${config.port}`, +})); + +const CONFIG: RoutstrdConfig = { port: 8008 } as RoutstrdConfig; + +function makeIntegration(configPath: string): IntegrationConfig { + return { clientId: "pi-agent", name: "Pi Agent", configPath }; +} + +async function readProvider(configPath: string) { + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { + providers: Record> }>; + }; + return parsed.providers["routstr"]; +} + +describe("installPiIntegration", () => { + it("pins supportsDeveloperRole=false for deepseek* models", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + + const provider = await readProvider(configPath); + const deepseek = provider.models.find((m) => m.id === "deepseek-v4.1-flash"); + expect(deepseek?.compat).toEqual({ supportsDeveloperRole: false }); + }); + + it("leaves non-deepseek models without a compat block", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + + const provider = await readProvider(configPath); + const glm = provider.models.find((m) => m.id === "glm-5.3"); + expect(glm?.compat).toBeUndefined(); + }); + + it("preserves user compat keys on deepseek* models while pinning the role", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + + // Simulate a user-curated refresh: seed reasoning/compat, run again. + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { + providers: Record> }>; + }; + const deepseek = parsed.providers["routstr"].models.find( + (m) => m.id === "deepseek-v4.1-flash", + ); + deepseek!.reasoning = true; + deepseek!.compat = { supportsStrictMode: true }; + const { writeFileSync } = await import("fs"); + writeFileSync(configPath, JSON.stringify(parsed)); + + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + const provider = await readProvider(configPath); + const updated = provider.models.find((m) => m.id === "deepseek-v4.1-flash"); + expect(updated?.compat).toEqual({ + supportsStrictMode: true, + supportsDeveloperRole: false, + }); + expect(updated?.reasoning).toBe(true); + }); + + it("preserves compat untouched for non-deepseek models across refreshes", async () => { + const dir = mkdtempSync(join(tmpdir(), "pi-models-")); + const configPath = join(dir, "models.json"); + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + + const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as { + providers: Record> }>; + }; + const glm = parsed.providers["routstr"].models.find((m) => m.id === "glm-5.3"); + glm!.compat = { supportsDeveloperRole: true }; + const { writeFileSync } = await import("fs"); + writeFileSync(configPath, JSON.stringify(parsed)); + + await installPiIntegration(CONFIG, "key", makeIntegration(configPath)); + const provider = await readProvider(configPath); + const updated = provider.models.find((m) => m.id === "glm-5.3"); + expect(updated?.compat).toEqual({ supportsDeveloperRole: true }); + }); +});