@token2chat/t2c 0.2.0-beta.1 → 0.2.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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * SSE stream parser that extracts cashu-change events.
3
+ *
4
+ * The Gate emits change tokens via `event: cashu-change` SSE events
5
+ * during streaming responses. This parser intercepts those events,
6
+ * extracts the token, and filters them out so they are not forwarded
7
+ * to the AI tool.
8
+ */
9
+ /**
10
+ * Create a TransformStream that filters out `event: cashu-change` SSE events
11
+ * and captures the token data.
12
+ */
13
+ export function extractCashuChangeFromSSE(input) {
14
+ let token;
15
+ const decoder = new TextDecoder();
16
+ let buffer = "";
17
+ const filtered = new ReadableStream({
18
+ async start() { },
19
+ async pull(controller) {
20
+ // This is handled via piping below
21
+ },
22
+ cancel() { },
23
+ });
24
+ // Use a TransformStream approach
25
+ const encoder = new TextEncoder();
26
+ let resolveReady = null;
27
+ const transform = new TransformStream({
28
+ transform(chunk, controller) {
29
+ buffer += decoder.decode(chunk, { stream: true });
30
+ // Process complete SSE blocks (separated by \n\n)
31
+ while (true) {
32
+ const blockEnd = buffer.indexOf("\n\n");
33
+ if (blockEnd === -1)
34
+ break;
35
+ const block = buffer.slice(0, blockEnd + 2);
36
+ buffer = buffer.slice(blockEnd + 2);
37
+ // Check if this block is a cashu-change event
38
+ if (isCashuChangeBlock(block)) {
39
+ // Extract the token from "data: <token>\n"
40
+ const dataMatch = block.match(/^data:\s*(.+)$/m);
41
+ if (dataMatch && !token) {
42
+ token = dataMatch[1].trim();
43
+ }
44
+ // Don't forward this block
45
+ continue;
46
+ }
47
+ // Forward non-cashu-change blocks
48
+ controller.enqueue(encoder.encode(block));
49
+ }
50
+ },
51
+ flush(controller) {
52
+ // Flush any remaining buffer
53
+ if (buffer.length > 0) {
54
+ // Check if remaining buffer is a cashu-change event
55
+ if (isCashuChangeBlock(buffer)) {
56
+ const dataMatch = buffer.match(/^data:\s*(.+)$/m);
57
+ if (dataMatch && !token) {
58
+ token = dataMatch[1].trim();
59
+ }
60
+ }
61
+ else {
62
+ controller.enqueue(encoder.encode(buffer));
63
+ }
64
+ buffer = "";
65
+ }
66
+ },
67
+ });
68
+ const outputStream = input.pipeThrough(transform);
69
+ return {
70
+ filtered: outputStream,
71
+ changeToken: () => token,
72
+ };
73
+ }
74
+ /**
75
+ * Check if an SSE block is a cashu-change event.
76
+ */
77
+ function isCashuChangeBlock(block) {
78
+ // An SSE block with event: cashu-change
79
+ return /^event:\s*cashu-change\s*$/m.test(block);
80
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared type definitions for the proxy module.
3
+ */
4
+ /**
5
+ * Logger interface for proxy operations.
6
+ */
7
+ export interface Logger {
8
+ info: (...args: unknown[]) => void;
9
+ warn: (...args: unknown[]) => void;
10
+ error: (...args: unknown[]) => void;
11
+ }
12
+ /**
13
+ * Default console logger.
14
+ */
15
+ export declare const defaultLogger: Logger;
16
+ /**
17
+ * Known provider prefixes for model ID transformation.
18
+ * We use `-` as separator in OpenClaw to avoid double-slash issue,
19
+ * but Gate/OpenRouter expects `/` as separator.
20
+ */
21
+ export declare const MODEL_PROVIDER_PREFIXES: readonly ["openai", "anthropic", "google", "deepseek", "qwen", "moonshotai", "mistralai", "meta-llama", "nvidia", "cohere", "perplexity"];
22
+ /**
23
+ * Transform model ID from dash format to slash format.
24
+ * e.g., "anthropic-claude-sonnet-4.5" → "anthropic/claude-sonnet-4.5"
25
+ */
26
+ export declare function transformModelId(model: string): string;
27
+ /**
28
+ * Parse Retry-After header value.
29
+ * Returns milliseconds to wait, or null if invalid.
30
+ */
31
+ export declare function parseRetryAfter(value: string | null): number | null;
32
+ /**
33
+ * Result from a proxy request.
34
+ */
35
+ export interface ProxyResult {
36
+ status: number;
37
+ headers: Record<string, string>;
38
+ body: ReadableStream<Uint8Array> | string;
39
+ }
40
+ /**
41
+ * OpenAI chat completion request (minimal).
42
+ */
43
+ export interface CompletionRequest {
44
+ model: string;
45
+ messages: Array<{
46
+ role: string;
47
+ content: string | unknown[];
48
+ }>;
49
+ stream?: boolean;
50
+ [key: string]: unknown;
51
+ }
52
+ /**
53
+ * Model info in OpenAI format.
54
+ */
55
+ export interface ModelInfo {
56
+ id: string;
57
+ object: "model";
58
+ created: number;
59
+ owned_by: string;
60
+ }
61
+ /**
62
+ * Proxy handle returned by startProxy.
63
+ */
64
+ export interface ProxyHandle {
65
+ stop: () => void;
66
+ proxySecret: string;
67
+ }
68
+ /**
69
+ * Payment result from token selection.
70
+ */
71
+ export interface PaymentResult {
72
+ token: string;
73
+ priceSpent: number;
74
+ balanceAfter: number;
75
+ }
76
+ /**
77
+ * Change/refund received from Gate.
78
+ */
79
+ export interface TokenReceiveResult {
80
+ amount: number;
81
+ type: "change" | "refund";
82
+ }
83
+ /**
84
+ * Proxy request metrics for a single request.
85
+ */
86
+ export interface RequestMetrics {
87
+ txId: string;
88
+ model: string;
89
+ priceSat: number;
90
+ changeSat: number;
91
+ refundSat: number;
92
+ gateStatus: number;
93
+ balanceBefore: number;
94
+ balanceAfter: number;
95
+ durationMs: number;
96
+ error?: string;
97
+ }
98
+ /**
99
+ * Maximum request body size (10 MB).
100
+ */
101
+ export declare const MAX_BODY_SIZE: number;
102
+ /**
103
+ * Maximum retry delay (30 seconds).
104
+ */
105
+ export declare const MAX_RETRY_DELAY_MS = 30000;
106
+ /**
107
+ * Default retry configuration.
108
+ */
109
+ export declare const DEFAULT_RETRY_CONFIG: {
110
+ readonly maxRetries: 2;
111
+ readonly baseDelayMs: 2000;
112
+ readonly maxDelayMs: 30000;
113
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Shared type definitions for the proxy module.
3
+ */
4
+ /**
5
+ * Default console logger.
6
+ */
7
+ export const defaultLogger = {
8
+ info: (...args) => console.log("[t2c]", ...args),
9
+ warn: (...args) => console.warn("[t2c]", ...args),
10
+ error: (...args) => console.error("[t2c]", ...args),
11
+ };
12
+ /**
13
+ * Known provider prefixes for model ID transformation.
14
+ * We use `-` as separator in OpenClaw to avoid double-slash issue,
15
+ * but Gate/OpenRouter expects `/` as separator.
16
+ */
17
+ export const MODEL_PROVIDER_PREFIXES = [
18
+ "openai",
19
+ "anthropic",
20
+ "google",
21
+ "deepseek",
22
+ "qwen",
23
+ "moonshotai",
24
+ "mistralai",
25
+ "meta-llama",
26
+ "nvidia",
27
+ "cohere",
28
+ "perplexity",
29
+ ];
30
+ /**
31
+ * Transform model ID from dash format to slash format.
32
+ * e.g., "anthropic-claude-sonnet-4.5" → "anthropic/claude-sonnet-4.5"
33
+ */
34
+ export function transformModelId(model) {
35
+ for (const prefix of MODEL_PROVIDER_PREFIXES) {
36
+ if (model.startsWith(`${prefix}-`)) {
37
+ return `${prefix}/${model.slice(prefix.length + 1)}`;
38
+ }
39
+ }
40
+ return model;
41
+ }
42
+ /**
43
+ * Parse Retry-After header value.
44
+ * Returns milliseconds to wait, or null if invalid.
45
+ */
46
+ export function parseRetryAfter(value) {
47
+ if (!value)
48
+ return null;
49
+ const seconds = parseFloat(value);
50
+ if (!isNaN(seconds) && isFinite(seconds)) {
51
+ return Math.max(0, Math.ceil(seconds * 1000));
52
+ }
53
+ const date = new Date(value);
54
+ if (!isNaN(date.getTime())) {
55
+ return Math.max(0, date.getTime() - Date.now());
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Maximum request body size (10 MB).
61
+ */
62
+ export const MAX_BODY_SIZE = 10 * 1024 * 1024;
63
+ /**
64
+ * Maximum retry delay (30 seconds).
65
+ */
66
+ export const MAX_RETRY_DELAY_MS = 30_000;
67
+ /**
68
+ * Default retry configuration.
69
+ */
70
+ export const DEFAULT_RETRY_CONFIG = {
71
+ maxRetries: 2,
72
+ baseDelayMs: 2000,
73
+ maxDelayMs: MAX_RETRY_DELAY_MS,
74
+ };
package/dist/proxy.d.ts CHANGED
@@ -1,11 +1,4 @@
1
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
- }
2
+ import { type Logger, type ProxyHandle, transformModelId, parseRetryAfter } from "./proxy/index.js";
3
+ export { transformModelId, parseRetryAfter, type Logger, type ProxyHandle };
11
4
  export declare function startProxy(config: T2CConfig, logger?: Logger): Promise<ProxyHandle>;
package/dist/proxy.js CHANGED
@@ -3,62 +3,13 @@
3
3
  * into ecash-paid requests to the token2chat Gate.
4
4
  */
5
5
  import { createServer } from "node:http";
6
- import crypto from "node:crypto";
7
6
  import { CashuStore } from "./cashu-store.js";
8
- import { resolveHome, FAILED_TOKENS_PATH, appendFailedToken, appendTransaction, loadOrCreateProxySecret } from "./config.js";
7
+ import { resolveHome, appendFailedToken, appendTransaction, loadOrCreateProxySecret } from "./config.js";
9
8
  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
- }
9
+ import { defaultLogger, transformModelId, parseRetryAfter, MAX_BODY_SIZE, MAX_RETRY_DELAY_MS, DEFAULT_RETRY_CONFIG, PricingCache, GateClient, PaymentService, createAuthChecker, handleError, sendJsonResponse, } from "./proxy/index.js";
10
+ import { extractCashuChangeFromSSE } from "./proxy/sse-parser.js";
11
+ // Re-export for backwards compatibility
12
+ export { transformModelId, parseRetryAfter };
62
13
  export async function startProxy(config, logger = defaultLogger) {
63
14
  const { gateUrl, mintUrl, proxyPort: port, lowBalanceThreshold } = config;
64
15
  const walletPath = resolveHome(config.walletPath);
@@ -71,56 +22,28 @@ export async function startProxy(config, logger = defaultLogger) {
71
22
  }
72
23
  // Load proxy authentication secret
73
24
  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
- }
25
+ const checkAuth = createAuthChecker(proxySecret);
87
26
  // Load wallet synchronously before starting server (fixes race condition)
88
27
  let wallet;
89
28
  try {
90
29
  wallet = await CashuStore.load(walletPath, mintUrl);
91
- logger.info(`Wallet loaded: ${wallet.balance} sat (${wallet.proofCount} proofs)`);
30
+ logger.info(`Wallet loaded: balance=${wallet.balance} (${wallet.proofCount} proofs)`);
92
31
  }
93
32
  catch (e) {
94
33
  logger.error("Failed to load wallet:", e);
95
34
  throw new Error(`Cannot start proxy: wallet load failed - ${e instanceof Error ? e.message : e}`);
96
35
  }
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
36
+ // Pricing cache
37
+ const pricingCache = new PricingCache(gateUrl);
38
+ // Gate client
39
+ const gateClient = new GateClient(gateUrl, { logger });
40
+ // Payment service
41
+ const paymentService = new PaymentService({
42
+ wallet,
43
+ logger,
44
+ appendFailedToken,
45
+ lowBalanceThreshold,
46
+ });
124
47
  async function readBody(req) {
125
48
  const chunks = [];
126
49
  let size = 0;
@@ -137,14 +60,12 @@ export async function startProxy(config, logger = defaultLogger) {
137
60
  const server = createServer(async (req, res) => {
138
61
  // Health check (unauthenticated — no sensitive data)
139
62
  if (req.method === "GET" && req.url === "/health") {
140
- res.writeHead(200, { "Content-Type": "application/json" });
141
- res.end(JSON.stringify({ ok: true }));
63
+ sendJsonResponse(res, 200, { ok: true });
142
64
  return;
143
65
  }
144
66
  // All endpoints below require authentication
145
67
  if (!checkAuth(req)) {
146
- res.writeHead(401, { "Content-Type": "application/json" });
147
- res.end(JSON.stringify({ error: { message: "Unauthorized. Provide a valid Bearer token." } }));
68
+ sendJsonResponse(res, 401, { error: { message: "Unauthorized. Provide a valid Bearer token." } });
148
69
  return;
149
70
  }
150
71
  // Pricing passthrough
@@ -155,28 +76,25 @@ export async function startProxy(config, logger = defaultLogger) {
155
76
  res.end(await upstream.text());
156
77
  }
157
78
  catch {
158
- res.writeHead(502, { "Content-Type": "application/json" });
159
- res.end(JSON.stringify({ error: "Gate unreachable" }));
79
+ sendJsonResponse(res, 502, { error: "Gate unreachable" });
160
80
  }
161
81
  return;
162
82
  }
163
83
  // Models endpoint
164
84
  if (req.method === "GET" && req.url === "/v1/models") {
165
- const pricing = await fetchPricing();
166
- const models = Object.keys(pricing).map((id) => ({
85
+ await pricingCache.get(); // Ensure cache is populated
86
+ const models = pricingCache.getModels().map((id) => ({
167
87
  id,
168
88
  object: "model",
169
89
  created: Date.now(),
170
90
  owned_by: "token2chat",
171
91
  }));
172
- res.writeHead(200, { "Content-Type": "application/json" });
173
- res.end(JSON.stringify({ object: "list", data: models }));
92
+ sendJsonResponse(res, 200, { object: "list", data: models });
174
93
  return;
175
94
  }
176
95
  // Only proxy POST /v1/chat/completions
177
96
  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" } }));
97
+ sendJsonResponse(res, 404, { error: { message: "Not found" } });
180
98
  return;
181
99
  }
182
100
  try {
@@ -189,20 +107,15 @@ export async function startProxy(config, logger = defaultLogger) {
189
107
  const txId = `tx-${txStart}-${Math.random().toString(36).slice(2, 8)}`;
190
108
  let txChangeSat = 0;
191
109
  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
- }));
110
+ const balanceBefore = paymentService.getBalance();
111
+ await pricingCache.get(); // Ensure cache is populated
112
+ const price = pricingCache.getPrice(requestedModel);
113
+ // Check balance using PaymentService (throws InsufficientBalanceError)
114
+ try {
115
+ paymentService.checkBalance(price, requestedModel);
116
+ }
117
+ catch (e) {
118
+ handleError(res, e, logger);
206
119
  return;
207
120
  }
208
121
  // Prepare modified body once (model transform doesn't change between retries)
@@ -212,51 +125,33 @@ export async function startProxy(config, logger = defaultLogger) {
212
125
  const gateUrls = gateRegistry
213
126
  ? await gateRegistry.selectGate(requestedModel)
214
127
  : [gateUrl];
215
- // Make request with retry logic
216
- const maxRetries = 2;
217
- const retryBaseDelayMs = 2000;
218
- let lastResponse = null;
219
- let lastResponseBody;
128
+ // Make request with retry logic (new token per attempt for ecash)
129
+ const { maxRetries, baseDelayMs } = DEFAULT_RETRY_CONFIG;
130
+ let lastGateResponse = null;
220
131
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
221
132
  // Pick gate for this attempt (rotate through available gates)
222
133
  const currentGateUrl = gateUrls[attempt % gateUrls.length];
223
- const currentPrice = getPrice(pricing, requestedModel);
224
- const token = await wallet.selectAndEncode(currentPrice);
225
- const currentBalance = wallet.balance;
134
+ const currentPrice = pricingCache.getPrice(requestedModel);
135
+ // Select token using PaymentService
136
+ const { token, balanceAfter } = await paymentService.selectToken(currentPrice);
226
137
  if (attempt === 0) {
227
- logger.info(`Paying ${currentPrice} sat for ${requestedModel} → ${currentGateUrl} (balance: ${currentBalance + currentPrice} → ~${currentBalance})`);
138
+ logger.info(`Paying ${currentPrice} for ${requestedModel} → ${currentGateUrl} (balance: ${balanceAfter + currentPrice} → ~${balanceAfter})`);
228
139
  }
229
140
  else {
230
141
  logger.info(`Retry ${attempt}/${maxRetries} for ${requestedModel} → ${currentGateUrl}`);
231
142
  }
232
- const gateRes = await fetch(`${currentGateUrl}/v1/chat/completions`, {
233
- method: "POST",
234
- headers: {
235
- "Content-Type": "application/json",
236
- "X-Cashu": token,
237
- },
143
+ // Use GateClient for the actual request
144
+ const gateRes = await gateClient.request({
145
+ path: "/v1/chat/completions",
238
146
  body: modifiedBody,
147
+ token,
148
+ gateUrl: currentGateUrl,
149
+ stream: isStream,
239
150
  });
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
- }
151
+ // Handle change/refund tokens using PaymentService
152
+ const tokens = await paymentService.processGateTokens(gateRes.changeToken, gateRes.refundToken);
153
+ txChangeSat += tokens.changeSat;
154
+ txRefundSat += tokens.refundSat;
260
155
  // If not 429, we're done (and mark gate healthy for failover)
261
156
  if (gateRes.status !== 429) {
262
157
  if (gateRegistry) {
@@ -266,12 +161,13 @@ export async function startProxy(config, logger = defaultLogger) {
266
161
  gateRegistry.markSuccess(currentGateUrl);
267
162
  }
268
163
  const resHeaders = {};
269
- const ct = gateRes.headers.get("content-type");
270
- if (ct)
271
- resHeaders["Content-Type"] = ct;
164
+ if (gateRes.contentType)
165
+ resHeaders["Content-Type"] = gateRes.contentType;
272
166
  res.writeHead(gateRes.status, resHeaders);
273
- if (isStream && gateRes.body) {
274
- const reader = gateRes.body.getReader();
167
+ if (isStream && gateRes.stream) {
168
+ // Filter out cashu-change SSE events from stream
169
+ const { filtered, changeToken: sseChangeToken } = extractCashuChangeFromSSE(gateRes.stream);
170
+ const reader = filtered.getReader();
275
171
  try {
276
172
  while (true) {
277
173
  const { done, value } = await reader.read();
@@ -284,20 +180,23 @@ export async function startProxy(config, logger = defaultLogger) {
284
180
  reader.releaseLock();
285
181
  }
286
182
  res.end();
183
+ // Process SSE change token (if any)
184
+ const sseChange = sseChangeToken();
185
+ if (sseChange) {
186
+ const sseChangeResult = await paymentService.receiveChange(sseChange);
187
+ txChangeSat += sseChangeResult;
188
+ }
287
189
  }
288
190
  else {
289
- res.end(await gateRes.text());
191
+ res.end(gateRes.body ?? "");
290
192
  }
291
193
  // Log balance warning
292
- const newBalance = wallet.balance;
293
- if (newBalance < lowBalanceThreshold) {
294
- logger.warn(`⚠️ Low ecash balance: ${newBalance} sat (threshold: ${lowBalanceThreshold})`);
295
- }
194
+ paymentService.checkLowBalance();
296
195
  // Record transaction
297
196
  appendTransaction({
298
197
  id: txId, timestamp: txStart, model: requestedModel,
299
198
  priceSat: currentPrice, changeSat: txChangeSat, refundSat: txRefundSat,
300
- gateStatus: gateRes.status, balanceBefore, balanceAfter: wallet.balance,
199
+ gateStatus: gateRes.status, balanceBefore, balanceAfter: paymentService.getBalance(),
301
200
  durationMs: Date.now() - txStart,
302
201
  }).catch(() => { });
303
202
  return;
@@ -305,37 +204,26 @@ export async function startProxy(config, logger = defaultLogger) {
305
204
  // Store last 429 response — mark gate for failover
306
205
  if (gateRegistry)
307
206
  gateRegistry.markFailed(currentGateUrl);
308
- lastResponse = gateRes;
309
- lastResponseBody = await gateRes.text();
310
- // On 429, calculate backoff delay
207
+ lastGateResponse = { status: gateRes.status, body: gateRes.body };
208
+ // On 429, calculate backoff delay (GateClient doesn't retry, we do it here for new tokens)
311
209
  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);
210
+ const backoffMs = Math.min(baseDelayMs * Math.pow(2, attempt), MAX_RETRY_DELAY_MS);
314
211
  logger.warn(`Rate limited (429), retrying in ${backoffMs}ms...`);
315
- await sleep(backoffMs);
212
+ await new Promise((r) => setTimeout(r, backoffMs));
316
213
  }
317
214
  }
318
215
  // All retries exhausted — record failed transaction
319
216
  appendTransaction({
320
217
  id: txId, timestamp: txStart, model: requestedModel,
321
218
  priceSat: price, changeSat: txChangeSat, refundSat: txRefundSat,
322
- gateStatus: lastResponse.status, balanceBefore, balanceAfter: wallet.balance,
219
+ gateStatus: lastGateResponse.status, balanceBefore, balanceAfter: paymentService.getBalance(),
323
220
  durationMs: Date.now() - txStart, error: "Rate limited after retries",
324
221
  }).catch(() => { });
325
- res.writeHead(lastResponse.status, { "Content-Type": "application/json" });
326
- res.end(lastResponseBody);
222
+ res.writeHead(lastGateResponse.status, { "Content-Type": "application/json" });
223
+ res.end(lastGateResponse.body ?? "");
327
224
  }
328
225
  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
- }
226
+ handleError(res, e, logger);
339
227
  }
340
228
  });
341
229
  server.listen(port, "127.0.0.1", () => {