MVP demo: one operator-funded API key anyone can plug into an IDE/harness,
running real escrow-per-session marketplace conversations (llm.chat.v1)
behind the standard OpenAI /chat/completions wire shape.
- shared: ChatMessage gains tool role + tool_calls; normalizeChatMessage/
chatMessagesEquivalent keep supplier transcript and gateway mirror
hash-identical for receipts (field presence is part of the identity)
- supplier: /v1/chat/message accepts {messages,tools,tool_choice} alongside
legacy {content}; callOpenAiStream forwards tools, accumulates fragmented
delta.tool_calls, allows content-less tool turns; done frame carries the
assistant message verbatim + finish_reason + usage
- gateway: demo flag on api_keys (additive migration), create-demo-key CLI,
withdraw 403 + hidden deposit address for demo keys, per-IP demo rate
limit (demo keys skip the shared per-key limit), models filtered to chat
capability
- gateway executor (demoChat.ts): transcript-prefix affinity maps stateless
calls onto open sessions, forwards only unseen deltas, transparently
reopens on supplier idle-close; never falls back to one-shot (its settle
path assumes <=1 in-flight escrow per wallet)
- mutex scope: key mutex held only for chain work (open/close); off-chain
turns serialize on per-session locks so replicas serve users concurrently
- sweeper: chat-aware reclaim guard (35min vs 11min) and abandoned-session
cleanup; terminal state confirmed via on-chain queryUtxo (indexer keeps
lineage rows at their old state forever)
- deploy: kimi-chat replicas 2+3 compose files; chat idle timeout 2.5min
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
/**
|
|
* gateway/src/middleware/rateLimit.ts — per-IP and per-key limiters.
|
|
*
|
|
* Small sliding-window limiter (same shape as buyer/src/auth.ts createRateLimiter,
|
|
* inlined to avoid coupling the operator-auth module into the gateway). Used to
|
|
* bound public /signup abuse (per IP) and per-key request volume (429 + Retry-After).
|
|
*/
|
|
|
|
import type { Request, Response, NextFunction } from "express";
|
|
import { rateLimited } from "../openai/errors.js";
|
|
import { sendError } from "./http.js";
|
|
import { requireKey } from "./apiKeyAuth.js";
|
|
|
|
interface Bucket {
|
|
count: number;
|
|
windowStart: number;
|
|
}
|
|
|
|
interface Limiter {
|
|
attempt(key: string): { allowed: boolean; retryAfterMs: number };
|
|
}
|
|
|
|
function createLimiter(opts: { max: number; windowMs: number }): Limiter {
|
|
const buckets = new Map<string, Bucket>();
|
|
return {
|
|
attempt(key: string) {
|
|
const t = Date.now();
|
|
const existing = buckets.get(key);
|
|
if (!existing || t - existing.windowStart >= opts.windowMs) {
|
|
buckets.set(key, { count: 1, windowStart: t });
|
|
return { allowed: true, retryAfterMs: 0 };
|
|
}
|
|
if (existing.count >= opts.max) {
|
|
return { allowed: false, retryAfterMs: Math.max(0, opts.windowMs - (t - existing.windowStart)) };
|
|
}
|
|
existing.count += 1;
|
|
return { allowed: true, retryAfterMs: 0 };
|
|
},
|
|
};
|
|
}
|
|
|
|
type Keyer = (req: Request) => string;
|
|
|
|
function makeLimiter(opts: { max: number; windowMs: number }, keyOf: Keyer) {
|
|
const limiter = createLimiter(opts);
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
const decision = limiter.attempt(keyOf(req));
|
|
if (!decision.allowed) {
|
|
const retrySec = Math.ceil(decision.retryAfterMs / 1000);
|
|
res.setHeader("Retry-After", retrySec.toString());
|
|
sendError(res, rateLimited(`rate limit exceeded; retry in ${retrySec}s`));
|
|
return;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
/** Per-IP limiter (for the public /signup endpoint). */
|
|
export function ipRateLimit(opts: { max: number; windowMs: number }) {
|
|
return makeLimiter(opts, (req) => req.ip ?? "unknown");
|
|
}
|
|
|
|
/** Per-API-key limiter (apply AFTER apiKeyAuth). Demo keys are exempt: the
|
|
* shared demo key would let one abusive IP exhaust everyone's budget — the
|
|
* per-IP demoIpRateLimit is the demo control instead. */
|
|
export function keyRateLimit(opts: { max: number; windowMs: number }) {
|
|
const limit = makeLimiter(opts, (req) => requireKey(req).id);
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
if (requireKey(req).demo) {
|
|
next();
|
|
return;
|
|
}
|
|
limit(req, res, next);
|
|
};
|
|
}
|
|
|
|
/** Per-IP limiter that applies ONLY to demo-key requests (after apiKeyAuth). */
|
|
export function demoIpRateLimit(opts: { max: number; windowMs: number }) {
|
|
const limit = makeLimiter(opts, (req) => req.ip ?? "unknown");
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
if (!requireKey(req).demo) {
|
|
next();
|
|
return;
|
|
}
|
|
limit(req, res, next);
|
|
};
|
|
}
|