@relayplane/proxy 1.8.10 → 1.8.12

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.
@@ -85,6 +85,12 @@ const agent_tracker_js_1 = require("./agent-tracker.js");
85
85
  const version_status_js_1 = require("./utils/version-status.js");
86
86
  const signup_nudge_js_1 = require("./signup-nudge.js");
87
87
  const star_nudge_js_1 = require("./star-nudge.js");
88
+ const estimate_js_1 = require("./estimate.js");
89
+ // Per-IP rate limit state for /v1/estimate (60 req/min per IP)
90
+ const estimateRateMap = new Map();
91
+ // Fix A: Purge expired rate-limit entries every 5 minutes to prevent memory leak.
92
+ // Without this, IPs that make one request and disappear stay in the map forever.
93
+ setInterval(() => (0, estimate_js_1.purgeExpiredRateLimitEntries)(estimateRateMap, Date.now()), 5 * 60 * 1000);
88
94
  const PROXY_VERSION = (() => {
89
95
  try {
90
96
  const pkgPath = path.join(__dirname, '..', 'package.json');
@@ -987,12 +993,29 @@ function getAuthForModel(model, authConfig, envApiKey) {
987
993
  }
988
994
  return { apiKey: envApiKey, isMax: false };
989
995
  }
996
+ /**
997
+ * Set Anthropic auth header for a token.
998
+ * OAT tokens (sk-ant-oat*) require Authorization: Bearer + oauth beta header.
999
+ * Standard API keys use x-api-key.
1000
+ */
1001
+ function setAnthropicAuth(headers, token) {
1002
+ if (token.startsWith('sk-ant-oat')) {
1003
+ headers['Authorization'] = `Bearer ${token}`;
1004
+ const existing = headers['anthropic-beta'];
1005
+ const oauthBeta = 'oauth-2025-04-20';
1006
+ if (!existing) {
1007
+ headers['anthropic-beta'] = oauthBeta;
1008
+ }
1009
+ else if (!existing.includes(oauthBeta)) {
1010
+ headers['anthropic-beta'] = `${existing},${oauthBeta}`;
1011
+ }
1012
+ }
1013
+ else {
1014
+ headers['x-api-key'] = token;
1015
+ }
1016
+ }
990
1017
  /**
991
1018
  * Build Anthropic headers with hybrid auth support
992
- *
993
- * All Anthropic token types (sk-ant-api*, sk-ant-oat*) work via x-api-key header.
994
- * The Bearer header approach was incorrectly assumed for oat tokens but Anthropic
995
- * rejects it with "OAuth authentication is currently not supported."
996
1019
  */
997
1020
  function buildAnthropicHeadersWithAuth(ctx, apiKey, isMaxToken, isRerouted) {
998
1021
  const headers = {
@@ -1000,23 +1023,25 @@ function buildAnthropicHeadersWithAuth(ctx, apiKey, isMaxToken, isRerouted) {
1000
1023
  'anthropic-version': ctx.versionHeader || '2023-06-01',
1001
1024
  };
1002
1025
  // Auth priority: incoming auth (passthrough) > configured API key > env key
1003
- // All Anthropic tokens use x-api-key header (including sk-ant-oat* MAX tokens)
1004
1026
  if (ctx.authHeader) {
1005
- // Extract token from "Bearer <token>" and send as x-api-key
1006
1027
  const token = ctx.authHeader.replace(/^Bearer\s+/i, '');
1007
- headers['x-api-key'] = token;
1028
+ setAnthropicAuth(headers, token);
1008
1029
  }
1009
1030
  else if (ctx.apiKeyHeader) {
1010
- // Incoming x-api-key header — pass through as-is
1011
- headers['x-api-key'] = ctx.apiKeyHeader;
1031
+ setAnthropicAuth(headers, ctx.apiKeyHeader);
1012
1032
  }
1013
1033
  else if (apiKey) {
1014
- // Fallback to configured API key
1015
- headers['x-api-key'] = apiKey;
1034
+ setAnthropicAuth(headers, apiKey);
1016
1035
  }
1017
1036
  // Pass through beta headers
1018
1037
  if (ctx.betaHeaders) {
1019
- headers['anthropic-beta'] = ctx.betaHeaders;
1038
+ const existing = headers['anthropic-beta'];
1039
+ if (!existing) {
1040
+ headers['anthropic-beta'] = ctx.betaHeaders;
1041
+ }
1042
+ else if (!existing.includes(ctx.betaHeaders)) {
1043
+ headers['anthropic-beta'] = `${existing},${ctx.betaHeaders}`;
1044
+ }
1020
1045
  }
1021
1046
  // Pass through OAuth identity headers (required by Anthropic for OAuth token validation)
1022
1047
  if (ctx.userAgent) {
@@ -1029,35 +1054,32 @@ function buildAnthropicHeadersWithAuth(ctx, apiKey, isMaxToken, isRerouted) {
1029
1054
  }
1030
1055
  /**
1031
1056
  * Build Anthropic headers with auth passthrough support
1032
- *
1033
- * Auth priority:
1034
- * 1. Incoming Authorization header (Bearer token from Claude Code OAuth)
1035
- * 2. Incoming x-api-key header
1036
- * 3. ANTHROPIC_API_KEY env var (or MAX token for Opus models)
1037
1057
  */
1038
1058
  function buildAnthropicHeaders(ctx, envApiKey) {
1039
1059
  const headers = {
1040
1060
  'Content-Type': 'application/json',
1041
1061
  'anthropic-version': ctx.versionHeader || '2023-06-01',
1042
1062
  };
1043
- // Auth: prefer incoming auth for passthrough, fallback to env
1044
- // All Anthropic tokens use x-api-key (including sk-ant-oat* MAX tokens)
1063
+ // Auth priority: incoming auth > x-api-key header > env key
1045
1064
  if (ctx.authHeader) {
1046
- // Extract token from "Bearer <token>" and send as x-api-key
1047
1065
  const token = ctx.authHeader.replace(/^Bearer\s+/i, '');
1048
- headers['x-api-key'] = token;
1066
+ setAnthropicAuth(headers, token);
1049
1067
  }
1050
1068
  else if (ctx.apiKeyHeader) {
1051
- // Direct x-api-key header
1052
- headers['x-api-key'] = ctx.apiKeyHeader;
1069
+ setAnthropicAuth(headers, ctx.apiKeyHeader);
1053
1070
  }
1054
1071
  else if (envApiKey) {
1055
- // Fallback to env var
1056
- headers['x-api-key'] = envApiKey;
1072
+ setAnthropicAuth(headers, envApiKey);
1057
1073
  }
1058
- // Pass through beta headers (prompt caching, extended thinking, etc.)
1074
+ // Pass through beta headers
1059
1075
  if (ctx.betaHeaders) {
1060
- headers['anthropic-beta'] = ctx.betaHeaders;
1076
+ const existing = headers['anthropic-beta'];
1077
+ if (!existing) {
1078
+ headers['anthropic-beta'] = ctx.betaHeaders;
1079
+ }
1080
+ else if (!existing.includes(ctx.betaHeaders)) {
1081
+ headers['anthropic-beta'] = `${existing},${ctx.betaHeaders}`;
1082
+ }
1061
1083
  }
1062
1084
  // Pass through OAuth identity headers (required by Anthropic for OAuth token validation)
1063
1085
  if (ctx.userAgent) {
@@ -3899,6 +3921,39 @@ async function startProxy(config = {}) {
3899
3921
  }
3900
3922
  return;
3901
3923
  }
3924
+ // === Pre-flight cost estimation endpoint (Pro-tier) ===
3925
+ if (req.method === 'POST' && (url === '/v1/estimate' || url.endsWith('/v1/estimate'))) {
3926
+ log('Pre-flight estimate request');
3927
+ // --- Per-IP rate limit: 60 requests/minute ---
3928
+ // Fix B: Use only the raw socket address — never x-forwarded-for.
3929
+ // x-forwarded-for is a client-controlled header and is trivially spoofed;
3930
+ // any attacker can send "X-Forwarded-For: 1.2.3.4" to bypass per-IP limits.
3931
+ // The socket remoteAddress reflects the actual TCP connection and cannot be faked.
3932
+ const clientIp = req.socket?.remoteAddress ?? 'unknown';
3933
+ const now = Date.now();
3934
+ // Fix C: Delegate rate limit logic to the testable checkEstimateRateLimit() function
3935
+ // (extracted in estimate.ts so it can be unit-tested in isolation).
3936
+ const rateLimitResult = (0, estimate_js_1.checkEstimateRateLimit)(estimateRateMap, clientIp, now);
3937
+ if (!rateLimitResult.allowed) {
3938
+ res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': '60' });
3939
+ res.end(JSON.stringify({ error: 'rate_limit_exceeded', message: 'Too many estimate requests. Limit: 60/minute.' }));
3940
+ return;
3941
+ }
3942
+ // --- Read body with size limit (uses existing MAX_BODY_SIZE helper) ---
3943
+ let body;
3944
+ try {
3945
+ body = await readRequestBody(req);
3946
+ }
3947
+ catch (err) {
3948
+ res.writeHead(413, { 'Content-Type': 'application/json' });
3949
+ res.end(JSON.stringify({ error: 'payload_too_large', message: 'Request body too large (max 10MB)' }));
3950
+ return;
3951
+ }
3952
+ const result = (0, estimate_js_1.handleEstimateRequest)(body);
3953
+ res.writeHead(result.status, { 'Content-Type': 'application/json' });
3954
+ res.end(JSON.stringify(result.body));
3955
+ return;
3956
+ }
3902
3957
  // === Token counting endpoint ===
3903
3958
  if (req.method === 'POST' && url.includes('/v1/messages/count_tokens')) {
3904
3959
  log('Token count request');
@@ -3946,7 +4001,7 @@ async function startProxy(config = {}) {
3946
4001
  // === OpenAI-compatible /v1/chat/completions endpoint ===
3947
4002
  if (req.method !== 'POST' || !url.includes('/chat/completions')) {
3948
4003
  res.writeHead(404, { 'Content-Type': 'application/json' });
3949
- res.end(JSON.stringify({ error: 'Not found. Supported: POST /v1/messages, POST /v1/chat/completions, GET /v1/models' }));
4004
+ res.end(JSON.stringify({ error: 'Not found. Supported: POST /v1/messages, POST /v1/chat/completions, POST /v1/estimate, GET /v1/models' }));
3950
4005
  return;
3951
4006
  }
3952
4007
  // Parse request body