routstrd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "routstrd",
3
+ "version": "0.1.0",
4
+ "module": "src/index.ts",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "routstrd": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "bun src/index.ts start",
12
+ "monitor": "bun src/index.ts monitor",
13
+ "lint": "tsc --noEmit",
14
+ "test": "bun test",
15
+ "build": "bun build src/index.ts --target=bun --outfile=dist/index.js",
16
+ "prepublishOnly": "bun run build"
17
+ },
18
+ "devDependencies": {
19
+ "@routstr/sdk": "../routstr-chat/sdk",
20
+ "@types/bun": "latest"
21
+ },
22
+ "peerDependencies": {
23
+ "typescript": "^5"
24
+ },
25
+ "dependencies": {
26
+ "@cashu/cashu-ts": "^3.1.1",
27
+ "@routstr/sdk": "^0.2.5",
28
+ "applesauce-core": "^5.1.0",
29
+ "applesauce-relay": "^5.1.0",
30
+ "commander": "^14.0.2",
31
+ "rxjs": "^7.8.1",
32
+ "zustand": "^5.0.5"
33
+ }
34
+ }
@@ -0,0 +1,71 @@
1
+ # routstr Proxy Cost Logging Issue
2
+
3
+ ## Problem
4
+
5
+ The routstr proxy at `localhost:8009` returns usage data that differs slightly from what the pi-ai `openai-completions` provider expects.
6
+
7
+ ## What routstr Returns
8
+
9
+ **Streaming response (last chunk):**
10
+ ```json
11
+ {
12
+ "usage": {
13
+ "prompt_tokens": 9,
14
+ "completion_tokens": 9,
15
+ "total_tokens": 18,
16
+ "cost": 0.000018,
17
+ "prompt_tokens_details": {
18
+ "cached_tokens": 0,
19
+ "cache_write_tokens": 0
20
+ },
21
+ "completion_tokens_details": {
22
+ "reasoning_tokens": 0
23
+ }
24
+ },
25
+ "cost": {
26
+ "total_usd": 0.000018
27
+ }
28
+ }
29
+ ```
30
+
31
+ ## What pi-ai Expects
32
+
33
+ The `openai-completions` provider in pi-ai expects the standard OpenAI format:
34
+
35
+ ```json
36
+ {
37
+ "usage": {
38
+ "prompt_tokens": 9,
39
+ "completion_tokens": 9,
40
+ "prompt_tokens_details": {
41
+ "cached_tokens": 0
42
+ },
43
+ "completion_tokens_details": {
44
+ "reasoning_tokens": 0
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ ## Current Handling
51
+
52
+ The pi-ai provider already handles the standard format correctly via `parseChunkUsage()`:
53
+ - `input` ← `prompt_tokens - cached_tokens`
54
+ - `output` ← `completion_tokens + reasoning_tokens`
55
+ - `cacheRead` ← `prompt_tokens_details.cached_tokens`
56
+ - `cacheWrite` ← **NOT CURRENTLY PARSED** (hardcoded to 0)
57
+
58
+ The provider then calculates cost using `calculateCost()` based on the model's configured cost per million tokens, ignoring any `cost` field from the response.
59
+
60
+ ## Gap
61
+
62
+ The `parseChunkUsage()` function in `packages/ai/src/providers/openai-completions.ts` does not currently extract:
63
+ 1. `cache_write_tokens` from `prompt_tokens_details` (routstr-specific field)
64
+
65
+ Currently `cacheWrite` is hardcoded to 0.
66
+
67
+ ## Resolution
68
+
69
+ 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:
70
+ 1. `stream_options: { include_usage: true }` is passed
71
+ 2. The model has a `cost` configuration in the registry
@@ -0,0 +1,113 @@
1
+ # TUI refactor plan
2
+
3
+ ## Goals
4
+ - Move the usage TUI implementation out of `src/cli/usage-tui.ts` into a dedicated `src/tui/` folder.
5
+ - Reduce the size and responsibility of the current monolithic file.
6
+ - Keep the existing CLI entrypoint stable so current usage does not break.
7
+ - Preserve behavior while making future TUI work easier.
8
+
9
+ ## Current state
10
+ `src/cli/usage-tui.ts` currently mixes several concerns in one file:
11
+ - TUI-specific types and constants
12
+ - ANSI/terminal helpers
13
+ - scroll/search/vim navigation state
14
+ - data fetching from the daemon
15
+ - usage aggregation/stat helpers
16
+ - rendering for all tabs
17
+ - app lifecycle and keyboard event handling
18
+
19
+ This makes the file hard to extend safely.
20
+
21
+ ## Refactor strategy
22
+ Do this incrementally and keep a thin compatibility wrapper in `src/cli/usage-tui.ts`.
23
+
24
+ ### Target structure
25
+ - `src/tui/usage/index.ts`
26
+ - public entrypoint: `runUsageTui()`
27
+ - `src/tui/usage/types.ts`
28
+ - `UsageStats`, tab ids, tab metadata, derived stat types
29
+ - `src/tui/usage/constants.ts`
30
+ - tabs, colors, model/client color maps
31
+ - `src/tui/usage/terminal.ts`
32
+ - ANSI helpers, width/height helpers, `stripAnsi`
33
+ - `src/tui/usage/state.ts`
34
+ - vim/search/scroll state and state mutation helpers
35
+ - `src/tui/usage/data.ts`
36
+ - `fetchUsage()` and usage aggregation helpers
37
+ - `src/tui/usage/render.ts`
38
+ - shared render helpers and tab renderers
39
+ - `src/tui/usage/app.ts`
40
+ - main loop, render orchestration, input handling, cleanup
41
+ - `src/cli/usage-tui.ts`
42
+ - compatibility wrapper that re-exports or calls `runUsageTui()` from `src/tui/usage`
43
+
44
+ ## Design choices
45
+ ### 1. Keep CLI path compatibility
46
+ Do not delete the CLI file outright. Turn it into a tiny wrapper:
47
+ - minimal import from `../tui/usage/index.ts`
48
+ - export `runUsageTui()`
49
+
50
+ This avoids breaking any existing imports or scripts.
51
+
52
+ ### 2. Separate pure logic from side effects
53
+ Keep these pure where possible:
54
+ - aggregation helpers
55
+ - formatting helpers
56
+ - render helpers that return strings
57
+ - scroll clamping logic
58
+
59
+ Keep side effects isolated in the app layer:
60
+ - reading terminal size
61
+ - writing to stdout
62
+ - raw mode setup
63
+ - signal handling
64
+ - interval scheduling
65
+
66
+ ### 3. Avoid over-engineering
67
+ This should be a pragmatic refactor, not a framework:
68
+ - no unnecessary classes
69
+ - keep function-based design
70
+ - only extract modules around clear responsibility boundaries
71
+
72
+ ### 4. Preserve behavior first
73
+ No UX changes unless needed to support the extraction.
74
+ That means:
75
+ - same tabs
76
+ - same keybindings
77
+ - same output format
78
+ - same fetch cadence
79
+ - same search/scroll behavior
80
+
81
+ ## Implementation steps
82
+ 1. Create `src/tui/usage/`.
83
+ 2. Extract types/constants first.
84
+ 3. Extract terminal helpers.
85
+ 4. Extract data fetching + aggregation helpers.
86
+ 5. Extract state/search/scroll logic.
87
+ 6. Extract rendering helpers + tab renderers.
88
+ 7. Build `app.ts` using the extracted modules.
89
+ 8. Replace `src/cli/usage-tui.ts` with a thin wrapper.
90
+ 9. Run a TypeScript/bun check and fix imports.
91
+ 10. Smoke-test keyboard handling and rendering behavior.
92
+
93
+ ## Risks
94
+ - circular imports between render/state/constants
95
+ - broken relative import paths during extraction
96
+ - subtle behavior regressions in scroll/search state
97
+ - terminal escape handling differences if helpers are split carelessly
98
+
99
+ ## Validation checklist
100
+ - `src/cli/usage-tui.ts` still exposes `runUsageTui()`
101
+ - TUI starts from the same CLI path
102
+ - scroll still works for long content
103
+ - vim keys still work
104
+ - arrow keys still work
105
+ - tab switching still resets scroll
106
+ - search mode still works
107
+ - cleanup still restores cursor and alternate screen
108
+
109
+ ## Non-goals
110
+ - redesigning the UI
111
+ - changing tab contents
112
+ - introducing tests unless needed for safety
113
+ - adding new features unrelated to the refactor
@@ -0,0 +1,204 @@
1
+ import { program } from "commander";
2
+ import { existsSync } from "fs";
3
+ import {
4
+ CONFIG_FILE,
5
+ DEFAULT_CONFIG,
6
+ LOG_FILE,
7
+ type RoutstrdConfig,
8
+ } from "./utils/config";
9
+
10
+ export interface CommandResponse {
11
+ output?: unknown;
12
+ error?: string;
13
+ }
14
+
15
+ export async function loadConfig(): Promise<RoutstrdConfig> {
16
+ try {
17
+ if (existsSync(CONFIG_FILE)) {
18
+ const content = await Bun.file(CONFIG_FILE).text();
19
+ return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
20
+ }
21
+ } catch (error) {
22
+ console.error("Failed to load config:", error);
23
+ }
24
+ return DEFAULT_CONFIG;
25
+ }
26
+
27
+ async function callDaemon(
28
+ path: string,
29
+ options: { method?: "GET" | "POST"; body?: object } = {},
30
+ ): Promise<CommandResponse> {
31
+ const { method = "GET", body } = options;
32
+ const config = await loadConfig();
33
+
34
+ const response = await fetch(`http://localhost:${config.port}${path}`, {
35
+ method,
36
+ headers: body ? { "Content-Type": "application/json" } : {},
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+
40
+ if (!response.ok) {
41
+ const errorData = (await response.json()) as { error?: string };
42
+ throw new Error(errorData.error || `HTTP ${response.status}`);
43
+ }
44
+
45
+ return response.json() as Promise<CommandResponse>;
46
+ }
47
+
48
+ export async function isDaemonRunning(): Promise<boolean> {
49
+ try {
50
+ const config = await loadConfig();
51
+ const response = await fetch(`http://localhost:${config.port}/health`);
52
+ return response.ok;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ export async function startDaemonProcess(): Promise<void> {
59
+ const logFile = Bun.file(LOG_FILE);
60
+
61
+ const proc = Bun.spawn([
62
+ "bun", "run", `${import.meta.dir}/daemon/index.ts`
63
+ ], {
64
+ stdout: logFile,
65
+ stderr: logFile,
66
+ stdin: "ignore",
67
+ detached: true,
68
+ });
69
+
70
+ proc.unref();
71
+
72
+ for (let i = 0; i < 50; i++) {
73
+ await new Promise((resolve) => setTimeout(resolve, 100));
74
+ if (await isDaemonRunning()) {
75
+ return;
76
+ }
77
+ }
78
+
79
+ throw new Error("Daemon failed to start within 5 seconds");
80
+ }
81
+
82
+ export async function ensureDaemonRunning(): Promise<void> {
83
+ if (await isDaemonRunning()) {
84
+ return;
85
+ }
86
+
87
+ console.log("Starting daemon...");
88
+ await startDaemonProcess();
89
+ }
90
+
91
+ export async function handleDaemonCommand(
92
+ path: string,
93
+ options: { method?: "GET" | "POST"; body?: object } = {},
94
+ ): Promise<CommandResponse> {
95
+ try {
96
+ await ensureDaemonRunning();
97
+ const result = await callDaemon(path, options);
98
+
99
+ if (result.error) {
100
+ console.log(result.error);
101
+ process.exit(1);
102
+ }
103
+
104
+ if (result.output !== undefined) {
105
+ if (typeof result.output === "string") {
106
+ console.log(result.output);
107
+ } else {
108
+ try {
109
+ const formatted = JSON.stringify(result.output, null, 2);
110
+ console.log(formatted ?? String(result.output));
111
+ } catch {
112
+ console.log(String(result.output));
113
+ }
114
+ }
115
+ }
116
+
117
+ return result;
118
+ } catch (error) {
119
+ const message = (error as Error).message;
120
+ if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
121
+ console.error("Daemon is not running and failed to auto-start");
122
+ process.exit(1);
123
+ }
124
+ console.error(message);
125
+ process.exit(1);
126
+ }
127
+ }
128
+
129
+ export { program, callDaemon };
130
+
131
+ program
132
+ .command("refund")
133
+ .description("Refund pending tokens and API keys to a specified mint")
134
+ .option("-m, --mint-url <mintUrl>", "Mint URL to refund to (defaults to first mint in wallet)")
135
+ .option("-y, --yes", "Skip confirmation prompt", false)
136
+ .action(async (options: { mintUrl?: string; yes: boolean }) => {
137
+ const config = await loadConfig();
138
+
139
+ let mintUrl = options.mintUrl;
140
+ if (!mintUrl) {
141
+ const balanceResponse = await fetch(`http://localhost:${config.port}/balance`);
142
+ const balanceResult = (await balanceResponse.json()) as {
143
+ output?: { balances?: Record<string, number> };
144
+ error?: string;
145
+ };
146
+ if (balanceResult.error) {
147
+ console.log(balanceResult.error);
148
+ process.exit(1);
149
+ }
150
+ const balances = balanceResult.output?.balances;
151
+ if (!balances || Object.keys(balances).length === 0) {
152
+ console.log("No mint URLs found in wallet balance");
153
+ process.exit(1);
154
+ }
155
+ mintUrl = Object.keys(balances)[0];
156
+ console.log(`Using mint URL: ${mintUrl}`);
157
+ }
158
+
159
+ try {
160
+ const response = await fetch(`http://localhost:${config.port}/refund`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ mintUrl }),
164
+ });
165
+
166
+ if (!response.ok) {
167
+ const errorData = (await response.json()) as { error?: string };
168
+ throw new Error(errorData.error || `HTTP ${response.status}`);
169
+ }
170
+
171
+ const result = (await response.json()) as {
172
+ output?: {
173
+ message: string;
174
+ pendingTokens: number;
175
+ apiKeys: number;
176
+ results: Array<{ baseUrl: string; success: boolean }>;
177
+ };
178
+ error?: string;
179
+ };
180
+
181
+ if (result.error) {
182
+ console.log(result.error);
183
+ process.exit(1);
184
+ }
185
+
186
+ if (result.output) {
187
+ console.log(result.output.message);
188
+ console.log(`\nPending tokens: ${result.output.pendingTokens}`);
189
+ console.log(`API keys: ${result.output.apiKeys}`);
190
+ console.log("\nResults:");
191
+ for (const r of result.output.results) {
192
+ console.log(` - ${r.baseUrl}: ${r.success ? "success" : "failed"}`);
193
+ }
194
+ }
195
+ } catch (error) {
196
+ const message = (error as Error).message;
197
+ if (message?.includes("fetch failed") || message?.includes("Connection refused")) {
198
+ console.error("Daemon is not running");
199
+ process.exit(1);
200
+ }
201
+ console.error(message);
202
+ process.exit(1);
203
+ }
204
+ });