The OpenRouter provider called GET /api/v1/credits, which OpenRouter
documents as requiring a management key. Called with a normal inference
key it returns HTTP 200 and {"total_credits":0,"total_usage":0} instead
of an error, so the provider rendered "$0.00 left - $0.00 spent" for a
funded key and the !response.ok guard could never catch it.
Read GET /api/v1/key instead, which is documented as callable with any
valid API key. A key with a spending limit reports its own usage against
that limit in the window named by limit_reset, and a key without one
reports usage_monthly. Window usage is limit - limit_remaining rather
than usage, because usage is all-time and limit_remaining tracks the
current reset window. limit_remaining is also server-computed and
already honors include_byok_in_limit.
Bring the provider up to the deepseek.js standard while here: a 15s
timeout, 401 and 403 mapped to a session-expired message, parse failures
mapped to an invalid-response message, an explicit no-quota-data result,
and the aliases export that quota/DOCUMENTATION.md requires. Add the
missing openrouter.test.js and keep packages/vscode in sync.
Refs #3060
188 lines
4.9 KiB
JavaScript
188 lines
4.9 KiB
JavaScript
import { readAuthFile } from '../../opencode/auth.js';
|
|
import {
|
|
getAuthEntry,
|
|
normalizeAuthEntry,
|
|
buildResult,
|
|
toUsageWindow,
|
|
toNumber,
|
|
asObject,
|
|
formatMoney
|
|
} from '../utils/index.js';
|
|
|
|
export const providerId = 'openrouter';
|
|
export const providerName = 'OpenRouter';
|
|
export const aliases = ['openrouter'];
|
|
const OPENROUTER_QUOTA_URL = 'https://openrouter.ai/api/v1/key';
|
|
const PERIOD_SECONDS = { daily: 86400, weekly: 604800, monthly: 30 * 86400 };
|
|
|
|
export const resolveResetAt = (limitReset, nowMs) => {
|
|
const now = new Date(nowMs);
|
|
const y = now.getUTCFullYear();
|
|
const m = now.getUTCMonth();
|
|
const d = now.getUTCDate();
|
|
|
|
if (limitReset === 'daily') return Date.UTC(y, m, d + 1);
|
|
if (limitReset === 'weekly') {
|
|
const dow = now.getUTCDay();
|
|
return Date.UTC(y, m, d + ((8 - dow) % 7 || 7));
|
|
}
|
|
if (limitReset === 'monthly') return Date.UTC(y, m + 1, 1);
|
|
return null;
|
|
};
|
|
|
|
export const isConfigured = () => {
|
|
const auth = readAuthFile();
|
|
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
|
return Boolean(entry?.key || entry?.token);
|
|
};
|
|
|
|
export const fetchQuota = async () => {
|
|
const auth = readAuthFile();
|
|
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
|
const apiKey = entry?.key ?? entry?.token;
|
|
|
|
if (!apiKey) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: false,
|
|
error: 'Not configured'
|
|
});
|
|
}
|
|
|
|
const timeoutSignal = AbortSignal.timeout(15_000);
|
|
|
|
try {
|
|
const response = await fetch(OPENROUTER_QUOTA_URL, {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Accept-Encoding': 'identity'
|
|
},
|
|
signal: timeoutSignal
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: response.status === 401 || response.status === 403
|
|
? 'Session expired — please re-authenticate with OpenRouter'
|
|
: `API error: ${response.status}`
|
|
});
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const data = asObject(payload?.data);
|
|
|
|
if (data === null) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'No quota data in response'
|
|
});
|
|
}
|
|
|
|
if (data.is_management_key === true) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'Management key configured — quota needs an inference API key'
|
|
});
|
|
}
|
|
|
|
const limit = toNumber(data.limit);
|
|
const limitRemaining = toNumber(data.limit_remaining);
|
|
if (limit !== null && limitRemaining === null) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'No quota data in response'
|
|
});
|
|
}
|
|
|
|
const usageMonthly = toNumber(data.usage_monthly);
|
|
if (limit === null && usageMonthly === null) {
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: 'No quota data in response'
|
|
});
|
|
}
|
|
|
|
const nowMs = Date.now();
|
|
let windowKey;
|
|
let windowSeconds;
|
|
let resetAt;
|
|
let usedPercent;
|
|
let valueLabel;
|
|
|
|
if (limit === null) {
|
|
windowKey = 'monthly';
|
|
windowSeconds = PERIOD_SECONDS.monthly;
|
|
resetAt = resolveResetAt('monthly', nowMs);
|
|
usedPercent = null;
|
|
valueLabel = `$${formatMoney(usageMonthly)} spent`;
|
|
} else {
|
|
const used = Math.max(0, limit - limitRemaining);
|
|
const percent = limit > 0 ? (used / limit) * 100 : null;
|
|
usedPercent = percent === null ? null : Math.min(100, percent);
|
|
valueLabel = `$${formatMoney(used)} / $${formatMoney(limit)}`;
|
|
|
|
if (Object.hasOwn(PERIOD_SECONDS, data.limit_reset)) {
|
|
windowKey = data.limit_reset;
|
|
windowSeconds = PERIOD_SECONDS[data.limit_reset];
|
|
resetAt = resolveResetAt(data.limit_reset, nowMs);
|
|
} else {
|
|
windowKey = 'credits';
|
|
windowSeconds = null;
|
|
resetAt = null;
|
|
}
|
|
}
|
|
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: true,
|
|
configured: true,
|
|
usage: {
|
|
windows: {
|
|
[windowKey]: toUsageWindow({
|
|
usedPercent,
|
|
windowSeconds,
|
|
resetAt,
|
|
valueLabel
|
|
})
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
const isTimeout = error instanceof DOMException && (
|
|
error.name === 'TimeoutError' || (error.name === 'AbortError' && timeoutSignal.aborted)
|
|
);
|
|
const isParseError = error instanceof SyntaxError;
|
|
return buildResult({
|
|
providerId,
|
|
providerName,
|
|
ok: false,
|
|
configured: true,
|
|
error: isTimeout
|
|
? 'Request timed out'
|
|
: isParseError
|
|
? 'Invalid response from provider'
|
|
: (error instanceof Error ? error.message : 'Request failed')
|
|
});
|
|
}
|
|
};
|