WindsurfAPI / src /windsurf-api.js
github-actions[bot]
Deploy from GitHub: 7495fde758f0be655f95e6331fec2898267f790c
f6266b9
/**
* 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
*/
import http from 'http';
import https from 'https';
import { log } from './config.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';
// Tunnel HTTPS through an HTTP CONNECT proxy. Mirrors dashboard/windsurf-login.js
// so per-account outbound IPs stay consistent across login and credit fetch.
function createProxyTunnel(proxy, targetHost, targetPort) {
return new Promise((resolve, reject) => {
const proxyHost = proxy.host.replace(/:\d+$/, '');
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);
}
function postJson(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.108.2',
},
};
const onRes = (res) => {
const bufs = [];
res.on('data', d => bufs.push(d));
res.on('end', () => {
const raw = Buffer.concat(bufs).toString('utf8');
try {
const parsed = raw ? JSON.parse(raw) : {};
resolve({ status: res.statusCode, data: parsed, raw });
} 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); }
});
}
/**
* 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.108.2',
extensionName: 'windsurf',
extensionVersion: '1.108.2',
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;
};
const out = {
planName: plan.planName || 'Unknown',
dailyPercent: typeof ps.dailyQuotaRemainingPercent === 'number' ? ps.dailyQuotaRemainingPercent : null,
weeklyPercent: typeof ps.weeklyQuotaRemainingPercent === 'number' ? ps.weeklyQuotaRemainingPercent : null,
dailyResetAt: asUnix(ps.dailyQuotaResetAtUnix),
weeklyResetAt: asUnix(ps.weeklyQuotaResetAtUnix),
overageBalance: typeof ps.overageBalanceMicros === 'number' ? ps.overageBalanceMicros / 1_000_000 : null,
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.108.2',
extensionName: 'windsurf',
extensionVersion: '1.108.2',
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');
}
/**
* Pre-flight check: does this account still have message capacity?
* Returns { hasCapacity, messagesRemaining, maxMessages }.
* -1 means unlimited.
*
* @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 >= 400) {
lastErr = new Error(`CheckRateLimit ${host} β†’ ${res.status}: ${res.raw.slice(0, 160)}`);
continue;
}
return {
hasCapacity: res.data.hasCapacity !== false,
messagesRemaining: res.data.messagesRemaining ?? -1,
maxMessages: res.data.maxMessages ?? -1,
};
} 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.
log.warn(`CheckRateLimit failed: ${lastErr?.message}`);
return { hasCapacity: true, messagesRemaining: -1, maxMessages: -1 };
}