@ottocode/ai-sdk 0.1.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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@ottocode/ai-sdk",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts",
7
+ "./providers": "./src/providers/index.ts",
8
+ "./types": "./src/types.ts"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "dependencies": {
14
+ "@ai-sdk/anthropic": "^3.0.0",
15
+ "@ai-sdk/google": "^3.0.0",
16
+ "@ai-sdk/openai": "^3.0.0",
17
+ "@ai-sdk/openai-compatible": "^2.0.0",
18
+ "@solana/web3.js": "^1.98.0",
19
+ "bs58": "^6.0.0",
20
+ "tweetnacl": "^1.0.3",
21
+ "x402": "^1.1.0"
22
+ },
23
+ "peerDependencies": {
24
+ "ai": ">=6.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.0.0"
28
+ }
29
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { Keypair } from '@solana/web3.js';
2
+ import bs58 from 'bs58';
3
+ import nacl from 'tweetnacl';
4
+ import type { SetuAuth } from './types.ts';
5
+
6
+ export interface WalletContext {
7
+ keypair: Keypair;
8
+ walletAddress: string;
9
+ privateKeyBytes: Uint8Array;
10
+ }
11
+
12
+ export function createWalletContext(auth: SetuAuth): WalletContext {
13
+ const privateKeyBytes = bs58.decode(auth.privateKey);
14
+ const keypair = Keypair.fromSecretKey(privateKeyBytes);
15
+ const walletAddress = keypair.publicKey.toBase58();
16
+ return { keypair, walletAddress, privateKeyBytes };
17
+ }
18
+
19
+ export function signNonce(nonce: string, secretKey: Uint8Array): string {
20
+ const data = new TextEncoder().encode(nonce);
21
+ const signature = nacl.sign.detached(data, secretKey);
22
+ return bs58.encode(signature);
23
+ }
24
+
25
+ export function buildWalletHeaders(ctx: WalletContext): Record<string, string> {
26
+ const nonce = Date.now().toString();
27
+ const signature = signNonce(nonce, ctx.privateKeyBytes);
28
+ return {
29
+ 'x-wallet-address': ctx.walletAddress,
30
+ 'x-wallet-nonce': nonce,
31
+ 'x-wallet-signature': signature,
32
+ };
33
+ }
34
+
35
+ export function getPublicKeyFromPrivate(privateKey: string): string | null {
36
+ try {
37
+ const privateKeyBytes = bs58.decode(privateKey);
38
+ const keypair = Keypair.fromSecretKey(privateKeyBytes);
39
+ return keypair.publicKey.toBase58();
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
package/src/balance.ts ADDED
@@ -0,0 +1,109 @@
1
+ import bs58 from 'bs58';
2
+ import { Keypair } from '@solana/web3.js';
3
+ import type { SetuAuth, BalanceResponse, WalletUsdcBalance } from './types.ts';
4
+ import { signNonce } from './auth.ts';
5
+
6
+ const DEFAULT_BASE_URL = 'https://api.setu.ottocode.io';
7
+ const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com';
8
+ const USDC_MINT_MAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
9
+ const USDC_MINT_DEVNET = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
10
+
11
+ function trimTrailingSlash(url: string) {
12
+ return url.endsWith('/') ? url.slice(0, -1) : url;
13
+ }
14
+
15
+ export async function fetchBalance(
16
+ auth: SetuAuth,
17
+ baseURL?: string,
18
+ ): Promise<BalanceResponse | null> {
19
+ try {
20
+ const privateKeyBytes = bs58.decode(auth.privateKey);
21
+ const keypair = Keypair.fromSecretKey(privateKeyBytes);
22
+ const walletAddress = keypair.publicKey.toBase58();
23
+ const url = trimTrailingSlash(baseURL ?? DEFAULT_BASE_URL);
24
+
25
+ const nonce = Date.now().toString();
26
+ const signature = signNonce(nonce, privateKeyBytes);
27
+
28
+ const response = await fetch(`${url}/v1/balance`, {
29
+ headers: {
30
+ 'x-wallet-address': walletAddress,
31
+ 'x-wallet-nonce': nonce,
32
+ 'x-wallet-signature': signature,
33
+ },
34
+ });
35
+
36
+ if (!response.ok) return null;
37
+
38
+ const data = (await response.json()) as {
39
+ wallet_address: string;
40
+ balance_usd: number;
41
+ total_spent: number;
42
+ total_topups: number;
43
+ request_count: number;
44
+ created_at?: string;
45
+ last_request?: string;
46
+ };
47
+
48
+ return {
49
+ walletAddress: data.wallet_address,
50
+ balance: data.balance_usd,
51
+ totalSpent: data.total_spent,
52
+ totalTopups: data.total_topups,
53
+ requestCount: data.request_count,
54
+ createdAt: data.created_at,
55
+ lastRequest: data.last_request,
56
+ };
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export async function fetchWalletUsdcBalance(
63
+ auth: SetuAuth,
64
+ network: 'mainnet' | 'devnet' = 'mainnet',
65
+ ): Promise<WalletUsdcBalance | null> {
66
+ try {
67
+ const privateKeyBytes = bs58.decode(auth.privateKey);
68
+ const keypair = Keypair.fromSecretKey(privateKeyBytes);
69
+ const walletAddress = keypair.publicKey.toBase58();
70
+ const rpcUrl = network === 'devnet' ? 'https://api.devnet.solana.com' : DEFAULT_RPC_URL;
71
+ const usdcMint = network === 'devnet' ? USDC_MINT_DEVNET : USDC_MINT_MAINNET;
72
+
73
+ const response = await fetch(rpcUrl, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/json' },
76
+ body: JSON.stringify({
77
+ jsonrpc: '2.0',
78
+ id: 1,
79
+ method: 'getTokenAccountsByOwner',
80
+ params: [walletAddress, { mint: usdcMint }, { encoding: 'jsonParsed' }],
81
+ }),
82
+ });
83
+
84
+ if (!response.ok) return null;
85
+
86
+ const data = (await response.json()) as {
87
+ result?: {
88
+ value?: Array<{
89
+ account: {
90
+ data: {
91
+ parsed: {
92
+ info: { tokenAmount: { uiAmount: number } };
93
+ };
94
+ };
95
+ };
96
+ }>;
97
+ };
98
+ };
99
+
100
+ let totalUsdcBalance = 0;
101
+ for (const account of data.result?.value ?? []) {
102
+ totalUsdcBalance += account.account.data.parsed.info.tokenAmount.uiAmount ?? 0;
103
+ }
104
+
105
+ return { walletAddress, usdcBalance: totalUsdcBalance, network };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { AnthropicCacheConfig, AnthropicCachePlacement } from './types.ts';
2
+
3
+ interface MessageBlock {
4
+ type: string;
5
+ cache_control?: { type: string };
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ interface Message {
10
+ role: string;
11
+ content: unknown;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ interface ParsedBody {
16
+ system?: MessageBlock[];
17
+ messages?: Message[];
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ const DEFAULT_CONFIG: Required<Omit<AnthropicCacheConfig, 'transform'>> = {
22
+ strategy: 'auto',
23
+ systemBreakpoints: 1,
24
+ messageBreakpoints: 1,
25
+ systemPlacement: 'first',
26
+ messagePlacement: 'last',
27
+ cacheType: 'ephemeral',
28
+ };
29
+
30
+ function resolveConfig(config?: AnthropicCacheConfig): Required<Omit<AnthropicCacheConfig, 'transform'>> & { transform?: AnthropicCacheConfig['transform'] } {
31
+ if (!config) return { ...DEFAULT_CONFIG };
32
+ return {
33
+ strategy: config.strategy ?? DEFAULT_CONFIG.strategy,
34
+ systemBreakpoints: config.systemBreakpoints ?? DEFAULT_CONFIG.systemBreakpoints,
35
+ messageBreakpoints: config.messageBreakpoints ?? DEFAULT_CONFIG.messageBreakpoints,
36
+ systemPlacement: config.systemPlacement ?? DEFAULT_CONFIG.systemPlacement,
37
+ messagePlacement: config.messagePlacement ?? DEFAULT_CONFIG.messagePlacement,
38
+ cacheType: config.cacheType ?? DEFAULT_CONFIG.cacheType,
39
+ transform: config.transform,
40
+ };
41
+ }
42
+
43
+ function shouldCacheAtIndex(
44
+ index: number,
45
+ total: number,
46
+ maxBreakpoints: number,
47
+ placement: AnthropicCachePlacement,
48
+ ): boolean {
49
+ if (maxBreakpoints <= 0) return false;
50
+
51
+ switch (placement) {
52
+ case 'first': {
53
+ return index < maxBreakpoints;
54
+ }
55
+ case 'last': {
56
+ const startFrom = total - maxBreakpoints;
57
+ return index >= startFrom && index < total;
58
+ }
59
+ case 'all': {
60
+ return true;
61
+ }
62
+ }
63
+ }
64
+
65
+ function injectCacheOnBlocks(
66
+ blocks: MessageBlock[],
67
+ maxBreakpoints: number,
68
+ placement: AnthropicCachePlacement,
69
+ cacheType: string,
70
+ ): { blocks: MessageBlock[]; used: number } {
71
+ let used = 0;
72
+ const eligible = blocks.map((_, i) => shouldCacheAtIndex(i, blocks.length, maxBreakpoints, placement));
73
+ const result = blocks.map((block, i) => {
74
+ if (block.cache_control) return block;
75
+ if (used < maxBreakpoints && eligible[i]) {
76
+ used++;
77
+ return { ...block, cache_control: { type: cacheType } };
78
+ }
79
+ return block;
80
+ });
81
+ return { blocks: result, used };
82
+ }
83
+
84
+ export function addAnthropicCacheControl(
85
+ parsed: ParsedBody,
86
+ config?: AnthropicCacheConfig,
87
+ ): ParsedBody {
88
+ const resolved = resolveConfig(config);
89
+
90
+ if (resolved.strategy === false) return parsed;
91
+
92
+ if (resolved.strategy === 'manual') return parsed;
93
+
94
+ if (resolved.strategy === 'custom' && resolved.transform) {
95
+ return resolved.transform(parsed as Record<string, unknown>) as ParsedBody;
96
+ }
97
+
98
+ let systemUsed = 0;
99
+
100
+ if (parsed.system && Array.isArray(parsed.system)) {
101
+ const result = injectCacheOnBlocks(
102
+ parsed.system,
103
+ resolved.systemBreakpoints,
104
+ resolved.systemPlacement,
105
+ resolved.cacheType,
106
+ );
107
+ parsed.system = result.blocks;
108
+ systemUsed = result.used;
109
+ }
110
+
111
+ if (parsed.messages && Array.isArray(parsed.messages)) {
112
+ let messageUsed = 0;
113
+ const messageCount = parsed.messages.length;
114
+
115
+ const eligibleIndices: number[] = [];
116
+ for (let i = 0; i < messageCount; i++) {
117
+ if (shouldCacheAtIndex(i, messageCount, resolved.messageBreakpoints, resolved.messagePlacement)) {
118
+ eligibleIndices.push(i);
119
+ }
120
+ }
121
+
122
+ parsed.messages = parsed.messages.map((msg, msgIndex) => {
123
+ if (messageUsed >= resolved.messageBreakpoints) return msg;
124
+ if (!eligibleIndices.includes(msgIndex)) return msg;
125
+
126
+ if (Array.isArray(msg.content)) {
127
+ const blocks = msg.content as MessageBlock[];
128
+ const lastIdx = blocks.length - 1;
129
+ const content = blocks.map((block, blockIndex) => {
130
+ if (block.cache_control) return block;
131
+ if (blockIndex === lastIdx && messageUsed < resolved.messageBreakpoints) {
132
+ messageUsed++;
133
+ return { ...block, cache_control: { type: resolved.cacheType } };
134
+ }
135
+ return block;
136
+ });
137
+ return { ...msg, content };
138
+ }
139
+
140
+ if (typeof msg.content === 'string' && messageUsed < resolved.messageBreakpoints) {
141
+ messageUsed++;
142
+ return {
143
+ ...msg,
144
+ content: [
145
+ {
146
+ type: 'text',
147
+ text: msg.content,
148
+ cache_control: { type: resolved.cacheType },
149
+ },
150
+ ],
151
+ };
152
+ }
153
+
154
+ return msg;
155
+ });
156
+ }
157
+
158
+ return parsed;
159
+ }
package/src/fetch.ts ADDED
@@ -0,0 +1,288 @@
1
+ import type { WalletContext } from './auth.ts';
2
+ import type { PaymentCallbacks, PaymentOptions, CacheOptions, BalanceUpdate, AnthropicCacheConfig } from './types.ts';
3
+ import { buildWalletHeaders } from './auth.ts';
4
+ import { pickPaymentRequirement, handlePayment } from './payment.ts';
5
+ import { addAnthropicCacheControl } from './cache.ts';
6
+
7
+ const DEFAULT_RPC_URL = 'https://api.mainnet-beta.solana.com';
8
+ const DEFAULT_MAX_ATTEMPTS = 3;
9
+ const DEFAULT_MAX_PAYMENT_ATTEMPTS = 20;
10
+
11
+ interface PaymentQueueEntry {
12
+ promise: Promise<void>;
13
+ resolve: () => void;
14
+ }
15
+
16
+ const paymentQueues = new Map<string, PaymentQueueEntry>();
17
+ const globalPaymentAttempts = new Map<string, number>();
18
+
19
+ async function acquirePaymentLock(walletAddress: string): Promise<() => void> {
20
+ const existing = paymentQueues.get(walletAddress);
21
+
22
+ let resolveFunc: () => void = () => {};
23
+ const newPromise = new Promise<void>((resolve) => {
24
+ resolveFunc = resolve;
25
+ });
26
+
27
+ const entry: PaymentQueueEntry = { promise: newPromise, resolve: resolveFunc };
28
+ paymentQueues.set(walletAddress, entry);
29
+
30
+ if (existing) {
31
+ await existing.promise;
32
+ }
33
+
34
+ return () => {
35
+ if (paymentQueues.get(walletAddress) === entry) {
36
+ paymentQueues.delete(walletAddress);
37
+ }
38
+ resolveFunc();
39
+ };
40
+ }
41
+
42
+ function tryParseSetuComment(
43
+ line: string,
44
+ onBalanceUpdate: (update: BalanceUpdate) => void,
45
+ ) {
46
+ const trimmed = line.replace(/\r$/, '');
47
+ if (!trimmed.startsWith(': setu ')) return;
48
+ try {
49
+ const data = JSON.parse(trimmed.slice(7));
50
+ onBalanceUpdate({
51
+ costUsd: parseFloat(data.cost_usd ?? '0'),
52
+ balanceRemaining: parseFloat(data.balance_remaining ?? '0'),
53
+ inputTokens: data.input_tokens ? Number(data.input_tokens) : undefined,
54
+ outputTokens: data.output_tokens ? Number(data.output_tokens) : undefined,
55
+ });
56
+ } catch {}
57
+ }
58
+
59
+ function wrapResponseWithBalanceSniffing(
60
+ response: Response,
61
+ callbacks: PaymentCallbacks,
62
+ ): Response {
63
+ if (!callbacks.onBalanceUpdate) return response;
64
+
65
+ const balanceHeader = response.headers.get('x-balance-remaining');
66
+ const costHeader = response.headers.get('x-cost-usd');
67
+ if (balanceHeader && costHeader) {
68
+ callbacks.onBalanceUpdate({
69
+ costUsd: parseFloat(costHeader),
70
+ balanceRemaining: parseFloat(balanceHeader),
71
+ });
72
+ return response;
73
+ }
74
+
75
+ if (!response.body) return response;
76
+
77
+ const onBalanceUpdate = callbacks.onBalanceUpdate;
78
+ let partial = '';
79
+ const decoder = new TextDecoder();
80
+ const transform = new TransformStream<Uint8Array, Uint8Array>({
81
+ transform(chunk, controller) {
82
+ controller.enqueue(chunk);
83
+ partial += decoder.decode(chunk, { stream: true });
84
+ let nlIndex = partial.indexOf('\n');
85
+ while (nlIndex !== -1) {
86
+ const line = partial.slice(0, nlIndex);
87
+ partial = partial.slice(nlIndex + 1);
88
+ tryParseSetuComment(line, onBalanceUpdate);
89
+ nlIndex = partial.indexOf('\n');
90
+ }
91
+ },
92
+ flush() {
93
+ if (partial.trim()) {
94
+ tryParseSetuComment(partial, onBalanceUpdate);
95
+ }
96
+ },
97
+ });
98
+
99
+ return new Response(response.body.pipeThrough(transform), {
100
+ status: response.status,
101
+ statusText: response.statusText,
102
+ headers: response.headers,
103
+ });
104
+ }
105
+
106
+ async function getWalletUsdcBalance(
107
+ walletAddress: string,
108
+ rpcUrl: string,
109
+ ): Promise<number> {
110
+ const USDC_MINT_MAINNET = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
111
+ const USDC_MINT_DEVNET = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
112
+
113
+ try {
114
+ const usdcMint = rpcUrl.includes('devnet') ? USDC_MINT_DEVNET : USDC_MINT_MAINNET;
115
+ const response = await fetch(rpcUrl, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ jsonrpc: '2.0',
120
+ id: 1,
121
+ method: 'getTokenAccountsByOwner',
122
+ params: [walletAddress, { mint: usdcMint }, { encoding: 'jsonParsed' }],
123
+ }),
124
+ });
125
+ if (!response.ok) return 0;
126
+ const data = (await response.json()) as {
127
+ result?: {
128
+ value?: Array<{
129
+ account: {
130
+ data: { parsed: { info: { tokenAmount: { uiAmount: number } } } };
131
+ };
132
+ }>;
133
+ };
134
+ };
135
+ let total = 0;
136
+ for (const acct of data.result?.value ?? []) {
137
+ total += acct.account.data.parsed.info.tokenAmount.uiAmount ?? 0;
138
+ }
139
+ return total;
140
+ } catch {
141
+ return 0;
142
+ }
143
+ }
144
+
145
+ export interface CreateSetuFetchOptions {
146
+ wallet: WalletContext;
147
+ baseURL: string;
148
+ rpcURL?: string;
149
+ callbacks?: PaymentCallbacks;
150
+ cache?: CacheOptions;
151
+ payment?: PaymentOptions;
152
+ }
153
+
154
+ export function createSetuFetch(options: CreateSetuFetchOptions) {
155
+ const {
156
+ wallet,
157
+ baseURL,
158
+ rpcURL = DEFAULT_RPC_URL,
159
+ callbacks = {},
160
+ cache,
161
+ payment,
162
+ } = options;
163
+
164
+ const maxAttempts = payment?.maxRequestAttempts ?? DEFAULT_MAX_ATTEMPTS;
165
+ const maxPaymentAttempts = payment?.maxPaymentAttempts ?? DEFAULT_MAX_PAYMENT_ATTEMPTS;
166
+ const topupApprovalMode = payment?.topupApprovalMode ?? 'auto';
167
+ const autoPayThresholdUsd = payment?.autoPayThresholdUsd ?? 0;
168
+ const baseFetch = globalThis.fetch.bind(globalThis);
169
+
170
+ return async (
171
+ input: Parameters<typeof fetch>[0],
172
+ init?: Parameters<typeof fetch>[1],
173
+ ) => {
174
+ let attempt = 0;
175
+
176
+ while (attempt < maxAttempts) {
177
+ attempt++;
178
+ let body = init?.body;
179
+ if (body && typeof body === 'string') {
180
+ try {
181
+ const parsed = JSON.parse(body);
182
+ if (cache?.promptCacheKey) parsed.prompt_cache_key = cache.promptCacheKey;
183
+ if (cache?.promptCacheRetention) parsed.prompt_cache_retention = cache.promptCacheRetention;
184
+ const cacheConfig = cache?.anthropicCaching;
185
+ if (cacheConfig !== false) {
186
+ const anthropicConfig = typeof cacheConfig === 'object' ? cacheConfig : undefined;
187
+ addAnthropicCacheControl(parsed, anthropicConfig);
188
+ }
189
+ body = JSON.stringify(parsed);
190
+ } catch {}
191
+ }
192
+
193
+ const headers = new Headers(init?.headers);
194
+ const walletHeaders = buildWalletHeaders(wallet);
195
+ headers.set('x-wallet-address', walletHeaders['x-wallet-address']!);
196
+ headers.set('x-wallet-nonce', walletHeaders['x-wallet-nonce']!);
197
+ headers.set('x-wallet-signature', walletHeaders['x-wallet-signature']!);
198
+
199
+ const response = await baseFetch(input, { ...init, body, headers });
200
+
201
+ if (response.status !== 402) {
202
+ return wrapResponseWithBalanceSniffing(response, callbacks);
203
+ }
204
+
205
+ const payload = await response.json().catch(() => ({}));
206
+ const requirement = pickPaymentRequirement(payload);
207
+ if (!requirement) {
208
+ callbacks.onPaymentError?.('Unsupported payment requirement');
209
+ throw new Error('Setu: unsupported payment requirement');
210
+ }
211
+ if (attempt >= maxAttempts) {
212
+ callbacks.onPaymentError?.('Payment failed after multiple attempts');
213
+ throw new Error('Setu: payment failed after multiple attempts');
214
+ }
215
+
216
+ const currentAttempts = globalPaymentAttempts.get(wallet.walletAddress) ?? 0;
217
+ const remainingPayments = maxPaymentAttempts - currentAttempts;
218
+ if (remainingPayments <= 0) {
219
+ callbacks.onPaymentError?.('Maximum payment attempts exceeded');
220
+ throw new Error('Setu: payment failed after maximum payment attempts.');
221
+ }
222
+
223
+ const releaseLock = await acquirePaymentLock(wallet.walletAddress);
224
+
225
+ try {
226
+ const amountUsd = parseInt(requirement.maxAmountRequired, 10) / 1_000_000;
227
+ let walletUsdcBalance = 0;
228
+ if (autoPayThresholdUsd > 0) {
229
+ walletUsdcBalance = await getWalletUsdcBalance(wallet.walletAddress, rpcURL);
230
+ }
231
+
232
+ const canAutoPay = autoPayThresholdUsd > 0 && walletUsdcBalance >= autoPayThresholdUsd;
233
+
234
+ const requestApproval = async () => {
235
+ if (!callbacks.onPaymentApproval) return;
236
+ const approval = await callbacks.onPaymentApproval({
237
+ amountUsd,
238
+ currentBalance: walletUsdcBalance,
239
+ });
240
+ if (approval === 'cancel') {
241
+ callbacks.onPaymentError?.('Payment cancelled by user');
242
+ throw new Error('Setu: payment cancelled by user');
243
+ }
244
+ if (approval === 'fiat') {
245
+ const err = new Error('Setu: fiat payment selected') as Error & { code: string };
246
+ err.code = 'SETU_FIAT_SELECTED';
247
+ throw err;
248
+ }
249
+ };
250
+
251
+ if (!canAutoPay && topupApprovalMode === 'approval') {
252
+ await requestApproval();
253
+ }
254
+
255
+ callbacks.onPaymentRequired?.(amountUsd, walletUsdcBalance);
256
+
257
+ const doPayment = async () => {
258
+ const outcome = await handlePayment({
259
+ requirement,
260
+ wallet,
261
+ rpcURL,
262
+ baseURL,
263
+ baseFetch,
264
+ maxAttempts: remainingPayments,
265
+ callbacks,
266
+ });
267
+ const newTotal = currentAttempts + outcome.attemptsUsed;
268
+ globalPaymentAttempts.set(wallet.walletAddress, newTotal);
269
+ };
270
+
271
+ if (canAutoPay) {
272
+ try {
273
+ await doPayment();
274
+ } catch {
275
+ await requestApproval();
276
+ await doPayment();
277
+ }
278
+ } else {
279
+ await doPayment();
280
+ }
281
+ } finally {
282
+ releaseLock();
283
+ }
284
+ }
285
+
286
+ throw new Error('Setu: max attempts exceeded');
287
+ };
288
+ }
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export { createSetu } from './setu.ts';
2
+ export type { SetuInstance, SetuProvider } from './setu.ts';
3
+
4
+ export type {
5
+ SetuConfig,
6
+ SetuAuth,
7
+ ProviderId,
8
+ ProviderApiFormat,
9
+ ProviderConfig,
10
+ PaymentCallbacks,
11
+ PaymentOptions,
12
+ CacheOptions,
13
+ BalanceUpdate,
14
+ BalanceResponse,
15
+ WalletUsdcBalance,
16
+ ExactPaymentRequirement,
17
+ PaymentPayload,
18
+ FetchFunction,
19
+ AnthropicCacheStrategy,
20
+ AnthropicCachePlacement,
21
+ AnthropicCacheConfig,
22
+ } from './types.ts';
23
+
24
+ export { ProviderRegistry } from './providers/registry.ts';
25
+ export { createModel } from './providers/factory.ts';
26
+
27
+ export { createSetuFetch } from './fetch.ts';
28
+ export type { CreateSetuFetchOptions } from './fetch.ts';
29
+
30
+ export { fetchBalance, fetchWalletUsdcBalance } from './balance.ts';
31
+ export { getPublicKeyFromPrivate } from './auth.ts';
32
+ export { addAnthropicCacheControl } from './cache.ts';
package/src/payment.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import bs58 from 'bs58';
3
+ import { createPaymentHeader } from 'x402/client';
4
+ import { svm } from 'x402/shared';
5
+ import type { PaymentRequirements } from 'x402/types';
6
+ import type { WalletContext } from './auth.ts';
7
+ import type {
8
+ ExactPaymentRequirement,
9
+ PaymentPayload,
10
+ PaymentCallbacks,
11
+ } from './types.ts';
12
+ import { buildWalletHeaders } from './auth.ts';
13
+
14
+ function simplifyPaymentError(errMsg: string): string {
15
+ const lower = errMsg.toLowerCase();
16
+ if (lower.includes('insufficient') || lower.includes('not enough') || lower.includes('balance'))
17
+ return 'Insufficient USDC balance';
18
+ if (lower.includes('simulation') || lower.includes('compute unit'))
19
+ return 'Transaction simulation failed';
20
+ if (lower.includes('blockhash') || lower.includes('expired'))
21
+ return 'Transaction expired, please retry';
22
+ if (lower.includes('timeout') || lower.includes('timed out'))
23
+ return 'Transaction timed out';
24
+ if (lower.includes('rejected') || lower.includes('cancelled'))
25
+ return 'Transaction rejected';
26
+ if (lower.includes('network') || lower.includes('connection'))
27
+ return 'Network error';
28
+ const short = (errMsg.split('.')[0] ?? errMsg).slice(0, 80);
29
+ return short.length < errMsg.length ? `${short}...` : errMsg;
30
+ }
31
+
32
+ export function pickPaymentRequirement(
33
+ payload: unknown,
34
+ ): ExactPaymentRequirement | null {
35
+ const acceptsValue =
36
+ typeof payload === 'object' && payload !== null
37
+ ? (payload as { accepts?: unknown }).accepts
38
+ : undefined;
39
+ const accepts = Array.isArray(acceptsValue)
40
+ ? (acceptsValue as ExactPaymentRequirement[])
41
+ : [];
42
+ return accepts.find((opt) => opt && opt.scheme === 'exact') ?? null;
43
+ }
44
+
45
+ export async function createPaymentPayload(
46
+ requirement: ExactPaymentRequirement,
47
+ wallet: WalletContext,
48
+ rpcURL: string,
49
+ ): Promise<PaymentPayload> {
50
+ const privateKeyBase58 = bs58.encode(wallet.keypair.secretKey);
51
+ const signer = await svm.createSignerFromBase58(privateKeyBase58);
52
+ const header = await createPaymentHeader(
53
+ signer,
54
+ 1,
55
+ requirement as PaymentRequirements,
56
+ { svmConfig: { rpcUrl: rpcURL } },
57
+ );
58
+ const decoded = JSON.parse(
59
+ Buffer.from(header, 'base64').toString('utf-8'),
60
+ ) as { payload: { transaction: string } };
61
+
62
+ return {
63
+ x402Version: 1,
64
+ scheme: 'exact',
65
+ network: requirement.network,
66
+ payload: { transaction: decoded.payload.transaction },
67
+ };
68
+ }
69
+
70
+ interface PaymentResponse {
71
+ amount_usd?: number | string;
72
+ new_balance?: number | string;
73
+ amount?: number;
74
+ balance?: number;
75
+ transaction?: string;
76
+ }
77
+
78
+ export async function processSinglePayment(args: {
79
+ requirement: ExactPaymentRequirement;
80
+ wallet: WalletContext;
81
+ rpcURL: string;
82
+ baseURL: string;
83
+ baseFetch: typeof fetch;
84
+ callbacks: PaymentCallbacks;
85
+ }): Promise<{ attempts: number; balance?: number | string }> {
86
+ args.callbacks.onPaymentSigning?.();
87
+
88
+ let paymentPayload: PaymentPayload;
89
+ try {
90
+ paymentPayload = await createPaymentPayload(
91
+ args.requirement,
92
+ args.wallet,
93
+ args.rpcURL,
94
+ );
95
+ } catch (err) {
96
+ const errMsg = err instanceof Error ? err.message : String(err);
97
+ const userMsg = `Payment failed: ${simplifyPaymentError(errMsg)}`;
98
+ args.callbacks.onPaymentError?.(userMsg);
99
+ throw new Error(`Setu: ${userMsg}`);
100
+ }
101
+
102
+ const walletHeaders = buildWalletHeaders(args.wallet);
103
+ const response = await args.baseFetch(`${args.baseURL}/v1/topup`, {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json', ...walletHeaders },
106
+ body: JSON.stringify({
107
+ paymentPayload,
108
+ paymentRequirement: args.requirement,
109
+ }),
110
+ });
111
+
112
+ const rawBody = await response.text().catch(() => '');
113
+ if (!response.ok) {
114
+ if (response.status === 400 && rawBody.toLowerCase().includes('already processed')) {
115
+ return { attempts: 1 };
116
+ }
117
+ args.callbacks.onPaymentError?.(`Topup failed: ${response.status}`);
118
+ throw new Error(`Setu topup failed (${response.status}): ${rawBody}`);
119
+ }
120
+
121
+ let parsed: PaymentResponse | undefined;
122
+ try {
123
+ parsed = rawBody ? (JSON.parse(rawBody) as PaymentResponse) : undefined;
124
+ } catch {
125
+ parsed = undefined;
126
+ }
127
+
128
+ if (parsed) {
129
+ const amountUsd =
130
+ typeof parsed.amount_usd === 'string'
131
+ ? parseFloat(parsed.amount_usd)
132
+ : (parsed.amount_usd ?? parsed.amount ?? 0);
133
+ const newBalance =
134
+ typeof parsed.new_balance === 'string'
135
+ ? parseFloat(parsed.new_balance)
136
+ : (parsed.new_balance ?? parsed.balance ?? 0);
137
+ args.callbacks.onPaymentComplete?.({
138
+ amountUsd,
139
+ newBalance,
140
+ transactionId: parsed.transaction,
141
+ });
142
+ return { attempts: 1, balance: newBalance };
143
+ }
144
+ return { attempts: 1 };
145
+ }
146
+
147
+ export async function handlePayment(args: {
148
+ requirement: ExactPaymentRequirement;
149
+ wallet: WalletContext;
150
+ rpcURL: string;
151
+ baseURL: string;
152
+ baseFetch: typeof fetch;
153
+ maxAttempts: number;
154
+ callbacks: PaymentCallbacks;
155
+ }): Promise<{ attemptsUsed: number }> {
156
+ let attempts = 0;
157
+ while (attempts < args.maxAttempts) {
158
+ const result = await processSinglePayment(args);
159
+ attempts += result.attempts;
160
+ const balanceValue =
161
+ typeof result.balance === 'number'
162
+ ? result.balance
163
+ : result.balance != null
164
+ ? Number(result.balance)
165
+ : undefined;
166
+ if (balanceValue == null || Number.isNaN(balanceValue) || balanceValue >= 0) {
167
+ return { attemptsUsed: attempts };
168
+ }
169
+ }
170
+ throw new Error(`Setu: payment failed after ${attempts} additional top-ups.`);
171
+ }
@@ -0,0 +1,55 @@
1
+ import { createOpenAI } from '@ai-sdk/openai';
2
+ import { createAnthropic } from '@ai-sdk/anthropic';
3
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
4
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
5
+ import type { ProviderApiFormat, ProviderId, FetchFunction } from '../types.ts';
6
+
7
+ export function createModel(
8
+ modelId: string,
9
+ apiFormat: ProviderApiFormat,
10
+ providerId: ProviderId,
11
+ baseURL: string,
12
+ customFetch: FetchFunction,
13
+ ) {
14
+ const fetchFn = customFetch as unknown as typeof globalThis.fetch;
15
+
16
+ switch (apiFormat) {
17
+ case 'anthropic-messages': {
18
+ const provider = createAnthropic({
19
+ baseURL,
20
+ apiKey: 'setu-wallet-auth',
21
+ fetch: fetchFn,
22
+ });
23
+ return provider(modelId);
24
+ }
25
+
26
+ case 'google-native': {
27
+ const provider = createGoogleGenerativeAI({
28
+ baseURL,
29
+ apiKey: 'setu-wallet-auth',
30
+ fetch: fetchFn,
31
+ });
32
+ return provider(modelId);
33
+ }
34
+
35
+ case 'openai-chat': {
36
+ const provider = createOpenAICompatible({
37
+ name: `setu-${providerId}`,
38
+ baseURL,
39
+ headers: { Authorization: 'Bearer setu-wallet-auth' },
40
+ fetch: fetchFn,
41
+ });
42
+ return provider(modelId);
43
+ }
44
+
45
+ case 'openai-responses':
46
+ default: {
47
+ const provider = createOpenAI({
48
+ baseURL,
49
+ apiKey: 'setu-wallet-auth',
50
+ fetch: fetchFn,
51
+ });
52
+ return provider.responses(modelId);
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,2 @@
1
+ export { ProviderRegistry } from './registry.ts';
2
+ export { createModel } from './factory.ts';
@@ -0,0 +1,100 @@
1
+ import type { ProviderId, ProviderConfig, ProviderApiFormat } from '../types.ts';
2
+
3
+ const BUILTIN_PROVIDERS: ProviderConfig[] = [
4
+ {
5
+ id: 'openai',
6
+ apiFormat: 'openai-responses',
7
+ modelPrefix: 'gpt-',
8
+ },
9
+ {
10
+ id: 'openai',
11
+ apiFormat: 'openai-responses',
12
+ modelPrefix: 'o1',
13
+ },
14
+ {
15
+ id: 'openai',
16
+ apiFormat: 'openai-responses',
17
+ modelPrefix: 'o3',
18
+ },
19
+ {
20
+ id: 'openai',
21
+ apiFormat: 'openai-responses',
22
+ modelPrefix: 'o4',
23
+ },
24
+ {
25
+ id: 'openai',
26
+ apiFormat: 'openai-responses',
27
+ modelPrefix: 'codex-',
28
+ },
29
+ {
30
+ id: 'anthropic',
31
+ apiFormat: 'anthropic-messages',
32
+ modelPrefix: 'claude-',
33
+ },
34
+ {
35
+ id: 'google',
36
+ apiFormat: 'google-native',
37
+ modelPrefix: 'gemini-',
38
+ },
39
+ {
40
+ id: 'moonshot',
41
+ apiFormat: 'openai-chat',
42
+ modelPrefix: 'kimi-',
43
+ },
44
+ {
45
+ id: 'minimax',
46
+ apiFormat: 'anthropic-messages',
47
+ modelPrefix: 'MiniMax-',
48
+ },
49
+ {
50
+ id: 'zai',
51
+ apiFormat: 'openai-chat',
52
+ modelPrefix: 'z1-',
53
+ },
54
+ ];
55
+
56
+ export class ProviderRegistry {
57
+ private configs: ProviderConfig[];
58
+ private modelMap: Record<string, ProviderId>;
59
+
60
+ constructor(
61
+ customProviders?: ProviderConfig[],
62
+ modelMap?: Record<string, ProviderId>,
63
+ ) {
64
+ this.configs = [...BUILTIN_PROVIDERS, ...(customProviders ?? [])];
65
+ this.modelMap = modelMap ?? {};
66
+ }
67
+
68
+ resolve(modelId: string): { providerId: ProviderId; apiFormat: ProviderApiFormat } | null {
69
+ if (this.modelMap[modelId]) {
70
+ const providerId = this.modelMap[modelId];
71
+ const config = this.configs.find((c) => c.id === providerId);
72
+ return {
73
+ providerId,
74
+ apiFormat: config?.apiFormat ?? 'openai-chat',
75
+ };
76
+ }
77
+
78
+ for (const config of this.configs) {
79
+ if (config.models?.includes(modelId)) {
80
+ return { providerId: config.id, apiFormat: config.apiFormat };
81
+ }
82
+ }
83
+
84
+ for (const config of this.configs) {
85
+ if (config.modelPrefix && modelId.startsWith(config.modelPrefix)) {
86
+ return { providerId: config.id, apiFormat: config.apiFormat };
87
+ }
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ register(config: ProviderConfig): void {
94
+ this.configs.push(config);
95
+ }
96
+
97
+ mapModel(modelId: string, providerId: ProviderId): void {
98
+ this.modelMap[modelId] = providerId;
99
+ }
100
+ }
package/src/setu.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type { SetuConfig, ProviderId, ProviderApiFormat, FetchFunction, BalanceResponse, WalletUsdcBalance } from './types.ts';
2
+ import { createWalletContext, getPublicKeyFromPrivate } from './auth.ts';
3
+ import { createSetuFetch } from './fetch.ts';
4
+ import { ProviderRegistry } from './providers/registry.ts';
5
+ import { createModel } from './providers/factory.ts';
6
+ import { fetchBalance, fetchWalletUsdcBalance } from './balance.ts';
7
+
8
+ const DEFAULT_BASE_URL = 'https://api.setu.ottocode.io';
9
+
10
+ function trimTrailingSlash(url: string) {
11
+ return url.endsWith('/') ? url.slice(0, -1) : url;
12
+ }
13
+
14
+ export interface SetuProvider {
15
+ model(modelId: string): ReturnType<typeof createModel>;
16
+ }
17
+
18
+ export interface SetuInstance {
19
+ model(modelId: string): ReturnType<typeof createModel>;
20
+ provider(providerId: ProviderId, apiFormat?: ProviderApiFormat): SetuProvider;
21
+ fetch(): FetchFunction;
22
+ balance(): Promise<BalanceResponse | null>;
23
+ walletBalance(network?: 'mainnet' | 'devnet'): Promise<WalletUsdcBalance | null>;
24
+ walletAddress: string | null;
25
+ registry: ProviderRegistry;
26
+ }
27
+
28
+ export function createSetu(config: SetuConfig): SetuInstance {
29
+ const baseURL = trimTrailingSlash(config.baseURL ?? DEFAULT_BASE_URL);
30
+ const wallet = createWalletContext(config.auth);
31
+ const registry = new ProviderRegistry(config.providers, config.modelMap);
32
+
33
+ const setuFetch = createSetuFetch({
34
+ wallet,
35
+ baseURL,
36
+ rpcURL: config.rpcURL,
37
+ callbacks: config.callbacks,
38
+ cache: config.cache,
39
+ payment: config.payment,
40
+ });
41
+
42
+ const modelBaseURL = `${baseURL}/v1`;
43
+
44
+ return {
45
+ model(modelId: string) {
46
+ const resolved = registry.resolve(modelId);
47
+ if (!resolved) {
48
+ throw new Error(
49
+ `Setu: unknown model "${modelId}". Register it via providers or modelMap config.`,
50
+ );
51
+ }
52
+ return createModel(
53
+ modelId,
54
+ resolved.apiFormat,
55
+ resolved.providerId,
56
+ modelBaseURL,
57
+ setuFetch,
58
+ );
59
+ },
60
+
61
+ provider(providerId: ProviderId, apiFormat?: ProviderApiFormat): SetuProvider {
62
+ return {
63
+ model(modelId: string) {
64
+ const resolved = registry.resolve(modelId);
65
+ const format = apiFormat ?? resolved?.apiFormat ?? 'openai-chat';
66
+ return createModel(modelId, format, providerId, modelBaseURL, setuFetch);
67
+ },
68
+ };
69
+ },
70
+
71
+ fetch(): FetchFunction {
72
+ return setuFetch;
73
+ },
74
+
75
+ async balance() {
76
+ return fetchBalance(config.auth, baseURL);
77
+ },
78
+
79
+ async walletBalance(network?: 'mainnet' | 'devnet') {
80
+ return fetchWalletUsdcBalance(config.auth, network);
81
+ },
82
+
83
+ walletAddress: getPublicKeyFromPrivate(config.auth.privateKey),
84
+
85
+ registry,
86
+ };
87
+ }
package/src/types.ts ADDED
@@ -0,0 +1,119 @@
1
+ export type ProviderId =
2
+ | 'openai'
3
+ | 'anthropic'
4
+ | 'google'
5
+ | 'moonshot'
6
+ | 'zai'
7
+ | 'minimax'
8
+ | (string & {});
9
+
10
+ export type ProviderApiFormat = 'openai-responses' | 'anthropic-messages' | 'openai-chat' | 'google-native';
11
+
12
+ export type FetchFunction = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
13
+
14
+ export interface ProviderConfig {
15
+ id: ProviderId;
16
+ apiFormat: ProviderApiFormat;
17
+ models?: string[];
18
+ modelPrefix?: string;
19
+ }
20
+
21
+ export interface SetuAuth {
22
+ privateKey: string;
23
+ }
24
+
25
+ export interface BalanceUpdate {
26
+ costUsd: number;
27
+ balanceRemaining: number;
28
+ inputTokens?: number;
29
+ outputTokens?: number;
30
+ }
31
+
32
+ export interface PaymentCallbacks {
33
+ onPaymentRequired?: (amountUsd: number, currentBalance?: number) => void;
34
+ onPaymentSigning?: () => void;
35
+ onPaymentComplete?: (data: {
36
+ amountUsd: number;
37
+ newBalance: number;
38
+ transactionId?: string;
39
+ }) => void;
40
+ onPaymentError?: (error: string) => void;
41
+ onPaymentApproval?: (info: {
42
+ amountUsd: number;
43
+ currentBalance: number;
44
+ }) => Promise<'crypto' | 'fiat' | 'cancel'>;
45
+ onBalanceUpdate?: (update: BalanceUpdate) => void;
46
+ }
47
+
48
+ export type AnthropicCacheStrategy = 'auto' | 'manual' | 'custom' | false;
49
+
50
+ export type AnthropicCachePlacement = 'first' | 'last' | 'all';
51
+
52
+ export interface AnthropicCacheConfig {
53
+ strategy?: AnthropicCacheStrategy;
54
+ systemBreakpoints?: number;
55
+ messageBreakpoints?: number;
56
+ systemPlacement?: AnthropicCachePlacement;
57
+ messagePlacement?: AnthropicCachePlacement;
58
+ cacheType?: 'ephemeral';
59
+ transform?: (body: Record<string, unknown>) => Record<string, unknown>;
60
+ }
61
+
62
+ export interface CacheOptions {
63
+ promptCacheKey?: string;
64
+ promptCacheRetention?: 'in_memory' | '24h';
65
+ anthropicCaching?: boolean | AnthropicCacheConfig;
66
+ }
67
+
68
+ export interface PaymentOptions {
69
+ topupApprovalMode?: 'auto' | 'approval';
70
+ autoPayThresholdUsd?: number;
71
+ maxRequestAttempts?: number;
72
+ maxPaymentAttempts?: number;
73
+ }
74
+
75
+ export interface SetuConfig {
76
+ auth: SetuAuth;
77
+ baseURL?: string;
78
+ rpcURL?: string;
79
+ providers?: ProviderConfig[];
80
+ modelMap?: Record<string, ProviderId>;
81
+ callbacks?: PaymentCallbacks;
82
+ cache?: CacheOptions;
83
+ payment?: PaymentOptions;
84
+ }
85
+
86
+ export interface ExactPaymentRequirement {
87
+ scheme: 'exact';
88
+ network: string;
89
+ maxAmountRequired: string;
90
+ asset: string;
91
+ payTo: string;
92
+ description?: string;
93
+ resource?: string;
94
+ extra?: Record<string, unknown>;
95
+ maxTimeoutSeconds?: number;
96
+ }
97
+
98
+ export interface PaymentPayload {
99
+ x402Version: 1;
100
+ scheme: 'exact';
101
+ network: string;
102
+ payload: { transaction: string };
103
+ }
104
+
105
+ export interface BalanceResponse {
106
+ walletAddress: string;
107
+ balance: number;
108
+ totalSpent: number;
109
+ totalTopups: number;
110
+ requestCount: number;
111
+ createdAt?: string;
112
+ lastRequest?: string;
113
+ }
114
+
115
+ export interface WalletUsdcBalance {
116
+ walletAddress: string;
117
+ usdcBalance: number;
118
+ network: 'mainnet' | 'devnet';
119
+ }