@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/dist/adapters/aider.d.ts +5 -0
  4. package/dist/adapters/aider.js +29 -0
  5. package/dist/adapters/cline.d.ts +5 -0
  6. package/dist/adapters/cline.js +32 -0
  7. package/dist/adapters/continue.d.ts +5 -0
  8. package/dist/adapters/continue.js +45 -0
  9. package/dist/adapters/cursor.d.ts +5 -0
  10. package/dist/adapters/cursor.js +23 -0
  11. package/dist/adapters/env.d.ts +5 -0
  12. package/dist/adapters/env.js +25 -0
  13. package/dist/adapters/index.d.ts +6 -0
  14. package/dist/adapters/index.js +6 -0
  15. package/dist/adapters/openclaw.d.ts +2 -0
  16. package/dist/adapters/openclaw.js +167 -0
  17. package/dist/cashu-store.d.ts +52 -0
  18. package/dist/cashu-store.js +201 -0
  19. package/dist/commands/audit.d.ts +6 -0
  20. package/dist/commands/audit.js +340 -0
  21. package/dist/commands/balance.d.ts +5 -0
  22. package/dist/commands/balance.js +29 -0
  23. package/dist/commands/config.d.ts +5 -0
  24. package/dist/commands/config.js +62 -0
  25. package/dist/commands/connect.d.ts +1 -0
  26. package/dist/commands/connect.js +43 -0
  27. package/dist/commands/doctor.d.ts +1 -0
  28. package/dist/commands/doctor.js +178 -0
  29. package/dist/commands/init.d.ts +3 -0
  30. package/dist/commands/init.js +50 -0
  31. package/dist/commands/mint.d.ts +5 -0
  32. package/dist/commands/mint.js +168 -0
  33. package/dist/commands/recover.d.ts +1 -0
  34. package/dist/commands/recover.js +61 -0
  35. package/dist/commands/service.d.ts +7 -0
  36. package/dist/commands/service.js +378 -0
  37. package/dist/commands/setup.d.ts +1 -0
  38. package/dist/commands/setup.js +128 -0
  39. package/dist/commands/status.d.ts +5 -0
  40. package/dist/commands/status.js +87 -0
  41. package/dist/config.d.ts +83 -0
  42. package/dist/config.js +224 -0
  43. package/dist/connectors/cursor.d.ts +2 -0
  44. package/dist/connectors/cursor.js +28 -0
  45. package/dist/connectors/env.d.ts +2 -0
  46. package/dist/connectors/env.js +38 -0
  47. package/dist/connectors/index.d.ts +26 -0
  48. package/dist/connectors/index.js +30 -0
  49. package/dist/connectors/interface.d.ts +20 -0
  50. package/dist/connectors/interface.js +1 -0
  51. package/dist/connectors/openclaw.d.ts +2 -0
  52. package/dist/connectors/openclaw.js +202 -0
  53. package/dist/gate-discovery.d.ts +49 -0
  54. package/dist/gate-discovery.js +142 -0
  55. package/dist/index.d.ts +2 -0
  56. package/dist/index.js +177 -0
  57. package/dist/proxy.d.ts +11 -0
  58. package/dist/proxy.js +352 -0
  59. 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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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();
@@ -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
+ }