@token2chat/t2c 0.2.0-beta.1 → 0.2.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 (40) hide show
  1. package/README.md +117 -144
  2. package/dist/cashu-store.d.ts +1 -1
  3. package/dist/cashu-store.js +4 -4
  4. package/dist/commands/audit.d.ts +65 -0
  5. package/dist/commands/audit.js +12 -12
  6. package/dist/commands/balance.js +2 -2
  7. package/dist/commands/doctor.js +2 -2
  8. package/dist/commands/init.js +2 -2
  9. package/dist/commands/mint.js +14 -14
  10. package/dist/commands/monitor.d.ts +51 -0
  11. package/dist/commands/monitor.js +353 -0
  12. package/dist/commands/recover.js +4 -4
  13. package/dist/commands/setup.js +2 -2
  14. package/dist/commands/status.js +2 -3
  15. package/dist/config.d.ts +5 -0
  16. package/dist/config.js +17 -0
  17. package/dist/connectors/cursor.js +44 -15
  18. package/dist/connectors/openclaw.js +32 -24
  19. package/dist/index.js +8 -1
  20. package/dist/proxy/auth.d.ts +20 -0
  21. package/dist/proxy/auth.js +28 -0
  22. package/dist/proxy/errors.d.ts +58 -0
  23. package/dist/proxy/errors.js +95 -0
  24. package/dist/proxy/gate-client.d.ts +34 -0
  25. package/dist/proxy/gate-client.js +81 -0
  26. package/dist/proxy/index.d.ts +10 -0
  27. package/dist/proxy/index.js +17 -0
  28. package/dist/proxy/payment-service.d.ts +65 -0
  29. package/dist/proxy/payment-service.js +101 -0
  30. package/dist/proxy/pricing.d.ts +37 -0
  31. package/dist/proxy/pricing.js +90 -0
  32. package/dist/proxy/response.d.ts +24 -0
  33. package/dist/proxy/response.js +48 -0
  34. package/dist/proxy/sse-parser.d.ts +19 -0
  35. package/dist/proxy/sse-parser.js +80 -0
  36. package/dist/proxy/types.d.ts +113 -0
  37. package/dist/proxy/types.js +74 -0
  38. package/dist/proxy.d.ts +2 -9
  39. package/dist/proxy.js +74 -186
  40. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -16,6 +16,7 @@ import { recoverCommand } from "./commands/recover.js";
16
16
  import { doctorCommand } from "./commands/doctor.js";
17
17
  import { balanceCommand } from "./commands/balance.js";
18
18
  import { auditCommand } from "./commands/audit.js";
19
+ import { monitorCommand } from "./commands/monitor.js";
19
20
  // debug command is loaded dynamically — excluded from npm package
20
21
  const program = new Command();
21
22
  program
@@ -108,6 +109,12 @@ program
108
109
  .option("--json", "Output as JSON")
109
110
  .option("-n, --lines <n>", "Number of recent transactions to show", "20")
110
111
  .action(auditCommand);
112
+ // t2c monitor - Live TUI dashboard
113
+ program
114
+ .command("monitor")
115
+ .description("Live TUI dashboard for Gate, Mint, Proxy, and Funds")
116
+ .option("-r, --refresh <seconds>", "Refresh interval in seconds", "5")
117
+ .action(monitorCommand);
111
118
  // t2c config - Generate config for AI tools
112
119
  const config = program
113
120
  .command("config")
@@ -168,7 +175,7 @@ try {
168
175
  debug
169
176
  .command("topup")
170
177
  .description("Transfer ecash from Gate to local plugin wallet")
171
- .requiredOption("--amount <sats>", "Amount in sats to withdraw from Gate")
178
+ .requiredOption("--amount <units>", "Amount in units to withdraw from Gate")
172
179
  .action((opts) => debugCommand("topup", opts));
173
180
  }
