Merge pull request #679 from 0xble/fix/claude-oauth-policy-limits-unavailable

fix(cliproxy): handle Claude OAuth policy-limits 401 correctly
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-07 02:27:27 -05:00
committed by GitHub
5 changed files with 284 additions and 18 deletions
+54 -8
View File
@@ -21,6 +21,7 @@ export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_co
const CLAUDE_QUOTA_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota';
const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported';
interface ClaudeAuthData {
accessToken: string;
@@ -65,6 +66,37 @@ function isAuthExpired(expiry: string | null): boolean {
return expiry ? isTokenExpired(expiry) : false;
}
function extractErrorMessage(payload: unknown): string | null {
const root = toObject(payload);
if (!root) return null;
const direct = asString(root['message']);
if (direct) return direct;
const nested = toObject(root['error']);
if (!nested) return null;
return asString(nested['message']);
}
async function readResponseErrorMessage(response: Response): Promise<string | null> {
try {
const body = await response.text();
if (!body || body.trim().length === 0) return null;
try {
const parsed = JSON.parse(body) as unknown;
const extracted = extractErrorMessage(parsed);
if (extracted) return extracted;
} catch {
// fall through to plain-text fallback
}
return body.trim();
} catch {
return null;
}
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
const raw = await fsp.readFile(filePath, 'utf-8');
@@ -161,6 +193,16 @@ function buildEmptyResult(
};
}
function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult {
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
accountId,
};
}
/**
* Fetch quota for a single Claude account.
*/
@@ -204,18 +246,22 @@ export async function fetchClaudeQuota(
}
if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response);
if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) {
if (verbose) {
console.error(
'[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable'
);
}
return buildPolicyUnavailableResult(accountId);
}
return buildEmptyResult('Authentication required for policy limits', accountId, true);
}
if (response.status === 404) {
// Some accounts may not expose policy limits; treat as empty but successful.
return {
success: true,
windows: [],
coreUsage: { fiveHour: null, weekly: null },
lastUpdated: Date.now(),
accountId,
};
// Some accounts may not expose policy limits; treat as unavailable but successful.
return buildPolicyUnavailableResult(accountId);
}
if (response.status === 403) {
+10 -8
View File
@@ -281,7 +281,7 @@ function calculateQuotaPercent(quota: ManagedQuotaResult): number | null {
export async function findHealthyAccount(
provider: CLIProxyProvider,
exclude: string[]
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
): Promise<{ id: string; tier: string; lastQuota: number | null } | null> {
if (!isManagedQuotaProvider(provider)) {
return null;
}
@@ -309,7 +309,7 @@ export async function findHealthyAccount(
quota = await fetchQuotaWithDedup(provider, account.id);
}
const avgQuota = calculateQuotaPercent(quota) ?? 0;
const avgQuota = calculateQuotaPercent(quota);
return {
id: account.id,
@@ -320,22 +320,24 @@ export async function findHealthyAccount(
10
);
// Filter by threshold
const healthy = withQuotas.filter((a) => a.lastQuota >= threshold);
if (healthy.length === 0) return null;
// Prefer accounts with known healthy quota. If all remaining accounts have unavailable
// quota data, fall back to those unknown-but-usable accounts instead of treating them as 0%.
const healthy = withQuotas.filter((a) => a.lastQuota !== null && a.lastQuota >= threshold);
const selectable = healthy.length > 0 ? healthy : withQuotas.filter((a) => a.lastQuota === null);
if (selectable.length === 0) return null;
// Sort by tier priority then quota descending
healthy.sort((a, b) => {
selectable.sort((a, b) => {
const tierA = tierPriority.indexOf(a.tier);
const tierB = tierPriority.indexOf(b.tier);
const tierOrderA = tierA === -1 ? 999 : tierA;
const tierOrderB = tierB === -1 ? 999 : tierB;
if (tierOrderA !== tierOrderB) return tierOrderA - tierOrderB;
return b.lastQuota - a.lastQuota;
return (b.lastQuota ?? -1) - (a.lastQuota ?? -1);
});
return healthy[0];
return selectable[0];
}
/**