// Free-first tier for the remote MCP endpoint (POST /mcp tools/call). Paid REST routes and their x402 terms are untouched.
// Each client (IPv4 address, or IPv6 /64) gets FREE_DAILY_PER_CLIENT full-result calls per UTC day (default 30); each
// tool also has a global FREE_DAILY_GLOBAL cap (default 5,000) so a traffic spike falls back to the paid path instead
// of burning Workers / upstream budget. The per-minute Rate Limiting binding (FREE_LIMITER) still runs first, so floods
// never reach the counter. Counts live in one SQLite Durable Object per tool (binding FREE_TIER, class FreeTierCounter
// in free-tier-do.js); client keys are stored as sha256(day|ip) prefixes, never raw IPs, and old days are pruned.
// Without the binding (tests, local dev) an in-memory store is used. Storage errors fail open (the minute limiter stays).
export const FREE_TIER_DEFAULTS = { perClient: 30, globalDaily: 5000 };
export const CREDITS_URL = 'https://api.youraitools.co/credits/';
export const utcDay = (t) => new Date(t).toISOString().slice(0, 10);
export const nextResetIso = (t) => new Date(Date.parse(`${utcDay(t)}T00:00:00Z`) + 86400000).toISOString();
function expandV6(ip) {
const [head, tail = ''] = ip.split('::');
const h = head ? head.split(':') : []; const t = ip.includes('::') ? (tail ? tail.split(':') : []) : [];
const fill = ip.includes('::') ? Array(Math.max(0, 8 - h.length - t.length)).fill('0') : [];
return [...h, ...fill, ...t].map((x) => x.toLowerCase().padStart(4, '0'));
}
/** Client identity for the free tier: IPv4 as-is, IPv6 by /64 (one subscriber line), else 'unknown'. */
export function clientId(c) {
const raw = (c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for')?.split(',')[0] || '').trim();
if (!raw) return 'unknown';
if (raw.includes(':') && !/^\d+\.\d+\.\d+\.\d+$/.test(raw)) {
const v4 = raw.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/i); if (v4) return v4[1];
try { return `${expandV6(raw).slice(0, 4).join(':')}::/64`; } catch { return raw; }
}
return raw;
}
async function hashKey(day, id) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`yat-free|${day}|${id}`));
return [...new Uint8Array(buf)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('');
}
/** Same contract as FreeTierCounter (free-tier-do.js), in memory. */
export class MemoryFreeTierStore {
constructor() { this.m = new Map(); this.day = null; }
take(day, k, perClient, globalDaily) {
if (day !== this.day) { this.m.clear(); this.day = day; }
const n = this.m.get(k) || 0; const g = this.m.get('*') || 0;
if (n >= perClient) return { ok: false, reason: 'client', used: n, global_used: g };
if (g >= globalDaily) return { ok: false, reason: 'global', used: n, global_used: g };
this.m.set(k, n + 1); this.m.set('*', g + 1);
return { ok: true, used: n + 1, global_used: g + 1 };
}
refund(day, k) {
if (day !== this.day) return false;
const n = this.m.get(k) || 0; const g = this.m.get('*') || 0;
if (n > 0) this.m.set(k, n - 1); if (g > 0) this.m.set('*', g - 1);
return true;
}
}
/** Store backed by the FREE_TIER Durable Object namespace (one instance per tool). */
export function durableFreeTierStore(ns) {
const stub = () => ns.get(ns.idFromName('free-tier'));
return { take: (...a) => stub().take(...a), refund: (...a) => stub().refund(...a) };
}
/**
* @param {{store?: any, perClient?: number, globalDaily?: number, now?: () => number}} opts
* take(c) -> { ok, reason?, used, remaining, limit, resets_at, failOpen? }; refund(c, t) gives a call back (tool error).
*/
export function createFreeTier({ store, perClient, globalDaily, now = () => Date.now() } = {}) {
const per = Number(perClient) > 0 ? Math.floor(Number(perClient)) : FREE_TIER_DEFAULTS.perClient;
const glob = Number(globalDaily) > 0 ? Math.floor(Number(globalDaily)) : FREE_TIER_DEFAULTS.globalDaily;
const s = store || new MemoryFreeTierStore();
return {
perClient: per, globalDaily: glob,
async take(c) {
const t = now(); const day = utcDay(t); const base = { limit: per, resets_at: nextResetIso(t) };
let k;
try {
k = await hashKey(day, clientId(c));
const r = await s.take(day, k, per, glob);
return { ...base, ...r, remaining: Math.max(0, per - (r.used || 0)), day, k };
} catch (e) {
console.warn(`free-tier: store error, failing open: ${e?.message || e}`);
return { ...base, ok: true, failOpen: true, used: null, remaining: null, day, k };
}
},
async refund(t) { if (!t?.ok || t.failOpen || !t.k) return; try { await s.refund(t.day, t.k); } catch { /* fail-open */ } },
};
}
/** The extra `upgrade` string on every free MCP result (also the last line of the text output). */
export function upgradeLine(t, { price, url }) {
const left = t && t.remaining !== null && t.remaining !== undefined ? `${t.remaining}/${t.limit} calls left today` : `${t?.limit ?? FREE_TIER_DEFAULTS.perClient} calls/day`;
return `Free tier: ${left}. Heavy use? Pay per call in USDC via x402 (${price}/call): ${url}`;
}
/** Free-tier summary object added to structured results. */
export function freeTierInfo(t) {
return { tier: 'free', limit_per_day: t.limit, used_today: t.used, remaining_today: t.remaining, resets_at: t.resets_at, scope: 'per IP address (IPv6: per /64)' };
}
/** MCP tool result once the free tier is used up: the MCP form of a 402, with the paid x402 URL and terms. */
export function freeTierExhaustedResult(t, { tool, price, url, network = 'eip155:8453', priceNote }) {
const why = t.reason === 'global'
? `${tool}'s free capacity for today is used up (shared daily cap, resets ${t.resets_at}).`
: `Free tier used up: ${t.used}/${t.limit} calls today from this IP (resets ${t.resets_at}).`;
const upgrade = `Pay per call in USDC via x402 (${price}/call): ${url}`;
const text = `${why}\n${upgrade}\nPrepaid credits (one-click wallet button): ${CREDITS_URL}`;
return {
isError: true,
content: [{ type: 'text', text }],
structuredContent: {
error: 'payment_required', status: 402, reason: t.reason === 'global' ? 'free_tier_global_cap' : 'free_tier_daily_limit',
message: why, upgrade,
free_tier: { limit_per_day: t.limit, used_today: t.used, remaining_today: 0, resets_at: t.resets_at },
x402: { url, price, asset: 'USDC', network, ...(priceNote ? { note: priceNote } : {}), how: 'Call the URL with an x402 client (e.g. @x402/fetch); the HTTP 402 response carries the PAYMENT-REQUIRED terms. Base, Polygon, Arbitrum One and Solana USDC accepted.' },
credits: CREDITS_URL,
},
};
}
agentgate-source: code mirror on Cloudflare Pages. All repos