mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-08 20:19:37 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user