mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(cliproxy): preserve entitlement evidence on agy failures
This commit is contained in:
@@ -352,6 +352,28 @@ function buildAntigravityFailure(
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAntigravityTierEvidence(
|
||||
entitlement: ProviderEntitlementEvidence | undefined,
|
||||
tier: AccountTier,
|
||||
rawTierId: string | null,
|
||||
rawTierLabel: string | null
|
||||
): ProviderEntitlementEvidence | undefined {
|
||||
if (tier === 'unknown' && !entitlement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return buildProviderEntitlementEvidence({
|
||||
normalizedTier: tier,
|
||||
rawTierId,
|
||||
rawTierLabel,
|
||||
source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'),
|
||||
confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'),
|
||||
accessState: entitlement?.accessState ?? 'unknown',
|
||||
capacityState: entitlement?.capacityState ?? 'unknown',
|
||||
notes: entitlement?.notes ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function readManagedResponse(
|
||||
response: Response,
|
||||
viaManagement: boolean
|
||||
@@ -620,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
||||
error: 'Invalid quota response from provider',
|
||||
errorCode: 'provider_unavailable',
|
||||
retryable: true,
|
||||
entitlement: buildProviderEntitlementEvidence({
|
||||
normalizedTier: 'unknown',
|
||||
source: 'runtime_inference',
|
||||
confidence: 'low',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
notes: 'Provider returned a 2xx response with an empty or invalid project payload.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -862,6 +892,12 @@ export async function fetchAccountQuota(
|
||||
} else {
|
||||
result.isExpired = authData.isExpired;
|
||||
result.expiresAt = authData.expiresAt || undefined;
|
||||
result.entitlement = mergeAntigravityTierEvidence(
|
||||
result.entitlement,
|
||||
apiTier,
|
||||
rawTierId,
|
||||
rawTierLabel
|
||||
);
|
||||
}
|
||||
|
||||
if (verbose && result.error) {
|
||||
|
||||
@@ -82,4 +82,134 @@ describe('Antigravity quota failure metadata', () => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('provider_unavailable');
|
||||
expect(result.entitlement).toMatchObject({
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => {
|
||||
const moduleId = Date.now() + Math.random();
|
||||
const { fetchAccountQuota } = await import(
|
||||
`../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}`
|
||||
);
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?agy-config=${moduleId}`
|
||||
);
|
||||
const fs = await import('node:fs');
|
||||
const os = await import('node:os');
|
||||
const path = await import('node:path');
|
||||
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-'));
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
try {
|
||||
const authDir = getProviderAuthDir('agy');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'antigravity-user@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'antigravity',
|
||||
email: 'user@example.com',
|
||||
access_token: 'token',
|
||||
})
|
||||
);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestCount = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
cloudaicompanionProject: { id: 'project-x' },
|
||||
paidTier: { id: 'g1-pro-tier' },
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response('', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota('agy', 'user@example.com');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.entitlement).toMatchObject({
|
||||
normalizedTier: 'pro',
|
||||
rawTierId: 'g1-pro-tier',
|
||||
rawTierLabel: 'Pro',
|
||||
accessState: 'unknown',
|
||||
capacityState: 'temporarily_unavailable',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
ListAiProvidersResult,
|
||||
UpsertAiProviderEntryInput,
|
||||
} from '../../../src/cliproxy/ai-providers';
|
||||
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
|
||||
|
||||
export const API_BASE_URL = '/api';
|
||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||
export type { ProviderEntitlementEvidence };
|
||||
|
||||
export class ApiConflictError extends Error {
|
||||
readonly code = API_CONFLICT_ERROR_CODE;
|
||||
@@ -566,28 +568,6 @@ export interface QuotaResult {
|
||||
entitlement?: ProviderEntitlementEvidence;
|
||||
}
|
||||
|
||||
export interface ProviderEntitlementEvidence {
|
||||
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
rawTierId: string | null;
|
||||
rawTierLabel: string | null;
|
||||
source: 'runtime_api' | 'runtime_inference' | 'registry_cache' | 'official_docs';
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
accessState:
|
||||
| 'entitled'
|
||||
| 'not_entitled'
|
||||
| 'capacity_exhausted'
|
||||
| 'temporarily_unavailable'
|
||||
| 'unknown';
|
||||
capacityState:
|
||||
| 'available'
|
||||
| 'capacity_exhausted'
|
||||
| 'rate_limited'
|
||||
| 'temporarily_unavailable'
|
||||
| 'unknown';
|
||||
lastVerifiedAt: number;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
/** Codex rate limit window */
|
||||
export interface CodexQuotaWindow {
|
||||
/** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */
|
||||
|
||||
Reference in New Issue
Block a user