@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.
- package/README.md +117 -144
- package/dist/cashu-store.d.ts +1 -1
- package/dist/cashu-store.js +4 -4
- package/dist/commands/audit.d.ts +65 -0
- package/dist/commands/audit.js +12 -12
- package/dist/commands/balance.js +2 -2
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/mint.js +14 -14
- package/dist/commands/monitor.d.ts +51 -0
- package/dist/commands/monitor.js +353 -0
- package/dist/commands/recover.js +4 -4
- package/dist/commands/setup.js +2 -2
- package/dist/commands/status.js +2 -3
- package/dist/config.d.ts +5 -0
- package/dist/config.js +17 -0
- package/dist/connectors/cursor.js +44 -15
- package/dist/index.js +8 -1
- package/dist/proxy/auth.d.ts +20 -0
- package/dist/proxy/auth.js +28 -0
- package/dist/proxy/errors.d.ts +58 -0
- package/dist/proxy/errors.js +95 -0
- package/dist/proxy/gate-client.d.ts +34 -0
- package/dist/proxy/gate-client.js +81 -0
- package/dist/proxy/index.d.ts +10 -0
- package/dist/proxy/index.js +17 -0
- package/dist/proxy/payment-service.d.ts +65 -0
- package/dist/proxy/payment-service.js +101 -0
- package/dist/proxy/pricing.d.ts +37 -0
- package/dist/proxy/pricing.js +90 -0
- package/dist/proxy/response.d.ts +24 -0
- package/dist/proxy/response.js +48 -0
- package/dist/proxy/sse-parser.d.ts +19 -0
- package/dist/proxy/sse-parser.js +80 -0
- package/dist/proxy/types.d.ts +113 -0
- package/dist/proxy/types.js +74 -0
- package/dist/proxy.d.ts +2 -9
- package/dist/proxy.js +74 -186
- package/package.json +4 -1
|
@@ -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
|
-
|
|
3
|
-
|
|
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,
|
|
7
|
+
import { resolveHome, appendFailedToken, appendTransaction, loadOrCreateProxySecret } from "./config.js";
|
|
9
8
|
import { GateRegistry } from "./gate-discovery.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
166
|
-
const models =
|
|
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
|
|
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
|
|
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 =
|
|
193
|
-
|
|
194
|
-
const price = getPrice(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
217
|
-
|
|
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(
|
|
224
|
-
|
|
225
|
-
const
|
|
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}
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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.
|
|
274
|
-
|
|
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(
|
|
191
|
+
res.end(gateRes.body ?? "");
|
|
290
192
|
}
|
|
291
193
|
// Log balance warning
|
|
292
|
-
|
|
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:
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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
|
|
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:
|
|
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(
|
|
326
|
-
res.end(
|
|
222
|
+
res.writeHead(lastGateResponse.status, { "Content-Type": "application/json" });
|
|
223
|
+
res.end(lastGateResponse.body ?? "");
|
|
327
224
|
}
|
|
328
225
|
catch (e) {
|
|
329
|
-
|
|
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", () => {
|