fix(quota): support z.ai credit limits
This commit is contained in:
@@ -101,4 +101,4 @@ The provider computes `usedPercent` from whichever of `used`/`remaining` is pres
|
||||
- Keep provider IDs stable; clients use them directly.
|
||||
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
|
||||
- Keep Google behavior changes isolated and review `providers/google/*` together.
|
||||
- Z.ai Coding Plan exposes separate 5-hour and weekly `TOKENS_LIMIT` entries plus a monthly `TIME_LIMIT` for MCP tools; web and VS Code must preserve all three windows.
|
||||
- Z.ai Coding Plan exposes separate 5-hour and weekly token/credit limit entries plus a monthly `TIME_LIMIT` for MCP tools. The API renamed the limit type from `TOKENS_LIMIT` to `CREDIT_LIMIT` (same `unit`/`number` window semantics); `CREDIT_LIMIT` entries additionally carry `usage` (total), `currentValue` (consumed), and `remaining`, surfaced as a credit `valueLabel`, and the payload's `data.level` becomes `planLabel`. Web and VS Code must preserve these windows and stay in sync.
|
||||
|
||||
@@ -81,7 +81,7 @@ const fetchQuotaUncoalesced = async () => {
|
||||
|
||||
if (Date.now() < cooldownUntil) {
|
||||
return cachedResultFor(fingerprint, credential.planLabel)
|
||||
?? failure('Rate limited by Anthropic. Retrying shortly.');
|
||||
?? failure('Rate limited. Retrying soon.');
|
||||
}
|
||||
|
||||
let response;
|
||||
@@ -100,7 +100,7 @@ const fetchQuotaUncoalesced = async () => {
|
||||
if (response.status === 429) {
|
||||
cooldownUntil = Date.now() + cooldownFromHeader(response);
|
||||
return cachedResultFor(fingerprint, credential.planLabel)
|
||||
?? failure('Rate limited by Anthropic. Retrying shortly.');
|
||||
?? failure('Rate limited. Retrying soon.');
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeAuthEntry,
|
||||
buildResult,
|
||||
toUsageWindow,
|
||||
toNumber,
|
||||
resolveWindowSeconds,
|
||||
resolveWindowLabel,
|
||||
normalizeTimestamp
|
||||
@@ -13,6 +14,20 @@ export const providerId = 'zai-coding-plan';
|
||||
export const providerName = 'z.ai';
|
||||
const aliases = ['zai-coding-plan', 'zai', 'z.ai'];
|
||||
|
||||
// CREDIT_LIMIT entries carry `usage` (total credits), `currentValue` (consumed),
|
||||
// and `remaining`; TOKENS_LIMIT entries only carry a percentage.
|
||||
const formatCreditAmount = (value) => {
|
||||
if (value < 1000) return value.toLocaleString('en-US');
|
||||
return `${Math.round(value / 100) / 10}k`;
|
||||
};
|
||||
|
||||
const formatCreditValueLabel = (limit) => {
|
||||
const used = toNumber(limit?.currentValue);
|
||||
const total = toNumber(limit?.usage);
|
||||
if (used === null || total === null) return null;
|
||||
return `${formatCreditAmount(used)} / ${formatCreditAmount(total)} credits`;
|
||||
};
|
||||
|
||||
export const isConfigured = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
|
||||
@@ -56,16 +71,20 @@ export const fetchQuota = async () => {
|
||||
const payload = await response.json();
|
||||
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
||||
const windows = {};
|
||||
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(tokensLimit);
|
||||
// The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same,
|
||||
// so both limit types map to the same windows.
|
||||
for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) {
|
||||
const windowSeconds = resolveWindowSeconds(limit);
|
||||
const windowLabel = resolveWindowLabel(windowSeconds);
|
||||
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
|
||||
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
|
||||
const resetAt = limit?.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null;
|
||||
const usedPercent = typeof limit?.percentage === 'number' ? limit.percentage : null;
|
||||
const creditValueLabel = formatCreditValueLabel(limit);
|
||||
|
||||
windows[windowLabel] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt
|
||||
resetAt,
|
||||
valueLabel: creditValueLabel
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,7 +102,8 @@ export const fetchQuota = async () => {
|
||||
providerName,
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
usage: { windows },
|
||||
planLabel: typeof payload?.data?.level === 'string' && payload.data.level ? payload.data.level : null
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
|
||||
@@ -51,4 +51,37 @@ describe('Z.ai quota provider', () => {
|
||||
resetAt: 1787128459979,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
|
||||
code: 200,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
|
||||
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
|
||||
],
|
||||
level: 'pro',
|
||||
},
|
||||
})));
|
||||
|
||||
const result = await fetchQuota();
|
||||
const windows = result.usage.windows;
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.planLabel).toBe('pro');
|
||||
expect(windows['5h']).toMatchObject({
|
||||
usedPercent: 1,
|
||||
remainingPercent: 99,
|
||||
windowSeconds: 5 * 60 * 60,
|
||||
resetAt: 1787257978907,
|
||||
valueLabel: '65 / 12k credits',
|
||||
});
|
||||
expect(windows.weekly).toMatchObject({
|
||||
usedPercent: 1,
|
||||
remainingPercent: 99,
|
||||
windowSeconds: 7 * 24 * 60 * 60,
|
||||
resetAt: 1787844668997,
|
||||
valueLabel: '65 / 60k credits',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user