@token2chat/t2c 0.2.0-beta.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/dist/adapters/aider.d.ts +5 -0
- package/dist/adapters/aider.js +29 -0
- package/dist/adapters/cline.d.ts +5 -0
- package/dist/adapters/cline.js +32 -0
- package/dist/adapters/continue.d.ts +5 -0
- package/dist/adapters/continue.js +45 -0
- package/dist/adapters/cursor.d.ts +5 -0
- package/dist/adapters/cursor.js +23 -0
- package/dist/adapters/env.d.ts +5 -0
- package/dist/adapters/env.js +25 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +6 -0
- package/dist/adapters/openclaw.d.ts +2 -0
- package/dist/adapters/openclaw.js +167 -0
- package/dist/cashu-store.d.ts +52 -0
- package/dist/cashu-store.js +201 -0
- package/dist/commands/audit.d.ts +6 -0
- package/dist/commands/audit.js +340 -0
- package/dist/commands/balance.d.ts +5 -0
- package/dist/commands/balance.js +29 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +62 -0
- package/dist/commands/connect.d.ts +1 -0
- package/dist/commands/connect.js +43 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +178 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +50 -0
- package/dist/commands/mint.d.ts +5 -0
- package/dist/commands/mint.js +168 -0
- package/dist/commands/recover.d.ts +1 -0
- package/dist/commands/recover.js +61 -0
- package/dist/commands/service.d.ts +7 -0
- package/dist/commands/service.js +378 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +128 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +87 -0
- package/dist/config.d.ts +83 -0
- package/dist/config.js +224 -0
- package/dist/connectors/cursor.d.ts +2 -0
- package/dist/connectors/cursor.js +28 -0
- package/dist/connectors/env.d.ts +2 -0
- package/dist/connectors/env.js +38 -0
- package/dist/connectors/index.d.ts +26 -0
- package/dist/connectors/index.js +30 -0
- package/dist/connectors/interface.d.ts +20 -0
- package/dist/connectors/interface.js +1 -0
- package/dist/connectors/openclaw.d.ts +2 -0
- package/dist/connectors/openclaw.js +202 -0
- package/dist/gate-discovery.d.ts +49 -0
- package/dist/gate-discovery.js +142 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +177 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +352 -0
- package/package.json +84 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate auto-discovery and failover.
|
|
3
|
+
*
|
|
4
|
+
* Fetches available Gates from token2.cash/gates.json,
|
|
5
|
+
* tracks health per Gate, and selects the best one per request.
|
|
6
|
+
*/
|
|
7
|
+
const DISCOVERY_URL = "https://token2.cash/gates.json";
|
|
8
|
+
const CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
|
9
|
+
const CIRCUIT_OPEN_MS = 60_000; // 1 minute circuit breaker
|
|
10
|
+
const MAX_FAIL_COUNT = 3;
|
|
11
|
+
export class GateRegistry {
|
|
12
|
+
gates = [];
|
|
13
|
+
health = new Map();
|
|
14
|
+
lastFetch = 0;
|
|
15
|
+
primaryUrl;
|
|
16
|
+
discoveryUrl;
|
|
17
|
+
constructor(primaryUrl, discoveryUrl = DISCOVERY_URL) {
|
|
18
|
+
this.primaryUrl = primaryUrl;
|
|
19
|
+
this.discoveryUrl = discoveryUrl;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch gates.json and update registry.
|
|
23
|
+
* Cached for CACHE_TTL_MS.
|
|
24
|
+
*/
|
|
25
|
+
async discover() {
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (this.gates.length > 0 && now - this.lastFetch < CACHE_TTL_MS) {
|
|
28
|
+
return this.gates;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(this.discoveryUrl, { signal: AbortSignal.timeout(8000) });
|
|
32
|
+
if (res.ok) {
|
|
33
|
+
const data = (await res.json());
|
|
34
|
+
if (data.gates && Array.isArray(data.gates)) {
|
|
35
|
+
this.gates = data.gates;
|
|
36
|
+
this.lastFetch = now;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Discovery failed — keep existing cache or fall back to primary
|
|
42
|
+
}
|
|
43
|
+
// Ensure primary gate is always in the list
|
|
44
|
+
if (!this.gates.some((g) => g.url === this.primaryUrl)) {
|
|
45
|
+
this.gates.unshift({
|
|
46
|
+
name: "primary",
|
|
47
|
+
url: this.primaryUrl,
|
|
48
|
+
mint: "",
|
|
49
|
+
providers: [],
|
|
50
|
+
models: ["*"],
|
|
51
|
+
markup: "0%",
|
|
52
|
+
description: "Configured primary gate",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return this.gates;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Select the best gate for a given model.
|
|
59
|
+
* Priority: primary gate > healthy gates that support the model > any healthy gate.
|
|
60
|
+
*/
|
|
61
|
+
async selectGate(model) {
|
|
62
|
+
await this.discover();
|
|
63
|
+
const now = Date.now();
|
|
64
|
+
const candidates = [];
|
|
65
|
+
// Primary first if healthy
|
|
66
|
+
const primaryEntry = this.gates.find((g) => g.url === this.primaryUrl);
|
|
67
|
+
if (primaryEntry && this.isHealthy(primaryEntry.url, now)) {
|
|
68
|
+
candidates.push(primaryEntry);
|
|
69
|
+
}
|
|
70
|
+
// Then other healthy gates
|
|
71
|
+
for (const gate of this.gates) {
|
|
72
|
+
if (gate.url === this.primaryUrl)
|
|
73
|
+
continue;
|
|
74
|
+
if (!this.isHealthy(gate.url, now))
|
|
75
|
+
continue;
|
|
76
|
+
// Check model support
|
|
77
|
+
if (model && gate.models.length > 0) {
|
|
78
|
+
const supports = gate.models.some((m) => m === "*" || m.includes("*") || model.includes(m) || m.includes(model.split("/").pop()));
|
|
79
|
+
if (!supports)
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
candidates.push(gate);
|
|
83
|
+
}
|
|
84
|
+
// If primary was unhealthy, add it as last resort
|
|
85
|
+
if (primaryEntry && !this.isHealthy(primaryEntry.url, now)) {
|
|
86
|
+
candidates.push(primaryEntry);
|
|
87
|
+
}
|
|
88
|
+
// Ensure at least primary is returned
|
|
89
|
+
if (candidates.length === 0) {
|
|
90
|
+
return [this.primaryUrl];
|
|
91
|
+
}
|
|
92
|
+
return candidates.map((g) => g.url);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Mark a gate as having failed.
|
|
96
|
+
*/
|
|
97
|
+
markFailed(gateUrl) {
|
|
98
|
+
const h = this.getHealth(gateUrl);
|
|
99
|
+
h.healthy = false;
|
|
100
|
+
h.failCount++;
|
|
101
|
+
h.lastFailure = Date.now();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Mark a gate as having succeeded.
|
|
105
|
+
*/
|
|
106
|
+
markSuccess(gateUrl) {
|
|
107
|
+
const h = this.getHealth(gateUrl);
|
|
108
|
+
h.healthy = true;
|
|
109
|
+
h.failCount = 0;
|
|
110
|
+
h.lastSuccess = Date.now();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get list of all known gates with health status.
|
|
114
|
+
*/
|
|
115
|
+
getAll() {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
return this.gates.map((g) => ({
|
|
118
|
+
...g,
|
|
119
|
+
healthy: this.isHealthy(g.url, now),
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
isHealthy(gateUrl, now) {
|
|
123
|
+
const h = this.health.get(gateUrl);
|
|
124
|
+
if (!h)
|
|
125
|
+
return true; // Unknown = healthy (optimistic)
|
|
126
|
+
if (h.healthy)
|
|
127
|
+
return true;
|
|
128
|
+
// Circuit breaker: re-try after CIRCUIT_OPEN_MS
|
|
129
|
+
if (h.failCount >= MAX_FAIL_COUNT && now - h.lastFailure < CIRCUIT_OPEN_MS) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true; // Half-open: allow retry
|
|
133
|
+
}
|
|
134
|
+
getHealth(gateUrl) {
|
|
135
|
+
let h = this.health.get(gateUrl);
|
|
136
|
+
if (!h) {
|
|
137
|
+
h = { healthy: true, failCount: 0, lastFailure: 0, lastSuccess: 0 };
|
|
138
|
+
this.health.set(gateUrl, h);
|
|
139
|
+
}
|
|
140
|
+
return h;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* t2c - Token2Chat CLI
|
|
4
|
+
*
|
|
5
|
+
* Pay-per-request LLM access via Cashu ecash
|
|
6
|
+
*/
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { initCommand } from "./commands/init.js";
|
|
9
|
+
import { connectCommand } from "./commands/connect.js";
|
|
10
|
+
import { setupCommand } from "./commands/setup.js";
|
|
11
|
+
import { statusCommand } from "./commands/status.js";
|
|
12
|
+
import { serviceCommand } from "./commands/service.js";
|
|
13
|
+
import { configCommand } from "./commands/config.js";
|
|
14
|
+
import { mintCommand } from "./commands/mint.js";
|
|
15
|
+
import { recoverCommand } from "./commands/recover.js";
|
|
16
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
17
|
+
import { balanceCommand } from "./commands/balance.js";
|
|
18
|
+
import { auditCommand } from "./commands/audit.js";
|
|
19
|
+
// debug command is loaded dynamically — excluded from npm package
|
|
20
|
+
const program = new Command();
|
|
21
|
+
program
|
|
22
|
+
.name("t2c")
|
|
23
|
+
.description("Pay-per-request LLM access via Cashu ecash")
|
|
24
|
+
.version("0.1.0");
|
|
25
|
+
// t2c init - Core initialization
|
|
26
|
+
program
|
|
27
|
+
.command("init")
|
|
28
|
+
.description("Initialize Token2Chat")
|
|
29
|
+
.option("-f, --force", "Reinitialize even if already configured")
|
|
30
|
+
.action((opts) => initCommand(opts));
|
|
31
|
+
// t2c connect <app> - Connect to AI tools
|
|
32
|
+
program
|
|
33
|
+
.command("connect [app]")
|
|
34
|
+
.description("Connect to an AI tool (openclaw, cursor, env)")
|
|
35
|
+
.action(connectCommand);
|
|
36
|
+
// t2c setup - Interactive setup wizard (legacy, points to init)
|
|
37
|
+
program
|
|
38
|
+
.command("setup")
|
|
39
|
+
.description("Interactive setup wizard (alias for init)")
|
|
40
|
+
.action(setupCommand);
|
|
41
|
+
// t2c status - Show service status and wallet balance
|
|
42
|
+
program
|
|
43
|
+
.command("status")
|
|
44
|
+
.description("Show service status and wallet balance")
|
|
45
|
+
.option("--json", "Output as JSON")
|
|
46
|
+
.action(statusCommand);
|
|
47
|
+
// t2c service - Manage the local proxy service
|
|
48
|
+
const service = program
|
|
49
|
+
.command("service")
|
|
50
|
+
.description("Manage the local proxy service");
|
|
51
|
+
service
|
|
52
|
+
.command("start")
|
|
53
|
+
.description("Start the proxy service")
|
|
54
|
+
.option("-f, --foreground", "Run in foreground (don't daemonize)")
|
|
55
|
+
.action((opts) => serviceCommand("start", opts));
|
|
56
|
+
service
|
|
57
|
+
.command("stop")
|
|
58
|
+
.description("Stop the proxy service")
|
|
59
|
+
.action(() => serviceCommand("stop", {}));
|
|
60
|
+
service
|
|
61
|
+
.command("restart")
|
|
62
|
+
.description("Restart the proxy service")
|
|
63
|
+
.action(() => serviceCommand("restart", {}));
|
|
64
|
+
service
|
|
65
|
+
.command("status")
|
|
66
|
+
.description("Show detailed service status")
|
|
67
|
+
.action(() => serviceCommand("status", {}));
|
|
68
|
+
service
|
|
69
|
+
.command("logs")
|
|
70
|
+
.description("Show service logs")
|
|
71
|
+
.option("-f, --follow", "Follow log output")
|
|
72
|
+
.option("-n, --lines <n>", "Number of lines to show", "50")
|
|
73
|
+
.action((opts) => serviceCommand("logs", opts));
|
|
74
|
+
service
|
|
75
|
+
.command("install")
|
|
76
|
+
.description("Install as system service (launchd on macOS, systemd on Linux)")
|
|
77
|
+
.action(() => serviceCommand("install", {}));
|
|
78
|
+
service
|
|
79
|
+
.command("uninstall")
|
|
80
|
+
.description("Uninstall system service")
|
|
81
|
+
.action(() => serviceCommand("uninstall", {}));
|
|
82
|
+
// t2c mint - Add funds to wallet
|
|
83
|
+
program
|
|
84
|
+
.command("mint [amount]")
|
|
85
|
+
.description("Show deposit address or mint ecash from pending deposits")
|
|
86
|
+
.option("--check", "Check for pending deposits and mint")
|
|
87
|
+
.action(mintCommand);
|
|
88
|
+
// t2c recover - Recover failed tokens
|
|
89
|
+
program
|
|
90
|
+
.command("recover")
|
|
91
|
+
.description("Recover failed change/refund tokens")
|
|
92
|
+
.action(recoverCommand);
|
|
93
|
+
// t2c doctor - Self-diagnostic command
|
|
94
|
+
program
|
|
95
|
+
.command("doctor")
|
|
96
|
+
.description("Run diagnostics and check all components")
|
|
97
|
+
.action(doctorCommand);
|
|
98
|
+
// t2c balance - Simple balance display
|
|
99
|
+
program
|
|
100
|
+
.command("balance")
|
|
101
|
+
.description("Show wallet balance")
|
|
102
|
+
.option("--json", "Output as JSON")
|
|
103
|
+
.action(balanceCommand);
|
|
104
|
+
// t2c audit - Full-chain fund visualization
|
|
105
|
+
program
|
|
106
|
+
.command("audit")
|
|
107
|
+
.description("Full-chain fund audit: wallet + mint + gate + transactions")
|
|
108
|
+
.option("--json", "Output as JSON")
|
|
109
|
+
.option("-n, --lines <n>", "Number of recent transactions to show", "20")
|
|
110
|
+
.action(auditCommand);
|
|
111
|
+
// t2c config - Generate config for AI tools
|
|
112
|
+
const config = program
|
|
113
|
+
.command("config")
|
|
114
|
+
.description("Generate config for AI tools");
|
|
115
|
+
config
|
|
116
|
+
.command("openclaw")
|
|
117
|
+
.description("Generate OpenClaw configuration")
|
|
118
|
+
.option("--apply", "Apply config directly to openclaw.json")
|
|
119
|
+
.option("--json", "Output as JSON (for manual editing)")
|
|
120
|
+
.action((opts) => configCommand("openclaw", opts));
|
|
121
|
+
config
|
|
122
|
+
.command("cursor")
|
|
123
|
+
.description("Generate Cursor configuration")
|
|
124
|
+
.action((opts) => configCommand("cursor", opts));
|
|
125
|
+
config
|
|
126
|
+
.command("cline")
|
|
127
|
+
.description("Generate Cline VS Code extension configuration")
|
|
128
|
+
.option("--json", "Output as JSON")
|
|
129
|
+
.action((opts) => configCommand("cline", opts));
|
|
130
|
+
config
|
|
131
|
+
.command("continue")
|
|
132
|
+
.description("Generate Continue.dev configuration")
|
|
133
|
+
.option("--json", "Output as JSON")
|
|
134
|
+
.action((opts) => configCommand("continue", opts));
|
|
135
|
+
config
|
|
136
|
+
.command("aider")
|
|
137
|
+
.description("Generate Aider configuration")
|
|
138
|
+
.option("--json", "Output as JSON")
|
|
139
|
+
.action((opts) => configCommand("aider", opts));
|
|
140
|
+
config
|
|
141
|
+
.command("env")
|
|
142
|
+
.description("Output environment variables for generic OpenAI-compatible tools")
|
|
143
|
+
.action((opts) => configCommand("env", opts));
|
|
144
|
+
config
|
|
145
|
+
.command("list")
|
|
146
|
+
.description("List supported AI tools")
|
|
147
|
+
.action(() => configCommand("list", {}));
|
|
148
|
+
// t2c debug — dev-only commands, dynamically loaded (excluded from npm package)
|
|
149
|
+
try {
|
|
150
|
+
const { debugCommand } = await import("./commands/debug.js");
|
|
151
|
+
const debug = program
|
|
152
|
+
.command("debug")
|
|
153
|
+
.description("⚠️ Debug/testing commands (dev only)")
|
|
154
|
+
.action(() => debugCommand("help"));
|
|
155
|
+
debug
|
|
156
|
+
.command("force")
|
|
157
|
+
.description("Force OpenClaw to use token2chat as sole provider")
|
|
158
|
+
.action(() => debugCommand("force"));
|
|
159
|
+
debug
|
|
160
|
+
.command("rollback")
|
|
161
|
+
.description("Restore original OpenClaw config")
|
|
162
|
+
.action(() => debugCommand("rollback"));
|
|
163
|
+
debug
|
|
164
|
+
.command("logs")
|
|
165
|
+
.description("Show auth profiles, cooldowns, and recent model errors")
|
|
166
|
+
.option("-n, --lines <n>", "Number of log lines to show", "30")
|
|
167
|
+
.action((opts) => debugCommand("logs", opts));
|
|
168
|
+
debug
|
|
169
|
+
.command("topup")
|
|
170
|
+
.description("Transfer ecash from Gate to local plugin wallet")
|
|
171
|
+
.requiredOption("--amount <sats>", "Amount in sats to withdraw from Gate")
|
|
172
|
+
.action((opts) => debugCommand("topup", opts));
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// debug module not available (stripped from npm package) — skip silently
|
|
176
|
+
}
|
|
177
|
+
program.parse();
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type T2CConfig } from "./config.js";
|
|
2
|
+
export interface Logger {
|
|
3
|
+
info: (...args: unknown[]) => void;
|
|
4
|
+
warn: (...args: unknown[]) => void;
|
|
5
|
+
error: (...args: unknown[]) => void;
|
|
6
|
+
}
|
|
7
|
+
export interface ProxyHandle {
|
|
8
|
+
stop: () => void;
|
|
9
|
+
proxySecret: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function startProxy(config: T2CConfig, logger?: Logger): Promise<ProxyHandle>;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP proxy that translates standard OpenAI-compatible requests
|
|
3
|
+
* into ecash-paid requests to the token2chat Gate.
|
|
4
|
+
*/
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { CashuStore } from "./cashu-store.js";
|
|
8
|
+
import { resolveHome, FAILED_TOKENS_PATH, appendFailedToken, appendTransaction, loadOrCreateProxySecret } from "./config.js";
|
|
9
|
+
import { GateRegistry } from "./gate-discovery.js";
|
|
10
|
+
const defaultLogger = {
|
|
11
|
+
info: (...args) => console.log("[t2c]", ...args),
|
|
12
|
+
warn: (...args) => console.warn("[t2c]", ...args),
|
|
13
|
+
error: (...args) => console.error("[t2c]", ...args),
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Known provider prefixes for model ID transformation.
|
|
17
|
+
* We use `-` as separator in OpenClaw to avoid double-slash issue,
|
|
18
|
+
* but Gate/OpenRouter expects `/` as separator.
|
|
19
|
+
*/
|
|
20
|
+
const MODEL_PROVIDER_PREFIXES = [
|
|
21
|
+
"openai",
|
|
22
|
+
"anthropic",
|
|
23
|
+
"google",
|
|
24
|
+
"deepseek",
|
|
25
|
+
"qwen",
|
|
26
|
+
"moonshotai",
|
|
27
|
+
"mistralai",
|
|
28
|
+
"meta-llama",
|
|
29
|
+
"nvidia",
|
|
30
|
+
"cohere",
|
|
31
|
+
"perplexity",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Transform model ID from dash format to slash format.
|
|
35
|
+
* e.g., "anthropic-claude-sonnet-4.5" → "anthropic/claude-sonnet-4.5"
|
|
36
|
+
*/
|
|
37
|
+
function transformModelId(model) {
|
|
38
|
+
for (const prefix of MODEL_PROVIDER_PREFIXES) {
|
|
39
|
+
if (model.startsWith(`${prefix}-`)) {
|
|
40
|
+
return `${prefix}/${model.slice(prefix.length + 1)}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return model;
|
|
44
|
+
}
|
|
45
|
+
function sleep(ms) {
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
|
+
}
|
|
48
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
49
|
+
function parseRetryAfter(value) {
|
|
50
|
+
if (!value)
|
|
51
|
+
return null;
|
|
52
|
+
const seconds = parseFloat(value);
|
|
53
|
+
if (!isNaN(seconds) && isFinite(seconds)) {
|
|
54
|
+
return Math.max(0, Math.ceil(seconds * 1000));
|
|
55
|
+
}
|
|
56
|
+
const date = new Date(value);
|
|
57
|
+
if (!isNaN(date.getTime())) {
|
|
58
|
+
return Math.max(0, date.getTime() - Date.now());
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
export async function startProxy(config, logger = defaultLogger) {
|
|
63
|
+
const { gateUrl, mintUrl, proxyPort: port, lowBalanceThreshold } = config;
|
|
64
|
+
const walletPath = resolveHome(config.walletPath);
|
|
65
|
+
// Gate discovery + failover
|
|
66
|
+
const gateRegistry = config.autoDiscover
|
|
67
|
+
? new GateRegistry(gateUrl, config.discoveryUrl)
|
|
68
|
+
: null;
|
|
69
|
+
if (gateRegistry) {
|
|
70
|
+
await gateRegistry.discover().catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
// Load proxy authentication secret
|
|
73
|
+
const proxySecret = await loadOrCreateProxySecret();
|
|
74
|
+
function checkAuth(req) {
|
|
75
|
+
const auth = req.headers.authorization;
|
|
76
|
+
if (!auth)
|
|
77
|
+
return false;
|
|
78
|
+
const parts = auth.split(" ");
|
|
79
|
+
if (parts.length !== 2 || parts[0] !== "Bearer")
|
|
80
|
+
return false;
|
|
81
|
+
const provided = Buffer.from(parts[1]);
|
|
82
|
+
const expected = Buffer.from(proxySecret);
|
|
83
|
+
if (provided.length !== expected.length)
|
|
84
|
+
return false;
|
|
85
|
+
return crypto.timingSafeEqual(provided, expected);
|
|
86
|
+
}
|
|
87
|
+
// Load wallet synchronously before starting server (fixes race condition)
|
|
88
|
+
let wallet;
|
|
89
|
+
try {
|
|
90
|
+
wallet = await CashuStore.load(walletPath, mintUrl);
|
|
91
|
+
logger.info(`Wallet loaded: ${wallet.balance} sat (${wallet.proofCount} proofs)`);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
logger.error("Failed to load wallet:", e);
|
|
95
|
+
throw new Error(`Cannot start proxy: wallet load failed - ${e instanceof Error ? e.message : e}`);
|
|
96
|
+
}
|
|
97
|
+
// Cache pricing info
|
|
98
|
+
let pricingCache = null;
|
|
99
|
+
let pricingFetchedAt = 0;
|
|
100
|
+
async function fetchPricing() {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
if (pricingCache && now - pricingFetchedAt < 5 * 60_000)
|
|
103
|
+
return pricingCache;
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch(`${gateUrl}/v1/pricing`);
|
|
106
|
+
if (res.ok) {
|
|
107
|
+
const data = (await res.json());
|
|
108
|
+
pricingCache = {};
|
|
109
|
+
for (const [model, rule] of Object.entries(data.models)) {
|
|
110
|
+
pricingCache[model] = rule.per_request;
|
|
111
|
+
}
|
|
112
|
+
pricingFetchedAt = now;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
logger.warn("Failed to fetch pricing:", e);
|
|
117
|
+
}
|
|
118
|
+
return pricingCache ?? {};
|
|
119
|
+
}
|
|
120
|
+
function getPrice(pricing, model) {
|
|
121
|
+
return pricing[model] ?? pricing["*"] ?? 500;
|
|
122
|
+
}
|
|
123
|
+
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
124
|
+
async function readBody(req) {
|
|
125
|
+
const chunks = [];
|
|
126
|
+
let size = 0;
|
|
127
|
+
for await (const chunk of req) {
|
|
128
|
+
size += chunk.length;
|
|
129
|
+
if (size > MAX_BODY_SIZE) {
|
|
130
|
+
req.destroy();
|
|
131
|
+
throw new Error("Request body too large");
|
|
132
|
+
}
|
|
133
|
+
chunks.push(chunk);
|
|
134
|
+
}
|
|
135
|
+
return Buffer.concat(chunks);
|
|
136
|
+
}
|
|
137
|
+
const server = createServer(async (req, res) => {
|
|
138
|
+
// Health check (unauthenticated — no sensitive data)
|
|
139
|
+
if (req.method === "GET" && req.url === "/health") {
|
|
140
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
141
|
+
res.end(JSON.stringify({ ok: true }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// All endpoints below require authentication
|
|
145
|
+
if (!checkAuth(req)) {
|
|
146
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
147
|
+
res.end(JSON.stringify({ error: { message: "Unauthorized. Provide a valid Bearer token." } }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Pricing passthrough
|
|
151
|
+
if (req.method === "GET" && req.url === "/v1/pricing") {
|
|
152
|
+
try {
|
|
153
|
+
const upstream = await fetch(`${gateUrl}/v1/pricing`);
|
|
154
|
+
res.writeHead(upstream.status, { "Content-Type": "application/json" });
|
|
155
|
+
res.end(await upstream.text());
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
159
|
+
res.end(JSON.stringify({ error: "Gate unreachable" }));
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Models endpoint
|
|
164
|
+
if (req.method === "GET" && req.url === "/v1/models") {
|
|
165
|
+
const pricing = await fetchPricing();
|
|
166
|
+
const models = Object.keys(pricing).map((id) => ({
|
|
167
|
+
id,
|
|
168
|
+
object: "model",
|
|
169
|
+
created: Date.now(),
|
|
170
|
+
owned_by: "token2chat",
|
|
171
|
+
}));
|
|
172
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
173
|
+
res.end(JSON.stringify({ object: "list", data: models }));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Only proxy POST /v1/chat/completions
|
|
177
|
+
if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) {
|
|
178
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({ error: { message: "Not found" } }));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const body = await readBody(req);
|
|
184
|
+
const parsed = JSON.parse(body.toString());
|
|
185
|
+
const requestedModel = parsed.model;
|
|
186
|
+
const isStream = parsed.stream === true;
|
|
187
|
+
// Transaction tracking
|
|
188
|
+
const txStart = Date.now();
|
|
189
|
+
const txId = `tx-${txStart}-${Math.random().toString(36).slice(2, 8)}`;
|
|
190
|
+
let txChangeSat = 0;
|
|
191
|
+
let txRefundSat = 0;
|
|
192
|
+
const balanceBefore = wallet.balance;
|
|
193
|
+
const pricing = await fetchPricing();
|
|
194
|
+
const price = getPrice(pricing, requestedModel);
|
|
195
|
+
const balance = wallet.balance;
|
|
196
|
+
if (balance < price) {
|
|
197
|
+
logger.warn(`Insufficient balance: ${balance} sat < ${price} sat for ${requestedModel}`);
|
|
198
|
+
res.writeHead(402, { "Content-Type": "application/json" });
|
|
199
|
+
res.end(JSON.stringify({
|
|
200
|
+
error: {
|
|
201
|
+
code: "insufficient_balance",
|
|
202
|
+
message: `Wallet balance ${balance} sat < ${price} sat required. Run 't2c mint' to add funds.`,
|
|
203
|
+
type: "insufficient_funds",
|
|
204
|
+
},
|
|
205
|
+
}));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
// Prepare modified body once (model transform doesn't change between retries)
|
|
209
|
+
parsed.model = transformModelId(requestedModel);
|
|
210
|
+
const modifiedBody = JSON.stringify(parsed);
|
|
211
|
+
// Resolve gate URL(s) — with failover if auto-discover enabled
|
|
212
|
+
const gateUrls = gateRegistry
|
|
213
|
+
? await gateRegistry.selectGate(requestedModel)
|
|
214
|
+
: [gateUrl];
|
|
215
|
+
// Make request with retry logic
|
|
216
|
+
const maxRetries = 2;
|
|
217
|
+
const retryBaseDelayMs = 2000;
|
|
218
|
+
let lastResponse = null;
|
|
219
|
+
let lastResponseBody;
|
|
220
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
221
|
+
// Pick gate for this attempt (rotate through available gates)
|
|
222
|
+
const currentGateUrl = gateUrls[attempt % gateUrls.length];
|
|
223
|
+
const currentPrice = getPrice(pricing, requestedModel);
|
|
224
|
+
const token = await wallet.selectAndEncode(currentPrice);
|
|
225
|
+
const currentBalance = wallet.balance;
|
|
226
|
+
if (attempt === 0) {
|
|
227
|
+
logger.info(`Paying ${currentPrice} sat for ${requestedModel} → ${currentGateUrl} (balance: ${currentBalance + currentPrice} → ~${currentBalance})`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
logger.info(`Retry ${attempt}/${maxRetries} for ${requestedModel} → ${currentGateUrl}`);
|
|
231
|
+
}
|
|
232
|
+
const gateRes = await fetch(`${currentGateUrl}/v1/chat/completions`, {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers: {
|
|
235
|
+
"Content-Type": "application/json",
|
|
236
|
+
"X-Cashu": token,
|
|
237
|
+
},
|
|
238
|
+
body: modifiedBody,
|
|
239
|
+
});
|
|
240
|
+
// Handle change/refund tokens from Gate
|
|
241
|
+
for (const [header, type] of [["X-Cashu-Change", "change"], ["X-Cashu-Refund", "refund"]]) {
|
|
242
|
+
const tokenStr = gateRes.headers.get(header);
|
|
243
|
+
if (!tokenStr)
|
|
244
|
+
continue;
|
|
245
|
+
try {
|
|
246
|
+
const amt = await wallet.receiveToken(tokenStr);
|
|
247
|
+
if (type === "change")
|
|
248
|
+
txChangeSat += amt;
|
|
249
|
+
else
|
|
250
|
+
txRefundSat += amt;
|
|
251
|
+
logger.info(`Received ${amt} sat ${type}`);
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
255
|
+
logger.warn(`Failed to store ${type}: ${errMsg}`);
|
|
256
|
+
logger.warn(`Token saved to ${FAILED_TOKENS_PATH} - run 't2c recover' to retry`);
|
|
257
|
+
await appendFailedToken(tokenStr, type, errMsg);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// If not 429, we're done (and mark gate healthy for failover)
|
|
261
|
+
if (gateRes.status !== 429) {
|
|
262
|
+
if (gateRegistry) {
|
|
263
|
+
if (gateRes.status >= 500)
|
|
264
|
+
gateRegistry.markFailed(currentGateUrl);
|
|
265
|
+
else
|
|
266
|
+
gateRegistry.markSuccess(currentGateUrl);
|
|
267
|
+
}
|
|
268
|
+
const resHeaders = {};
|
|
269
|
+
const ct = gateRes.headers.get("content-type");
|
|
270
|
+
if (ct)
|
|
271
|
+
resHeaders["Content-Type"] = ct;
|
|
272
|
+
res.writeHead(gateRes.status, resHeaders);
|
|
273
|
+
if (isStream && gateRes.body) {
|
|
274
|
+
const reader = gateRes.body.getReader();
|
|
275
|
+
try {
|
|
276
|
+
while (true) {
|
|
277
|
+
const { done, value } = await reader.read();
|
|
278
|
+
if (done)
|
|
279
|
+
break;
|
|
280
|
+
res.write(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
reader.releaseLock();
|
|
285
|
+
}
|
|
286
|
+
res.end();
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
res.end(await gateRes.text());
|
|
290
|
+
}
|
|
291
|
+
// Log balance warning
|
|
292
|
+
const newBalance = wallet.balance;
|
|
293
|
+
if (newBalance < lowBalanceThreshold) {
|
|
294
|
+
logger.warn(`⚠️ Low ecash balance: ${newBalance} sat (threshold: ${lowBalanceThreshold})`);
|
|
295
|
+
}
|
|
296
|
+
// Record transaction
|
|
297
|
+
appendTransaction({
|
|
298
|
+
id: txId, timestamp: txStart, model: requestedModel,
|
|
299
|
+
priceSat: currentPrice, changeSat: txChangeSat, refundSat: txRefundSat,
|
|
300
|
+
gateStatus: gateRes.status, balanceBefore, balanceAfter: wallet.balance,
|
|
301
|
+
durationMs: Date.now() - txStart,
|
|
302
|
+
}).catch(() => { });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Store last 429 response — mark gate for failover
|
|
306
|
+
if (gateRegistry)
|
|
307
|
+
gateRegistry.markFailed(currentGateUrl);
|
|
308
|
+
lastResponse = gateRes;
|
|
309
|
+
lastResponseBody = await gateRes.text();
|
|
310
|
+
// On 429, calculate backoff delay
|
|
311
|
+
if (attempt < maxRetries) {
|
|
312
|
+
const retryAfterMs = parseRetryAfter(gateRes.headers.get("Retry-After"));
|
|
313
|
+
const backoffMs = Math.min(retryAfterMs ?? retryBaseDelayMs * Math.pow(2, attempt), MAX_RETRY_DELAY_MS);
|
|
314
|
+
logger.warn(`Rate limited (429), retrying in ${backoffMs}ms...`);
|
|
315
|
+
await sleep(backoffMs);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// All retries exhausted — record failed transaction
|
|
319
|
+
appendTransaction({
|
|
320
|
+
id: txId, timestamp: txStart, model: requestedModel,
|
|
321
|
+
priceSat: price, changeSat: txChangeSat, refundSat: txRefundSat,
|
|
322
|
+
gateStatus: lastResponse.status, balanceBefore, balanceAfter: wallet.balance,
|
|
323
|
+
durationMs: Date.now() - txStart, error: "Rate limited after retries",
|
|
324
|
+
}).catch(() => { });
|
|
325
|
+
res.writeHead(lastResponse.status, { "Content-Type": "application/json" });
|
|
326
|
+
res.end(lastResponseBody);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
330
|
+
logger.error("Proxy error:", e);
|
|
331
|
+
if (msg === "Request body too large") {
|
|
332
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
333
|
+
res.end(JSON.stringify({ error: { code: "payload_too_large", message: "Request body too large" } }));
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
337
|
+
res.end(JSON.stringify({ error: { code: "proxy_error", message: "Internal proxy error" } }));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
server.listen(port, "127.0.0.1", () => {
|
|
342
|
+
logger.info(`🎟️ t2c proxy on http://127.0.0.1:${port}`);
|
|
343
|
+
logger.info(` Gate: ${gateUrl} | Mint: ${mintUrl}`);
|
|
344
|
+
});
|
|
345
|
+
return {
|
|
346
|
+
stop: () => {
|
|
347
|
+
server.close();
|
|
348
|
+
logger.info("t2c proxy stopped");
|
|
349
|
+
},
|
|
350
|
+
proxySecret,
|
|
351
|
+
};
|
|
352
|
+
}
|