src/windsurf-api.js
Rama master · réplica de lectura del repositorio original.
/**
* REST/Connect-RPC client for Windsurf/Codeium cloud services.
*
* Unlike client.js (which talks to the local language server binary over gRPC),
* this module hits public Connect-RPC endpoints that accept JSON, so we don't
* need proto builders/parsers to fetch account metadata.
*
* POST https://server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus
* Content-Type: application/json
* Connect-Protocol-Version: 1
*
* Currently exposes:
* - getUserStatus(apiKey, proxy) — plan info, quotas, credit balance
* - getCascadeModelConfigs(apiKey, proxy) — live model catalog (82+ models)
* - checkMessageRateLimit(apiKey, proxy) — pre-flight rate limit check
* - getUserJwt(apiKey, host, proxy) — short-lived user JWT (env-gated,
* rides in Metadata.user_jwt on chat RPCs via src/windsurf.js)
*/
import http from 'http';
import https from 'https';
import { config, log } from './config.js';
import { redactCredentialFragments, safeKeyRef } from './log-safety.js';
import { resolveProxyConnectHost } from './net-safety.js';
// R-1 (RATE-RETRY audit): bounded response-body reads, shared with the
// devin-connect readers — one ceiling, one truncation contract.
import { createBoundedBodyAccumulator, MAX_ERROR_BODY_BYTES } from './devin-connect.js';
const SERVER_HOSTS = [
'server.codeium.com',
'server.self-serve.windsurf.com',
];
const USER_STATUS_PATH = '/exa.seat_management_pb.SeatManagementService/GetUserStatus';
const MODEL_CONFIGS_PATH = '/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs';
const RATE_LIMIT_PATH = '/exa.api_server_pb.ApiServerService/CheckUserMessageRateLimit';
const WEB_SEARCH_PATH = '/exa.api_server_pb.ApiServerService/GetWebSearchResults';
// exa.auth_pb.AuthService/GetUserJwt → GetUserJwtResponse { user_jwt = 1,
// custom_api_server_url = 2 }. Cross-validated across four independent .protos
// (windsurf-grpc, widevin, Antigravity-Tools-LS, OmniRoute); rsvedant/
// opencode-windsurf-auth treats the JWT as REQUIRED on chat RPCs and rides it
// in Metadata.user_jwt = 21. ~24 min TTL, HS256-signed. Gate: env-only, default
// OFF — attaching user_jwt changes the wire every chat RPC sends, and we have no
// live capture proving the Windsurf backend needs or tolerates it.
const USER_JWT_PATH = '/exa.auth_pb.AuthService/GetUserJwt';
const USER_JWT_TTL_MS = 24 * 60 * 1000;
// TTL floor lets tests (which fake time via cacheEpoch) and clock jitter force
// a re-mint without waiting out a full window; the JWT is not persisted.
const USER_JWT_MIN_TTL_MS = 5 * 60 * 1000;
// Env switch: enable minting + attaching the short-lived user JWT. Default OFF
// (byte-identical wire until an operator opts in). Read through a function so
// tests can flip it and callers share one source of truth.
export function isUserJwtEnabled(env = process.env) {
return String(env.WINDSURFAPI_USER_JWT || '') === '1';
}
// Monotonic epoch: bumped on every sign-out / key rotation. A mint that races a
// logout can't re-populate a stale JWT — the epoch guard below rejects the
// result of any mint that started before the bump. Mirrors rsvedant's cache
// design (per-(apiKey,host) in-flight dedup + cacheEpoch).
let _cacheEpoch = 0;
const _jwtCache = new Map(); // `${apiKey}@${host}` → { jwt, expiresAt, epoch }
const _jwtInflight = new Map(); // `${apiKey}@${host}` → Promise
/** Test seam: advance the epoch (logout/rotation path in auth.js calls this). */
export function __bumpUserJwtCacheEpoch() { _cacheEpoch++; }
import { isSocks, createSocksTunnel } from './socks.js';
// Tunnel HTTPS through an HTTP CONNECT proxy or SOCKS5 proxy.
async function createProxyTunnel(proxy, targetHost, targetPort) {
if (isSocks(proxy)) return createSocksTunnel(proxy, targetHost, targetPort);
// #11: pin the proxy host to a vetted IP literal so the CONNECT socket does no
// second (rebindable) DNS lookup. Honors ALLOW_PRIVATE_PROXY_HOSTS.
const rawHost = proxy.host.replace(/:\d+$/, '');
const proxyHost = await resolveProxyConnectHost(rawHost, { allowPrivate: config.allowPrivateProxyHosts });
return new Promise((resolve, reject) => {
const proxyPort = proxy.port || 8080;
const req = http.request({
host: proxyHost,
port: proxyPort,
method: 'CONNECT',
path: `${targetHost}:${targetPort}`,
headers: {
Host: `${targetHost}:${targetPort}`,
...(proxy.username ? {
'Proxy-Authorization': `Basic ${Buffer.from(`${proxy.username}:${proxy.password || ''}`).toString('base64')}`,
} : {}),
},
});
req.on('connect', (res, socket) => {
if (res.statusCode === 200) resolve(socket);
else { socket.destroy(); reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`)); }
});
req.on('error', (err) => reject(new Error(`Proxy tunnel: ${err.message}`)));
req.setTimeout(15000, () => { req.destroy(); reject(new Error('Proxy tunnel timeout')); });
req.end();
});
}
/** Detect errors caused by the proxy itself (not the upstream API). */
function isProxyError(err) {
const m = err?.message || '';
return /Proxy CONNECT failed|Proxy tunnel|Proxy connection/i.test(m);
}
let postJsonOverride = null;
export function __setWindsurfApiPostJsonForTest(fn) {
postJsonOverride = typeof fn === 'function' ? fn : null;
}
function postJson(host, path, body, proxy) {
if (postJsonOverride) return postJsonOverride(host, path, body, proxy);
return new Promise(async (resolve, reject) => {
const postData = JSON.stringify(body);
const opts = {
hostname: host,
port: 443,
path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'Connect-Protocol-Version': '1',
'Accept': 'application/json',
'User-Agent': 'windsurf/1.9600.41',
},
};
const onRes = (res) => {
// R-1 (RATE-RETRY audit): bound the read. Concatenating unbounded is an
// availability bug — a misbehaving upstream can stream past V8's string
// limit and the ERR_STRING_TOO_LONG throw escapes this 'end' listener as
// an uncaughtException (see createBoundedBodyAccumulator). Over the cap
// the response is rejected rather than parsed from a truncated prefix.
const buffer = createBoundedBodyAccumulator();
res.on('data', buffer.onData);
res.on('end', () => {
const { raw: rawBuffer, truncated } = buffer.finish();
if (truncated) {
reject(new Error(`Response body exceeded ${MAX_ERROR_BODY_BYTES} bytes; truncated`));
return;
}
const raw = rawBuffer.toString('utf8');
try {
const parsed = raw ? JSON.parse(raw) : {};
// `headers` feeds the rate-limit pre-flight's Retry-After fallback (R-2).
resolve({ status: res.statusCode, data: parsed, raw, headers: res.headers });
} catch {
reject(new Error(`Non-JSON response (${res.statusCode}): ${raw.slice(0, 200)}`));
}
});
res.on('error', reject);
};
try {
let req;
if (proxy && proxy.host) {
const socket = await createProxyTunnel(proxy, host, 443);
opts.socket = socket;
opts.agent = false;
req = https.request(opts, onRes);
} else {
req = https.request(opts, onRes);
}
req.on('error', (err) => reject(new Error(`Request: ${err.message}`)));
req.setTimeout(20000, () => { req.destroy(); reject(new Error('Request timeout')); });
req.write(postData);
req.end();
} catch (err) { reject(err); }
});
}
/**
* Mint (and cache) the short-lived user JWT for `apiKey` from the given host.
*
* Returns the JWT string. Callers that will send it MUST first check
* isUserJwtEnabled() — this function does the network round-trip regardless, so
* the hot path must not call it when the feature is off.
*
* Cache shape (mirrors rsvedant/opencode-windsurf-auth):
* - per-(apiKey,host) entries, so a multi-host pool doesn't share one JWT
* minted against the wrong host;
* - in-flight dedup: concurrent callers for the same (apiKey,host) share one
* upstream RPC instead of stampeding GetUserJwt;
* - monotonic cacheEpoch: auth.js bumps the epoch on every logout / key
* rotation, so a mint racing a logout cannot repopulate a stale JWT. The
* epoch guard lives INSIDE the shared mint promise, so every awaiter (the
* owner and any concurrent joiner) sees the same rejection rather than the
* owner alone — a joiner that returned a stale token would defeat the guard.
*
* The TTL is shortened to USER_JWT_MIN_TTL_MS when it would expire sooner, so
* a process that observes clock skew re-mints rather than trusting a token that
* may already be expired upstream.
*
* @param {string} apiKey
* @param {string} host
* @param {object} [proxy]
* @returns {Promise<string>}
*/
export async function getUserJwt(apiKey, host, proxy = null) {
const key = `${apiKey}@${host}`;
const now = Date.now();
const cached = _jwtCache.get(key);
if (cached && cached.epoch === _cacheEpoch && cached.expiresAt > now) {
return cached.jwt;
}
if (_jwtInflight.has(key)) {
// A mint is already running for this (apiKey,host) — share its guarded
// result instead of stampeding GetUserJwt. On failure, fall through and
// start a fresh mint below.
try { return await _jwtInflight.get(key); } catch { /* retry fresh */ }
}
const epochAtStart = _cacheEpoch;
const mint = (async () => {
const body = { metadata: buildMetadata(apiKey) };
let lastErr = null;
for (const px of proxy ? [proxy, null] : [null]) {
try {
const res = await postJson(host, USER_JWT_PATH, body, px);
if (res.status >= 400) {
lastErr = new Error(`GetUserJwt ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
const jwt = res.data?.userJwt || res.data?.user_jwt;
if (typeof jwt !== 'string' || !jwt) {
lastErr = new Error(`GetUserJwt ${host}: response missing user_jwt`);
continue;
}
// A logout/rotation that raced this mint must not re-populate the cache
// with a stale JWT nor hand one to any awaiter — reject outright.
if (epochAtStart !== _cacheEpoch) {
throw new Error('GetUserJwt: cache epoch changed during mint (logout raced)');
}
const mintedAt = Date.now();
const ttl = Math.max(USER_JWT_MIN_TTL_MS, Math.min(USER_JWT_TTL_MS, Number(res.data?.ttlMs) || USER_JWT_TTL_MS));
_jwtCache.set(key, { jwt, expiresAt: mintedAt + ttl, epoch: _cacheEpoch });
return jwt;
} catch (e) {
lastErr = e;
if (px && isProxyError(e)) break; // bad tunnel — go straight to direct
}
}
throw lastErr || new Error('GetUserJwt: all hosts failed');
})();
_jwtInflight.set(key, mint);
try {
return await mint;
} finally {
// Only the owner reaches here (joiners early-returned above); remove the
// in-flight marker so a later caller can mint a fresh one.
if (_jwtInflight.get(key) === mint) _jwtInflight.delete(key);
}
}
/** Test seam: reset the cache + epoch so tests can start clean. */
export function __resetUserJwtCache() {
_jwtCache.clear();
_jwtInflight.clear();
_cacheEpoch = 0;
}
/** Test seam: current cache size (dedup/invalidation assertions). */
export function __userJwtCacheStats() {
return { entries: _jwtCache.size, inflight: _jwtInflight.size, epoch: _cacheEpoch };
}
function normalizeWebSearchResults(data) {
const results = Array.isArray(data?.results) ? data.results : [];
return {
results,
webSearchUrl: data?.webSearchUrl || data?.web_search_url || '',
summary: data?.summary || '',
raw: data,
fetchedAt: Date.now(),
};
}
/**
* Fetch account status: plan, quotas, credit balance, and model catalog.
* Tries both known Connect-RPC hostnames before giving up.
*
* Returns a normalized shape that covers both the legacy credit contract
* (availablePromptCredits / usedPromptCredits) and the newer quota contract
* (dailyQuotaRemainingPercent / weeklyQuotaRemainingPercent).
*
* @param {string} apiKey
* @param {object} [proxy] optional HTTP CONNECT proxy
* @returns {Promise<{planName, dailyPercent, weeklyPercent, dailyResetAt, weeklyResetAt, prompt:{used,limit}, flex:{used,limit}, raw}>}
*/
export async function getUserStatus(apiKey, proxy = null) {
const body = {
metadata: {
apiKey,
ideName: 'windsurf',
ideVersion: '1.9600.41',
extensionName: 'windsurf',
extensionVersion: '1.9600.41',
locale: 'en',
},
};
// Try with proxy first, then retry direct if proxy itself fails (407 etc.).
const proxyModes = proxy ? [proxy, null] : [null];
let lastErr = null;
for (const px of proxyModes) {
for (const host of SERVER_HOSTS) {
try {
const res = await postJson(host, USER_STATUS_PATH, body, px);
if (res.status >= 400) {
lastErr = new Error(`GetUserStatus ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
return normalizeUserStatus(res.data);
} catch (e) {
lastErr = e;
log.debug(`getCreditUsage ${host} failed: ${e.message}`);
if (px && isProxyError(e)) break; // skip second host, go straight to direct
}
}
}
throw lastErr || new Error('GetUserStatus: all hosts failed');
}
function normalizeUserStatus(data) {
const ps = data?.userStatus?.planStatus || {};
const plan = ps.planInfo || {};
// Legacy values come in hundredths; divide by 100 for display.
const legacyDiv = (n) => (typeof n === 'number' ? n / 100 : null);
// Unix timestamps may be numeric or string depending on server version.
const asUnix = (v) => {
if (v == null) return null;
if (typeof v === 'number') return v;
const n = parseInt(v, 10);
return Number.isFinite(n) ? n : null;
};
// proto3 JSON encodes int64 as a DECIMAL STRING ("80000000"), so the old bare
// `typeof === 'number'` check silently dropped the on-demand balance whenever
// the server emitted the spec-compliant form. Accept both encodings and keep
// the protobuf path's rules: -1 (the "unlimited/absent" sentinel) and anything
// outside 0..1e12 micro-USD is unknown, not a balance.
const overageRaw = ps.overageBalanceMicros;
const overageMicros = typeof overageRaw === 'string'
? (overageRaw.trim() === '' ? NaN : Number(overageRaw))
: overageRaw;
const overageBalance = typeof overageMicros === 'number'
&& Number.isFinite(overageMicros) && overageMicros >= 0 && overageMicros <= 1e12
? overageMicros / 1_000_000
: null;
const out = {
planName: plan.planName || 'Unknown',
// proto3-JSON omits zero values, so a fully-used quota (0% remaining) comes
// back as an ABSENT field, not `0` — which we'd otherwise render as N/A even
// though the real answer is "0% left / 100% used". When the quota dimension
// is clearly active (its reset timestamp is present, or the sibling daily
// field exists), treat a missing percentage as 0 rather than null/N/A.
dailyPercent: typeof ps.dailyQuotaRemainingPercent === 'number'
? ps.dailyQuotaRemainingPercent
: (ps.dailyQuotaResetAtUnix != null ? 0 : null),
weeklyPercent: typeof ps.weeklyQuotaRemainingPercent === 'number'
? ps.weeklyQuotaRemainingPercent
: ((ps.weeklyQuotaResetAtUnix != null || typeof ps.dailyQuotaRemainingPercent === 'number') ? 0 : null),
dailyResetAt: asUnix(ps.dailyQuotaResetAtUnix),
weeklyResetAt: asUnix(ps.weeklyQuotaResetAtUnix),
overageBalance,
prompt: {
limit: legacyDiv(plan.monthlyPromptCredits),
used: legacyDiv(ps.usedPromptCredits),
remaining: legacyDiv(ps.availablePromptCredits),
},
flex: {
limit: legacyDiv(plan.monthlyFlexCreditPurchaseAmount),
used: legacyDiv(ps.usedFlexCredits),
remaining: legacyDiv(ps.availableFlexCredits),
},
planStart: ps.planStart || null,
planEnd: ps.planEnd || null,
// Preserve the untouched response so downstream caching (model catalog)
// can inspect fields we haven't normalized yet.
raw: data,
fetchedAt: Date.now(),
};
// Derive a single display-friendly percent: prefer daily remaining; otherwise
// compute from prompt credits; otherwise null.
if (out.dailyPercent != null) {
out.percent = out.dailyPercent;
} else if (out.prompt.limit && out.prompt.remaining != null) {
out.percent = (out.prompt.remaining / out.prompt.limit) * 100;
} else {
out.percent = null;
}
return out;
}
// ─── Dynamic model catalog ────────────────────────────────
function buildMetadata(apiKey) {
return {
apiKey,
ideName: 'windsurf',
ideVersion: '1.9600.41',
extensionName: 'windsurf',
extensionVersion: '1.9600.41',
locale: 'en',
};
}
/**
* Fetch the live model catalog from Codeium's cloud.
* Returns an array of ClientModelConfig objects with modelUid, label,
* creditMultiplier, provider, maxTokens, supportsImages, etc.
*
* @param {string} apiKey
* @param {object} [proxy]
* @returns {Promise<{configs: object[], sorts: object[], defaultOverride: object|null}>}
*/
export async function getCascadeModelConfigs(apiKey, proxy = null) {
const body = { metadata: buildMetadata(apiKey) };
const proxyModes = proxy ? [proxy, null] : [null];
let lastErr = null;
for (const px of proxyModes) {
for (const host of SERVER_HOSTS) {
try {
const res = await postJson(host, MODEL_CONFIGS_PATH, body, px);
if (res.status >= 400) {
lastErr = new Error(`GetCascadeModelConfigs ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
return {
configs: res.data.clientModelConfigs || [],
sorts: res.data.clientModelSorts || [],
defaultOverride: res.data.defaultOverrideModelConfig || null,
};
} catch (e) {
lastErr = e;
log.debug(`GetCascadeModelConfigs host ${host} failed: ${e.message}`);
if (px && isProxyError(e)) break;
}
}
}
throw lastErr || new Error('GetCascadeModelConfigs: all hosts failed');
}
/**
* Direct Windsurf web search API.
*
* Confirmed from the LS descriptor dump and VPS canary:
* GetWebSearchResultsRequest {
* metadata = 1;
* query = 2;
* limit = 3;
* domain = 4;
* third_party_config = 5;
* mode = 6;
* }
* GetWebSearchResultsResponse { results = 1; web_search_url = 2; summary = 3 }
*
* This helper is intentionally separate from the native bridge. LS-native
* WebSearch/WebFetch still returns a Cascade permission_denied error in live
* canaries even when this direct API succeeds.
*/
export async function getWebSearchResults(apiKey, {
query,
limit = 5,
domain = '',
thirdPartyConfig = null,
mode = undefined,
} = {}, proxy = null) {
const q = String(query || '').trim();
if (!q) throw new Error('getWebSearchResults: query required');
const body = {
metadata: buildMetadata(apiKey),
query: q,
limit: Math.max(1, Math.min(10, Number(limit) || 5)),
};
if (domain) body.domain = String(domain);
if (thirdPartyConfig && typeof thirdPartyConfig === 'object') body.thirdPartyConfig = thirdPartyConfig;
if (mode !== undefined && mode !== null && mode !== '') body.mode = mode;
const proxyModes = proxy ? [proxy, null] : [null];
let lastErr = null;
for (const px of proxyModes) {
for (const host of SERVER_HOSTS) {
try {
const res = await postJson(host, WEB_SEARCH_PATH, body, px);
if (res.status >= 400) {
lastErr = new Error(`GetWebSearchResults ${host} -> ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
return normalizeWebSearchResults(res.data);
} catch (e) {
lastErr = e;
log.debug(`GetWebSearchResults host ${host} failed: ${e.message}`);
if (px && isProxyError(e)) break;
}
}
}
throw lastErr || new Error('GetWebSearchResults: all hosts failed');
}
/**
* Register a Codeium/Windsurf account from a Firebase ID token. v2.0.57:
* tries the new `register.windsurf.com/.../SeatManagementService/RegisterUser`
* Connect-RPC path first, then falls back to the legacy
* `api.codeium.com/register_user/` REST path. Windsurf migrated the seat-
* management surface in 2026 — the new path is the one wam-bundle and
* WindsurfSwitch use, and is what the official Windsurf 2.0.67 IDE talks
* to. The fallback keeps existing /auth/login flows alive even if the
* new host has a regional outage or a temporary 5xx.
*
* Optional `customRequest(url, opts, body)` lets callers (windsurf-login.js)
* inject fingerprint headers + proxy tunneling. When omitted we use the
* built-in postJson with no fingerprint and direct egress.
*
* @param {string} firebaseToken
* @param {object} [opts]
* @param {(url:string, opts:object, body:string) => Promise<{status:number,data:any,raw:string}>} [opts.requestFn]
* Custom HTTP function. Receives full URL, fetch-like opts, and stringified body.
* @param {object} [opts.proxy] Used by the default postJson path.
* @returns {Promise<{apiKey, name, apiServerUrl, source: 'new'|'legacy'}>}
*/
export async function registerWithFirebaseToken(firebaseToken, opts = {}) {
if (!firebaseToken || typeof firebaseToken !== 'string') {
throw new Error('registerWithFirebaseToken: firebase token required');
}
const body = { firebase_id_token: firebaseToken };
const bodyStr = JSON.stringify(body);
const proxy = opts.proxy || null;
// Connect-RPC compliant request to register.windsurf.com.
const newUrl = 'https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser';
// Legacy REST endpoint at api.codeium.com.
const legacyUrl = 'https://api.codeium.com/register_user/';
const tryUrl = async (url, source) => {
if (typeof opts.requestFn === 'function') {
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
'Connect-Protocol-Version': '1',
'Accept': 'application/json',
'User-Agent': 'windsurf/1.9600.41',
};
const r = await opts.requestFn(url, { method: 'POST', headers }, bodyStr);
return { status: r.status, data: r.data, raw: r.raw, source };
}
// Default path: built-in postJson on the host of the URL.
const u = new URL(url);
const r = await postJson(u.hostname, u.pathname, body, proxy);
return { status: r.status, data: r.data, raw: r.raw, source };
};
const errors = [];
for (const [url, source] of [[newUrl, 'new'], [legacyUrl, 'legacy']]) {
try {
const r = await tryUrl(url, source);
// Both paths return either snake_case (api_key/name/api_server_url) or
// camelCase (apiKey/name/apiServerUrl) depending on the gateway.
const apiKey = r.data?.api_key || r.data?.apiKey;
const name = r.data?.name || '';
const apiServerUrl = r.data?.api_server_url || r.data?.apiServerUrl || '';
if (r.status < 400 && apiKey) {
if (source === 'legacy') {
log.warn(`RegisterUser fell back to legacy api.codeium.com (new endpoint failed)`);
} else {
log.info(`RegisterUser via register.windsurf.com OK (${safeKeyRef(apiKey, 'apiKey')})`);
}
return { apiKey, name, apiServerUrl, source };
}
// Redact first. A real Firebase JWT echoed in JSON is longer than
// 120 chars and slice-then-redact leaves `eyJ…` with only one dot,
// which misses the JWT regex and leaks into the dashboard toast.
const raw = redactCredentialFragments(r.raw || '').slice(0, 120);
errors.push(`${source}=HTTP ${r.status} ${raw || '(empty)'}`);
} catch (e) {
errors.push(`${source}=${e.message}`);
}
}
throw new Error(`RegisterUser failed both endpoints: ${redactCredentialFragments(errors.join(' | '))}`);
}
// R-2: Retry-After is delta-seconds or an HTTP-date (RFC 9110) → ms, or null.
function parseRetryAfterHeaderMs(value) {
if (value == null) return null;
const s = String(value).trim();
if (!s) return null;
const seconds = Number(s);
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
const when = Date.parse(s);
if (Number.isNaN(when)) return null;
return Math.max(0, when - Date.now());
}
/**
* Pre-flight check: does this account still have message capacity?
* Returns { hasCapacity, messagesRemaining, maxMessages, retryAfterMs }.
* -1 means unlimited; retryAfterMs is the server cooldown in ms (null when
* absent).
*
* @param {string} apiKey
* @param {object} [proxy]
* @returns {Promise<{hasCapacity: boolean, messagesRemaining: number, maxMessages: number}>}
*/
export async function checkMessageRateLimit(apiKey, proxy = null) {
const body = { metadata: buildMetadata(apiKey) };
const proxyModes = proxy ? [proxy, null] : [null];
let lastErr = null;
for (const px of proxyModes) {
for (const host of SERVER_HOSTS) {
try {
const res = await postJson(host, RATE_LIMIT_PATH, body, px);
if (res.status === 429 || res.status === 503) {
// R-2 (RATE-RETRY audit): a throttle on the pre-flight itself must
// not read as "capacity fine" — swallowing it burns an upstream
// request right after. Report no-capacity with the reset window from
// resets_in_seconds (#5) or the Retry-After header. The limit is
// account-scoped, so there is no point trying the other host;
// network errors below still fail open.
const resetSeconds = res.data?.resetsInSeconds == null ? NaN : Number(res.data.resetsInSeconds);
const bodyMs = Number.isFinite(resetSeconds) ? Math.round(resetSeconds * 1000) : null;
const retryAfterMs = bodyMs ?? parseRetryAfterHeaderMs(res.headers?.['retry-after']) ?? null;
log.warn(`CheckRateLimit ${host} → ${res.status}: no capacity${retryAfterMs != null ? ` (retry after ${retryAfterMs}ms)` : ''}`);
return {
hasCapacity: false,
messagesRemaining: res.data?.messagesRemaining ?? -1,
maxMessages: res.data?.maxMessages ?? -1,
retryAfterMs,
};
}
if (res.status >= 400) {
lastErr = new Error(`CheckRateLimit ${host} → ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
// The cooldown rides resets_in_seconds (#5) — int64, which may arrive as
// a number or a proto3-JSON string; retryAfterMs is the ×1000 ms form.
const resetSeconds = res.data.resetsInSeconds == null ? NaN : Number(res.data.resetsInSeconds);
return {
hasCapacity: res.data.hasCapacity !== false,
messagesRemaining: res.data.messagesRemaining ?? -1,
maxMessages: res.data.maxMessages ?? -1,
retryAfterMs: Number.isFinite(resetSeconds) ? resetSeconds * 1000 : null,
};
} catch (e) {
lastErr = e;
log.debug(`CheckRateLimit host ${host} failed: ${e.message}`);
if (px && isProxyError(e)) break;
}
}
}
// On failure, assume capacity so we don't block requests.
//
// `error` distinguishes a real "yes" from "we could not ask": the dashboard's
// capacity preflight must show `unknown` for this path, not a green light.
// Callers that only read `hasCapacity` are unaffected.
log.warn(`CheckRateLimit failed: ${lastErr?.message}`);
return {
hasCapacity: true,
messagesRemaining: -1,
maxMessages: -1,
retryAfterMs: null,
error: 'upstream_unreachable',
};
}