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/.claude/settings.local.json +7 -0
- package/README.md +191 -0
- package/bun.lock +376 -0
- package/dist/index.js +27019 -0
- package/package.json +34 -0
- package/routstr-cost-logging.md +71 -0
- package/src/TUI refactor.md +113 -0
- package/src/cli-shared.ts +204 -0
- package/src/cli.ts +650 -0
- package/src/daemon/args.ts +19 -0
- package/src/daemon/config-store.ts +36 -0
- package/src/daemon/http/index.ts +608 -0
- package/src/daemon/index.ts +151 -0
- package/src/daemon/models.ts +49 -0
- package/src/daemon/sse.ts +98 -0
- package/src/daemon/types.ts +25 -0
- package/src/daemon/wallet/index.ts +207 -0
- package/src/daemon.ts +1 -0
- package/src/index.ts +4 -0
- package/src/integrations/index.ts +67 -0
- package/src/integrations/openclaw.ts +177 -0
- package/src/integrations/opencode.ts +120 -0
- package/src/integrations/pi.ts +116 -0
- package/src/start-daemon.ts +90 -0
- package/src/tui/usage/app.ts +247 -0
- package/src/tui/usage/constants.ts +42 -0
- package/src/tui/usage/data.ts +228 -0
- package/src/tui/usage/index.ts +1 -0
- package/src/tui/usage/render.ts +539 -0
- package/src/tui/usage/state.ts +100 -0
- package/src/tui/usage/terminal.ts +39 -0
- package/src/tui/usage/types.ts +65 -0
- package/src/utils/config.ts +22 -0
- package/src/utils/logger.ts +54 -0
- package/test_box.ts +15 -0
- package/test_curl.sh +11 -0
- package/test_split_box.ts +17 -0
- package/test_split_box2.ts +23 -0
- package/tsconfig.json +20 -0
- package/v1-messages-format-report.md +223 -0
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
|
+
});
|