mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 02:17:11 +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(
|
async function readManagedResponse(
|
||||||
response: Response,
|
response: Response,
|
||||||
viaManagement: boolean
|
viaManagement: boolean
|
||||||
@@ -620,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise<Pro
|
|||||||
error: 'Invalid quota response from provider',
|
error: 'Invalid quota response from provider',
|
||||||
errorCode: 'provider_unavailable',
|
errorCode: 'provider_unavailable',
|
||||||
retryable: true,
|
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 {
|
} else {
|
||||||
result.isExpired = authData.isExpired;
|
result.isExpired = authData.isExpired;
|
||||||
result.expiresAt = authData.expiresAt || undefined;
|
result.expiresAt = authData.expiresAt || undefined;
|
||||||
|
result.entitlement = mergeAntigravityTierEvidence(
|
||||||
|
result.entitlement,
|
||||||
|
apiTier,
|
||||||
|
rawTierId,
|
||||||
|
rawTierLabel
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verbose && result.error) {
|
if (verbose && result.error) {
|
||||||
|
|||||||
@@ -82,4 +82,134 @@ describe('Antigravity quota failure metadata', () => {
|
|||||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
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,
|
ListAiProvidersResult,
|
||||||
UpsertAiProviderEntryInput,
|
UpsertAiProviderEntryInput,
|
||||||
} from '../../../src/cliproxy/ai-providers';
|
} from '../../../src/cliproxy/ai-providers';
|
||||||
|
import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types';
|
||||||
|
|
||||||
export const API_BASE_URL = '/api';
|
export const API_BASE_URL = '/api';
|
||||||
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
|
||||||
|
export type { ProviderEntitlementEvidence };
|
||||||
|
|
||||||
export class ApiConflictError extends Error {
|
export class ApiConflictError extends Error {
|
||||||
readonly code = API_CONFLICT_ERROR_CODE;
|
readonly code = API_CONFLICT_ERROR_CODE;
|
||||||
@@ -566,28 +568,6 @@ export interface QuotaResult {
|
|||||||
entitlement?: ProviderEntitlementEvidence;
|
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 */
|
/** Codex rate limit window */
|
||||||
export interface CodexQuotaWindow {
|
export interface CodexQuotaWindow {
|
||||||
/** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */
|
/** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */
|
||||||
|
|||||||
Reference in New Issue
Block a user