mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-19 00:14:49 +00:00
fix(cliproxy): delegate gemini refresh to upstream
This commit is contained in:
1 parent
e7297bd66b
commit
9dc6374851
8 files changed
+410
-756
No files matched your search
@@ -0,0 +1,72 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
describe('Gemini refresh delegation', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
moduleVersion += 1;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalCcsDir === undefined) {
|
||||
delete process.env.CCS_DIR;
|
||||
} else {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => {
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}`
|
||||
);
|
||||
const { ensureTokenValid } = await import(
|
||||
`../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}`
|
||||
);
|
||||
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'gemini-delegated.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
type: 'gemini',
|
||||
email: 'delegated@example.com',
|
||||
project_id: 'delegated-project',
|
||||
token: {
|
||||
access_token: 'expired-access-token',
|
||||
refresh_token: 'still-present-refresh-token',
|
||||
expiry: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await ensureTokenValid('gemini');
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
refreshed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,7 @@ describe('provider-capabilities', () => {
|
||||
expect(getOAuthCallbackPort('cursor')).toBeNull();
|
||||
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
|
||||
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
||||
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
|
||||
expect(getProviderDisplayName('agy')).toBe('Antigravity');
|
||||
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
|
||||
});
|
||||
|
||||
@@ -13,18 +13,16 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
describe('Gemini CLI Quota Fetcher', () => {
|
||||
const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const MANAGEMENT_AUTH_FILES_URL = 'http://127.0.0.1:8317/v0/management/auth-files';
|
||||
const MANAGEMENT_API_CALL_URL = 'http://127.0.0.1:8317/v0/management/api-call';
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalGeminiClientId: string | undefined;
|
||||
let originalGeminiClientSecret: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
|
||||
let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota;
|
||||
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
|
||||
let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports;
|
||||
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
|
||||
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
||||
|
||||
function writeGeminiToken(token: Record<string, unknown>, filename = 'gemini-test.json'): string {
|
||||
@@ -47,9 +45,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expiry: Date.now() + 60 * 60 * 1000,
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
@@ -60,12 +55,7 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
delete process.env.CCS_DIR;
|
||||
|
||||
const configGenerator = await import(
|
||||
@@ -79,9 +69,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
} = await import(
|
||||
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
|
||||
));
|
||||
({ refreshGeminiToken } = await import(
|
||||
`../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}`
|
||||
));
|
||||
({ getProviderAuthDir } = configGenerator);
|
||||
});
|
||||
|
||||
@@ -100,18 +87,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
} else {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
}
|
||||
|
||||
if (originalGeminiClientId === undefined) {
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
} else {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId;
|
||||
}
|
||||
|
||||
if (originalGeminiClientSecret === undefined) {
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
} else {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret;
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveGeminiCliProjectId', () => {
|
||||
@@ -520,14 +495,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
method: 'POST',
|
||||
status: 400,
|
||||
response: {
|
||||
error: 'invalid_grant',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await fetchGeminiCliQuota('reauth@example.com');
|
||||
@@ -667,7 +634,7 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
expect(result.errorDetail).toBe('[HTML error response omitted]');
|
||||
});
|
||||
|
||||
it('refreshes the requested Gemini account instead of the default account', async () => {
|
||||
it('uses the requested Gemini account when delegating auth recovery to CLIProxy management', async () => {
|
||||
writeGeminiToken(
|
||||
{
|
||||
type: 'gemini',
|
||||
@@ -677,9 +644,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'default-access-token',
|
||||
refresh_token: 'default-refresh-token',
|
||||
expiry: Date.now() + 60 * 60 * 1000,
|
||||
client_id: 'default-client-id',
|
||||
client_secret: 'default-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-default.json'
|
||||
@@ -694,9 +658,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'target-stale-token',
|
||||
refresh_token: 'target-refresh-token',
|
||||
expiry: Date.now() - 1000,
|
||||
client_id: 'target-client-id',
|
||||
client_secret: 'target-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-target.json'
|
||||
@@ -704,16 +665,37 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
url: MANAGEMENT_AUTH_FILES_URL,
|
||||
response: {
|
||||
files: [
|
||||
{
|
||||
auth_index: 'target-auth-index',
|
||||
provider: 'gemini',
|
||||
email: 'target@example.com',
|
||||
name: 'target@example.com-gen-lang-client-target-project.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
url: MANAGEMENT_API_CALL_URL,
|
||||
method: 'POST',
|
||||
response: { access_token: 'target-fresh-token', expires_in: 1800 },
|
||||
response: {
|
||||
status_code: 200,
|
||||
body: JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: GEMINI_QUOTA_URL,
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
status: 401,
|
||||
response: {
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }],
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -728,14 +710,14 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const [refreshRequest, quotaRequest] = getCapturedFetchRequests();
|
||||
expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL);
|
||||
expect(refreshRequest.body).toContain('refresh_token=target-refresh-token');
|
||||
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
||||
expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token');
|
||||
const [, managedLookupRequest, managedQuotaRequest] = getCapturedFetchRequests();
|
||||
expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL);
|
||||
expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL);
|
||||
expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"');
|
||||
expect(managedQuotaRequest.body).not.toContain('default-refresh-token');
|
||||
});
|
||||
|
||||
it('retries a 401 quota failure after a transient proactive refresh failure', async () => {
|
||||
it('retries a 401 quota failure through the management API instead of refreshing locally', async () => {
|
||||
writeGeminiToken(
|
||||
{
|
||||
type: 'gemini',
|
||||
@@ -745,20 +727,12 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'retry-stale-token',
|
||||
refresh_token: 'retry-refresh-token',
|
||||
expiry: Date.now() + 60 * 1000,
|
||||
client_id: 'retry-client-id',
|
||||
client_secret: 'retry-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-retry.json'
|
||||
);
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
method: 'POST',
|
||||
response: { access_token: 'unused-default', expires_in: 1800 },
|
||||
},
|
||||
{
|
||||
url: GEMINI_QUOTA_URL,
|
||||
method: 'POST',
|
||||
@@ -776,49 +750,63 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
]);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let refreshAttempt = 0;
|
||||
let quotaAttempt = 0;
|
||||
let managedLookupAttempt = 0;
|
||||
let managedRequestAttempt = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url === GOOGLE_TOKEN_URL) {
|
||||
refreshAttempt += 1;
|
||||
return refreshAttempt === 1
|
||||
? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
: new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === GEMINI_QUOTA_URL) {
|
||||
quotaAttempt += 1;
|
||||
return quotaAttempt === 1
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
}),
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url === MANAGEMENT_AUTH_FILES_URL) {
|
||||
managedLookupAttempt += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
: new Response(
|
||||
JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
auth_index: 'retry-auth-index',
|
||||
provider: 'gemini',
|
||||
email: 'retry@example.com',
|
||||
name: 'retry@example.com-gen-lang-client-retry-project.json',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url === MANAGEMENT_API_CALL_URL) {
|
||||
managedRequestAttempt += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status_code: 200,
|
||||
body: JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
@@ -828,13 +816,9 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
const result = await fetchGeminiCliQuota('retry@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(refreshAttempt).toBe(2);
|
||||
expect(quotaAttempt).toBe(2);
|
||||
|
||||
const storedToken = JSON.parse(
|
||||
fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8')
|
||||
) as { token?: { access_token?: string } };
|
||||
expect(storedToken.token?.access_token).toBe('retry-fresh-token');
|
||||
expect(quotaAttempt).toBe(1);
|
||||
expect(managedLookupAttempt).toBe(1);
|
||||
expect(managedRequestAttempt).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -934,84 +918,4 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshGeminiToken', () => {
|
||||
it('uses OAuth client metadata stored in the token file', async () => {
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'file@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
client_id: 'file-client-id',
|
||||
client_secret: 'file-client-secret',
|
||||
token_uri: 'https://oauth2.googleapis.com/token',
|
||||
},
|
||||
});
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: { access_token: 'fresh-token', expires_in: 1800 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.body).toContain('client_id=file-client-id');
|
||||
expect(request.body).toContain('client_secret=file-client-secret');
|
||||
expect(request.body).toContain('refresh_token=refresh-from-file');
|
||||
});
|
||||
|
||||
it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => {
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id';
|
||||
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret';
|
||||
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'env@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: 'https://oauth2.googleapis.com/token',
|
||||
method: 'POST',
|
||||
response: { access_token: 'fresh-token', expires_in: 1800 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const [request] = getCapturedFetchRequests();
|
||||
expect(request.body).toContain('client_id=env-client-id');
|
||||
expect(request.body).toContain('client_secret=env-client-secret');
|
||||
});
|
||||
|
||||
it('returns a clear error when no refresh client credentials are available', async () => {
|
||||
writeGeminiToken({
|
||||
type: 'gemini',
|
||||
email: 'missing@example.com',
|
||||
token: {
|
||||
access_token: 'old-token',
|
||||
refresh_token: 'refresh-from-file',
|
||||
expiry: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await refreshGeminiToken();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID');
|
||||
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user