174
181
  catch {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Request-like object with authorization header.
3
+ */
4
+ export interface AuthRequest {
5
+ headers: {
6
+ authorization?: string;
7
+ };
8
+ }
9
+ /**
10
+ * Function that checks if a request is authenticated.
11
+ */
12
+ export type AuthChecker = (req: AuthRequest) => boolean;
13
+ /**
14
+ * Create an auth checker function for Bearer token authentication.
15
+ * Uses timing-safe comparison to prevent timing attacks.
16
+ *
17
+ * @param secret - The expected Bearer token value
18
+ * @returns A function that returns true if the request has a valid Bearer token
19
+ */
20
+ export declare function createAuthChecker(secret: string): AuthChecker;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Authentication middleware for proxy requests.
3
+ * Uses timing-safe comparison to prevent timing attacks.
4
+ */
5
+ import crypto from "node:crypto";
6
+ /**
7
+ * Create an auth checker function for Bearer token authentication.
8
+ * Uses timing-safe comparison to prevent timing attacks.
9
+ *
10
+ * @param secret - The expected Bearer token value
11
+ * @returns A function that returns true if the request has a valid Bearer token
12
+ */
13
+ export function createAuthChecker(secret) {
14
+ return (req) => {
15
+ const auth = req.headers.authorization;
16
+ if (!auth)
17
+ return false;
18
+ const parts = auth.split(" ");
19
+ if (parts.length !== 2 || parts[0] !== "Bearer")
20
+ return false;
21
+ const provided = Buffer.from(parts[1]);
22
+ const expected = Buffer.from(secret);
23
+ // Length check before timing-safe comparison
24
+ if (provided.length !== expected.length)
25
+ return false;
26
+ return crypto.timingSafeEqual(provided, expected);
27
+ };
28
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Proxy error classes with OpenAI-compatible JSON serialization.
3
+ */
4
+ export interface ProxyErrorJSON {
5
+ error: {
6
+ code: string;
7
+ message: string;
8
+ type?: string;
9
+ };
10
+ }
11
+ /**
12
+ * Base error class for proxy errors.
13
+ * Serializes to OpenAI-compatible error format.
14
+ */
15
+ export declare class ProxyError extends Error {
16
+ readonly code: string;
17
+ readonly httpStatus: number;
18
+ readonly type?: string;
19
+ constructor(code: string, message: string, httpStatus?: number, type?: string);
20
+ toJSON(): ProxyErrorJSON;
21
+ }
22
+ /**
23
+ * Thrown when wallet balance is insufficient for the requested model.
24
+ */
25
+ export declare class InsufficientBalanceError extends ProxyError {
26
+ readonly balance: number;
27
+ readonly required: number;
28
+ readonly model: string;
29
+ constructor(balance: number, required: number, model: string);
30
+ }
31
+ /**
32
+ * Thrown when the Gate is unreachable.
33
+ */
34
+ export declare class GateUnreachableError extends ProxyError {
35
+ readonly gateUrl: string;
36
+ constructor(gateUrl: string, cause?: Error);
37
+ }
38
+ /**
39
+ * Thrown when request body exceeds size limit.
40
+ */
41
+ export declare class PayloadTooLargeError extends ProxyError {
42
+ readonly size: number;
43
+ readonly limit: number;
44
+ constructor(size: number, limit: number);
45
+ }
46
+ /**
47
+ * Thrown when authentication fails.
48
+ */
49
+ export declare class UnauthorizedError extends ProxyError {
50
+ constructor(message?: string);
51
+ }
52
+ /**
53
+ * Thrown when endpoint is not found.
54
+ */
55
+ export declare class NotFoundError extends ProxyError {
56
+ readonly path: string;
57
+ constructor(path: string);
58
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Proxy error classes with OpenAI-compatible JSON serialization.
3
+ */
4
+ /**
5
+ * Base error class for proxy errors.
6
+ * Serializes to OpenAI-compatible error format.
7
+ */
8
+ export class ProxyError extends Error {
9
+ code;
10
+ httpStatus;
11
+ type;
12
+ constructor(code, message, httpStatus = 500, type) {
13
+ super(message);
14
+ this.name = "ProxyError";
15
+ this.code = code;
16
+ this.httpStatus = httpStatus;
17
+ this.type = type;
18
+ }
19
+ toJSON() {
20
+ return {
21
+ error: {
22
+ code: this.code,
23
+ message: this.message,
24
+ ...(this.type && { type: this.type }),
25
+ },
26
+ };
27
+ }
28
+ }
29
+ /**
30
+ * Thrown when wallet balance is insufficient for the requested model.
31
+ */
32
+ export class InsufficientBalanceError extends ProxyError {
33
+ balance;
34
+ required;
35
+ model;
36
+ constructor(balance, required, model) {
37
+ super("insufficient_balance", `Wallet balance ${balance} < ${required} required for ${model}. Run 't2c mint' to add funds.`, 402, "insufficient_funds");
38
+ this.name = "InsufficientBalanceError";
39
+ this.balance = balance;
40
+ this.required = required;
41
+ this.model = model;
42
+ }
43
+ }
44
+ /**
45
+ * Thrown when the Gate is unreachable.
46
+ */
47
+ export class GateUnreachableError extends ProxyError {
48
+ gateUrl;
49
+ constructor(gateUrl, cause) {
50
+ const host = new URL(gateUrl).host;
51
+ super("gate_unreachable", `Gate at ${host} is unreachable`, 502);
52
+ this.name = "GateUnreachableError";
53
+ this.gateUrl = gateUrl;
54
+ if (cause) {
55
+ this.cause = cause;
56
+ }
57
+ }
58
+ }
59
+ function formatBytes(bytes) {
60
+ const mb = bytes / (1024 * 1024);
61
+ return `${mb.toFixed(1)} MB`;
62
+ }
63
+ /**
64
+ * Thrown when request body exceeds size limit.
65
+ */
66
+ export class PayloadTooLargeError extends ProxyError {
67
+ size;
68
+ limit;
69
+ constructor(size, limit) {
70
+ super("payload_too_large", `Request body ${formatBytes(size)} exceeds limit of ${formatBytes(limit)}`, 413);
71
+ this.name = "PayloadTooLargeError";
72
+ this.size = size;
73
+ this.limit = limit;
74
+ }
75
+ }
76
+ /**
77
+ * Thrown when authentication fails.
78
+ */
79
+ export class UnauthorizedError extends ProxyError {
80
+ constructor(message = "Unauthorized. Provide a valid Bearer token.") {
81
+ super("unauthorized", message, 401, "authentication_error");
82
+ this.name = "UnauthorizedError";
83
+ }
84
+ }
85
+ /**
86
+ * Thrown when endpoint is not found.
87
+ */
88
+ export class NotFoundError extends ProxyError {
89
+ path;
90
+ constructor(path) {
91
+ super("not_found", `Endpoint not found: ${path}`, 404);
92
+ this.name = "NotFoundError";
93
+ this.path = path;
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * GateClient - HTTP client for token2chat Gate with retry logic.
3
+ */
4
+ import { type Logger } from "./types.js";
5
+ export interface GateClientOptions {
6
+ fetchFn?: typeof fetch;
7
+ logger?: Logger;
8
+ }
9
+ export interface GateRequestOptions {
10
+ path: string;
11
+ body: string;
12
+ token: string;
13
+ gateUrl?: string;
14
+ stream?: boolean;
15
+ maxRetries?: number;
16
+ baseDelayMs?: number;
17
+ maxDelayMs?: number;
18
+ }
19
+ export interface GateResponse {
20
+ status: number;
21
+ body?: string;
22
+ stream?: ReadableStream<Uint8Array>;
23
+ contentType?: string;
24
+ changeToken?: string;
25
+ refundToken?: string;
26
+ retriesExhausted?: boolean;
27
+ }
28
+ export declare class GateClient {
29
+ private readonly gateUrl;
30
+ private readonly fetchFn;
31
+ private readonly logger;
32
+ constructor(gateUrl: string, options?: GateClientOptions);
33
+ request(options: GateRequestOptions): Promise<GateResponse>;
34
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * GateClient - HTTP client for token2chat Gate with retry logic.
3
+ */
4
+ import { defaultLogger, parseRetryAfter, MAX_RETRY_DELAY_MS } from "./types.js";
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ export class GateClient {
9
+ gateUrl;
10
+ fetchFn;
11
+ logger;
12
+ constructor(gateUrl, options = {}) {
13
+ this.gateUrl = gateUrl;
14
+ this.fetchFn = options.fetchFn ?? fetch;
15
+ this.logger = options.logger ?? defaultLogger;
16
+ }
17
+ async request(options) {
18
+ const { path, body, token, gateUrl = this.gateUrl, stream = false, maxRetries = 0, baseDelayMs = 2000, maxDelayMs = MAX_RETRY_DELAY_MS, } = options;
19
+ const url = `${gateUrl}${path}`;
20
+ let lastResponse = null;
21
+ let lastBody;
22
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
+ try {
24
+ const res = await this.fetchFn(url, {
25
+ method: "POST",
26
+ headers: {
27
+ "Content-Type": "application/json",
28
+ "X-Cashu": token,
29
+ },
30
+ body,
31
+ });
32
+ // Extract change/refund tokens
33
+ const changeToken = res.headers.get("X-Cashu-Change") ?? undefined;
34
+ const refundToken = res.headers.get("X-Cashu-Refund") ?? undefined;
35
+ const contentType = res.headers.get("content-type") ?? undefined;
36
+ // If not 429, return immediately
37
+ if (res.status !== 429) {
38
+ if (stream && res.body) {
39
+ return {
40
+ status: res.status,
41
+ stream: res.body,
42
+ contentType,
43
+ changeToken,
44
+ refundToken,
45
+ };
46
+ }
47
+ return {
48
+ status: res.status,
49
+ body: await res.text(),
50
+ contentType,
51
+ changeToken,
52
+ refundToken,
53
+ };
54
+ }
55
+ // Store for potential return after retries exhausted
56
+ lastResponse = res;
57
+ lastBody = await res.text();
58
+ // Log and wait before retry
59
+ if (attempt < maxRetries) {
60
+ const retryAfterMs = parseRetryAfter(res.headers.get("Retry-After"));
61
+ const backoffMs = Math.min(retryAfterMs ?? baseDelayMs * Math.pow(2, attempt), maxDelayMs);
62
+ this.logger.warn(`Rate limited (429), retrying in ${backoffMs}ms...`);
63
+ await sleep(backoffMs);
64
+ }
65
+ }
66
+ catch (e) {
67
+ this.logger.error("Gate request failed:", e);
68
+ throw e;
69
+ }
70
+ }
71
+ // All retries exhausted
72
+ return {
73
+ status: lastResponse.status,
74
+ body: lastBody,
75
+ contentType: lastResponse.headers.get("content-type") ?? undefined,
76
+ changeToken: lastResponse.headers.get("X-Cashu-Change") ?? undefined,
77
+ refundToken: lastResponse.headers.get("X-Cashu-Refund") ?? undefined,
78
+ retriesExhausted: true,
79
+ };
80
+ }
81
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Proxy module exports.
3
+ */
4
+ export * from "./types.js";
5
+ export * from "./errors.js";
6
+ export { PricingCache, type PricingCacheOptions } from "./pricing.js";
7
+ export { GateClient, type GateClientOptions, type GateRequestOptions, type GateResponse } from "./gate-client.js";
8
+ export { PaymentService, type PaymentServiceOptions, type Wallet, type TokenSelectionResult, type GateTokensResult } from "./payment-service.js";
9
+ export { createAuthChecker, type AuthChecker, type AuthRequest } from "./auth.js";
10
+ export { handleError, sendError, sendJsonResponse, type ResponseWriter } from "./response.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Proxy module exports.
3
+ */
4
+ // Types and utilities
5
+ export * from "./types.js";
6
+ // Error classes
7
+ export * from "./errors.js";
8
+ // Pricing cache
9
+ export { PricingCache } from "./pricing.js";
10
+ // Gate client
11
+ export { GateClient } from "./gate-client.js";
12
+ // Payment service
13
+ export { PaymentService } from "./payment-service.js";
14
+ // Auth
15
+ export { createAuthChecker } from "./auth.js";
16
+ // Response utilities
17
+ export { handleError, sendError, sendJsonResponse } from "./response.js";
@@ -0,0 +1,65 @@
1
+ /**
2
+ * PaymentService - Handles ecash payment operations for proxy requests.
3
+ */
4
+ import { type Logger } from "./types.js";
5
+ /**
6
+ * Minimal wallet interface for PaymentService.
7
+ */
8
+ export interface Wallet {
9
+ balance: number;
10
+ selectAndEncode(amount: number): Promise<string>;
11
+ receiveToken(token: string): Promise<number>;
12
+ }
13
+ export interface PaymentServiceOptions {
14
+ wallet: Wallet;
15
+ logger?: Logger;
16
+ appendFailedToken?: (token: string, type: "change" | "refund", error: string) => Promise<void>;
17
+ lowBalanceThreshold?: number;
18
+ }
19
+ export interface TokenSelectionResult {
20
+ token: string;
21
+ balanceBefore: number;
22
+ balanceAfter: number;
23
+ }
24
+ export interface GateTokensResult {
25
+ changeSat: number;
26
+ refundSat: number;
27
+ }
28
+ export declare class PaymentService {
29
+ private readonly wallet;
30
+ private readonly logger;
31
+ private readonly appendFailedToken;
32
+ private readonly lowBalanceThreshold;
33
+ constructor(options: PaymentServiceOptions);
34
+ /**
35
+ * Check if wallet has sufficient balance for the request.
36
+ * @throws InsufficientBalanceError if balance is insufficient
37
+ */
38
+ checkBalance(required: number, model: string): boolean;
39
+ /**
40
+ * Select and encode tokens for payment.
41
+ */
42
+ selectToken(amount: number): Promise<TokenSelectionResult>;
43
+ /**
44
+ * Receive change token from Gate.
45
+ * Returns amount received, or 0 if failed.
46
+ */
47
+ receiveChange(token: string): Promise<number>;
48
+ /**
49
+ * Receive refund token from Gate.
50
+ * Returns amount received, or 0 if failed.
51
+ */
52
+ receiveRefund(token: string): Promise<number>;
53
+ /**
54
+ * Process change and refund tokens from Gate response.
55
+ */
56
+ processGateTokens(changeToken?: string, refundToken?: string): Promise<GateTokensResult>;
57
+ /**
58
+ * Check and warn if balance is below threshold.
59
+ */
60
+ checkLowBalance(): void;
61
+ /**
62
+ * Get current wallet balance.
63
+ */
64
+ getBalance(): number;
65
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * PaymentService - Handles ecash payment operations for proxy requests.
3
+ */
4
+ import { defaultLogger } from "./types.js";
5
+ import { InsufficientBalanceError } from "./errors.js";
6
+ export class PaymentService {
7
+ wallet;
8
+ logger;
9
+ appendFailedToken;
10
+ lowBalanceThreshold;
11
+ constructor(options) {
12
+ this.wallet = options.wallet;
13
+ this.logger = options.logger ?? defaultLogger;
14
+ this.appendFailedToken = options.appendFailedToken ?? (async () => { });
15
+ this.lowBalanceThreshold = options.lowBalanceThreshold ?? 100;
16
+ }
17
+ /**
18
+ * Check if wallet has sufficient balance for the request.
19
+ * @throws InsufficientBalanceError if balance is insufficient
20
+ */
21
+ checkBalance(required, model) {
22
+ const balance = this.wallet.balance;
23
+ if (balance < required) {
24
+ this.logger.warn(`Insufficient balance: ${balance} < ${required} for ${model}`);
25
+ throw new InsufficientBalanceError(balance, required, model);
26
+ }
27
+ return true;
28
+ }
29
+ /**
30
+ * Select and encode tokens for payment.
31
+ */
32
+ async selectToken(amount) {
33
+ const balanceBefore = this.wallet.balance;
34
+ const token = await this.wallet.selectAndEncode(amount);
35
+ const balanceAfter = this.wallet.balance;
36
+ return { token, balanceBefore, balanceAfter };
37
+ }
38
+ /**
39
+ * Receive change token from Gate.
40
+ * Returns amount received, or 0 if failed.
41
+ */
42
+ async receiveChange(token) {
43
+ try {
44
+ const amount = await this.wallet.receiveToken(token);
45
+ this.logger.info(`Received ${amount} change`);
46
+ return amount;
47
+ }
48
+ catch (e) {
49
+ const errMsg = e instanceof Error ? e.message : String(e);
50
+ this.logger.warn(`Failed to store change: ${errMsg}`);
51
+ await this.appendFailedToken(token, "change", errMsg);
52
+ return 0;
53
+ }
54
+ }
55
+ /**
56
+ * Receive refund token from Gate.
57
+ * Returns amount received, or 0 if failed.
58
+ */
59
+ async receiveRefund(token) {
60
+ try {
61
+ const amount = await this.wallet.receiveToken(token);
62
+ this.logger.info(`Received ${amount} refund`);
63
+ return amount;
64
+ }
65
+ catch (e) {
66
+ const errMsg = e instanceof Error ? e.message : String(e);
67
+ this.logger.warn(`Failed to store refund: ${errMsg}`);
68
+ await this.appendFailedToken(token, "refund", errMsg);
69
+ return 0;
70
+ }
71
+ }
72
+ /**
73
+ * Process change and refund tokens from Gate response.
74
+ */
75
+ async processGateTokens(changeToken, refundToken) {
76
+ let changeSat = 0;
77
+ let refundSat = 0;
78
+ if (changeToken) {
79
+ changeSat = await this.receiveChange(changeToken);
80
+ }
81
+ if (refundToken) {
82
+ refundSat = await this.receiveRefund(refundToken);
83
+ }
84
+ return { changeSat, refundSat };
85
+ }
86
+ /**
87
+ * Check and warn if balance is below threshold.
88
+ */
89
+ checkLowBalance() {
90
+ const balance = this.wallet.balance;
91
+ if (balance < this.lowBalanceThreshold) {
92
+ this.logger.warn(`⚠️ Low ecash balance: ${balance} (threshold: ${this.lowBalanceThreshold})`);
93
+ }
94
+ }
95
+ /**
96
+ * Get current wallet balance.
97
+ */
98
+ getBalance() {
99
+ return this.wallet.balance;
100
+ }
101
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * PricingCache - Caches model pricing from Gate with TTL.
3
+ */
4
+ export interface PricingCacheOptions {
5
+ ttlMs?: number;
6
+ fetchFn?: typeof fetch;
7
+ }
8
+ export declare class PricingCache {
9
+ private cache;
10
+ private fetchedAt;
11
+ private fetchPromise;
12
+ private readonly gateUrl;
13
+ private readonly ttlMs;
14
+ private readonly fetchFn;
15
+ constructor(gateUrl: string, options?: PricingCacheOptions);
16
+ /**
17
+ * Get cached pricing, refreshing if stale.
18
+ */
19
+ get(): Promise<Record<string, number>>;
20
+ /**
21
+ * Force refresh pricing from gate.
22
+ */
23
+ refresh(): Promise<void>;
24
+ /**
25
+ * Clear cache, forcing next get() to refetch.
26
+ */
27
+ invalidate(): void;
28
+ /**
29
+ * Get price for a model. Uses wildcard "*" or default if not found.
30
+ */
31
+ getPrice(model: string, defaultPrice?: number): number;
32
+ /**
33
+ * Get list of available models (excludes wildcard).
34
+ */
35
+ getModels(): string[];
36
+ private doFetch;
37
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * PricingCache - Caches model pricing from Gate with TTL.
3
+ */
4
+ const DEFAULT_TTL_MS = 5 * 60_000; // 5 minutes
5
+ const DEFAULT_PRICE = 500; // units
6
+ export class PricingCache {
7
+ cache = null;
8
+ fetchedAt = 0;
9
+ fetchPromise = null;
10
+ gateUrl;
11
+ ttlMs;
12
+ fetchFn;
13
+ constructor(gateUrl, options = {}) {
14
+ this.gateUrl = gateUrl;
15
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
16
+ this.fetchFn = options.fetchFn ?? fetch;
17
+ }
18
+ /**
19
+ * Get cached pricing, refreshing if stale.
20
+ */
21
+ async get() {
22
+ const now = Date.now();
23
+ if (this.cache && now - this.fetchedAt < this.ttlMs) {
24
+ return this.cache;
25
+ }
26
+ // Deduplicate concurrent fetches
27
+ if (this.fetchPromise) {
28
+ return this.fetchPromise;
29
+ }
30
+ this.fetchPromise = this.doFetch();
31
+ try {
32
+ return await this.fetchPromise;
33
+ }
34
+ finally {
35
+ this.fetchPromise = null;
36
+ }
37
+ }
38
+ /**
39
+ * Force refresh pricing from gate.
40
+ */
41
+ async refresh() {
42
+ this.fetchPromise = null;
43
+ await this.doFetch();
44
+ }
45
+ /**
46
+ * Clear cache, forcing next get() to refetch.
47
+ */
48
+ invalidate() {
49
+ this.cache = null;
50
+ this.fetchedAt = 0;
51
+ this.fetchPromise = null;
52
+ }
53
+ /**
54
+ * Get price for a model. Uses wildcard "*" or default if not found.
55
+ */
56
+ getPrice(model, defaultPrice = DEFAULT_PRICE) {
57
+ if (!this.cache) {
58
+ return defaultPrice;
59
+ }
60
+ return this.cache[model] ?? this.cache["*"] ?? defaultPrice;
61
+ }
62
+ /**
63
+ * Get list of available models (excludes wildcard).
64
+ */
65
+ getModels() {
66
+ if (!this.cache) {
67
+ return [];
68
+ }
69
+ return Object.keys(this.cache).filter((m) => m !== "*");
70
+ }
71
+ async doFetch() {
72
+ try {
73
+ const res = await this.fetchFn(`${this.gateUrl}/v1/pricing`);
74
+ if (!res.ok) {
75
+ return this.cache ?? {};
76
+ }
77
+ const data = (await res.json());
78
+ const prices = {};
79
+ for (const [model, rule] of Object.entries(data.models)) {
80
+ prices[model] = rule.per_request;
81
+ }
82
+ this.cache = prices;
83
+ this.fetchedAt = Date.now();
84
+ return prices;
85
+ }
86
+ catch {
87
+ return this.cache ?? {};
88
+ }
89
+ }
90
+ }