fix: project id forwarding regression (#93)
* fix(proxy): avoid empty project context and hydrate project_id from account token * test(proxy): add regressions for empty project and token project_id hydration * test(scripts): add live request validator for project_id forwarding
This commit is contained in:
7 files changed
+534
-13
No files matched your search
@@ -0,0 +1,358 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
|
||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
|
||||
const mockTokenManager: any = {
|
||||
getNextToken: async () => null,
|
||||
markAsRateLimited: () => undefined,
|
||||
markAsForbidden: () => undefined,
|
||||
};
|
||||
|
||||
const mockGeminiClient: any = {
|
||||
streamGenerateInternal: async () => undefined,
|
||||
generateInternal: async () => undefined,
|
||||
};
|
||||
|
||||
class TestableProxyService extends ProxyService {
|
||||
constructor() {
|
||||
super(mockTokenManager, mockGeminiClient);
|
||||
}
|
||||
|
||||
public createGeminiInternal(
|
||||
model: string,
|
||||
request: Record<string, unknown>,
|
||||
projectId: string | undefined,
|
||||
requestType: string,
|
||||
): Record<string, unknown> {
|
||||
return (this as any).createGeminiInternalRequest(model, request, projectId, requestType);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnv(name: string, fallback?: string): string {
|
||||
const value = process.env[name];
|
||||
if (value && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error(`Missing required env: ${name}`);
|
||||
}
|
||||
|
||||
function getEnvFromList(names: string[], fallback?: string): string {
|
||||
for (const name of names) {
|
||||
const value = process.env[name];
|
||||
if (value && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
throw new Error(`Missing required env. Tried: ${names.join(', ')}`);
|
||||
}
|
||||
|
||||
function getLiveHeaders(apiKey?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (apiKey && apiKey.trim() !== '') {
|
||||
headers.Authorization = apiKey.startsWith('Bearer ') ? apiKey : `Bearer ${apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function callLive(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
apiKey?: string,
|
||||
): Promise<{ status: number; text: string }> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getLiveHeaders(apiKey),
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
text,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to call ${url}. Ensure proxy service is running and reachable. Original error: ${message}`,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateMapperProjectField(): Promise<void> {
|
||||
const request = {
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
max_tokens: 128,
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
};
|
||||
|
||||
const emptyProjectBody = transformClaudeRequestIn(request as any, '');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(emptyProjectBody, 'project'));
|
||||
|
||||
const blankProjectBody = transformClaudeRequestIn(request as any, ' ');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(blankProjectBody, 'project'));
|
||||
|
||||
const validProjectBody = transformClaudeRequestIn(request as any, 'resolved-project-id');
|
||||
assert.equal(validProjectBody.project, 'resolved-project-id');
|
||||
}
|
||||
|
||||
async function validateProxyInternalBuilder(): Promise<void> {
|
||||
const service = new TestableProxyService();
|
||||
|
||||
const emptyInternalBody = service.createGeminiInternal(
|
||||
'gemini-2.5-flash',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] },
|
||||
'',
|
||||
'generate-content',
|
||||
);
|
||||
console.log(
|
||||
'[DEBUG] Internal request with empty project_id:',
|
||||
JSON.stringify(emptyInternalBody, null, 2),
|
||||
);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(emptyInternalBody, 'project'));
|
||||
|
||||
const validInternalBody = service.createGeminiInternal(
|
||||
'gemini-2.5-flash',
|
||||
{ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] },
|
||||
'resolved-project-id',
|
||||
'generate-content',
|
||||
);
|
||||
console.log(
|
||||
'[DEBUG] Internal request with valid project_id:',
|
||||
JSON.stringify(validInternalBody, null, 2),
|
||||
);
|
||||
assert.equal(validInternalBody.project, 'resolved-project-id');
|
||||
}
|
||||
|
||||
async function validateRuntimeGeminiRequestPath(): Promise<void> {
|
||||
const service = new TestableProxyService();
|
||||
const originalGetNextToken = mockTokenManager.getNextToken;
|
||||
const originalGenerateInternal = mockGeminiClient.generateInternal;
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
|
||||
mockTokenManager.getNextToken = async () => ({
|
||||
id: 'account-1',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
project_id: '',
|
||||
upstream_proxy_url: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
mockGeminiClient.generateInternal = async (body: Record<string, unknown>) => {
|
||||
capturedBody = body;
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'ok' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
totalTokenCount: 1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
} finally {
|
||||
mockTokenManager.getNextToken = originalGetNextToken;
|
||||
mockGeminiClient.generateInternal = originalGenerateInternal;
|
||||
}
|
||||
|
||||
assert.ok(capturedBody, 'Expected captured Gemini internal request');
|
||||
console.log(
|
||||
'[DEBUG] Runtime captured internal request (from handleGeminiGenerateContent):',
|
||||
JSON.stringify(capturedBody, null, 2),
|
||||
);
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(capturedBody, 'project'));
|
||||
}
|
||||
|
||||
async function validateRuntimeGeminiRequestFromAccountProject(): Promise<void> {
|
||||
const sourceProjectId = `account-project-${Date.now()}`;
|
||||
const account = {
|
||||
id: 'account-source-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'source-access-token',
|
||||
refresh_token: 'source-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
project_id: sourceProjectId,
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 1,
|
||||
};
|
||||
|
||||
const cloudAccountRepo = CloudAccountRepo as any;
|
||||
const googleApiService = GoogleAPIService as any;
|
||||
|
||||
const originalGetAccounts = cloudAccountRepo.getAccounts;
|
||||
const originalGetAccount = cloudAccountRepo.getAccount;
|
||||
const originalUpdateToken = cloudAccountRepo.updateToken;
|
||||
const originalFetchProjectId = googleApiService.fetchProjectId;
|
||||
const originalGenerateInternal = mockGeminiClient.generateInternal;
|
||||
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
let fetchProjectIdCalled = false;
|
||||
|
||||
cloudAccountRepo.getAccounts = async () => [account];
|
||||
cloudAccountRepo.getAccount = async (id: string) => (id === account.id ? account : null);
|
||||
cloudAccountRepo.updateToken = async () => undefined;
|
||||
googleApiService.fetchProjectId = async () => {
|
||||
fetchProjectIdCalled = true;
|
||||
return 'unexpected-project-id';
|
||||
};
|
||||
|
||||
mockGeminiClient.generateInternal = async (body: Record<string, unknown>) => {
|
||||
capturedBody = body;
|
||||
return {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'ok' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
totalTokenCount: 1,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const tokenManager = new TokenManagerService();
|
||||
const service = new ProxyService(tokenManager as any, mockGeminiClient as any);
|
||||
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello from account source' }] }],
|
||||
} as any);
|
||||
} finally {
|
||||
cloudAccountRepo.getAccounts = originalGetAccounts;
|
||||
cloudAccountRepo.getAccount = originalGetAccount;
|
||||
cloudAccountRepo.updateToken = originalUpdateToken;
|
||||
googleApiService.fetchProjectId = originalFetchProjectId;
|
||||
mockGeminiClient.generateInternal = originalGenerateInternal;
|
||||
}
|
||||
|
||||
assert.ok(capturedBody, 'Expected captured Gemini internal request');
|
||||
const captured = capturedBody as Record<string, unknown>;
|
||||
assert.equal(
|
||||
captured['project'],
|
||||
sourceProjectId,
|
||||
'Expected project field to come from account.token.project_id',
|
||||
);
|
||||
assert.equal(
|
||||
fetchProjectIdCalled,
|
||||
false,
|
||||
'fetchProjectId should not be called when account already has project_id',
|
||||
);
|
||||
|
||||
console.log('[DEBUG] Account source project_id:', sourceProjectId);
|
||||
console.log(
|
||||
'[DEBUG] Runtime captured internal request (project from account):',
|
||||
JSON.stringify(captured, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
async function validateLiveRequests(): Promise<void> {
|
||||
const baseUrl = getEnvFromList(['PROJECT_ID_TEST_BASE_URL', 'PARITY_BASE_URL_A']);
|
||||
const timeoutMs = Number(getEnv('PROJECT_ID_TEST_TIMEOUT_MS', '30000'));
|
||||
const apiKey = getEnvFromList(
|
||||
['PROJECT_ID_TEST_API_KEY', 'PARITY_API_KEY_A', 'PARITY_API_KEY'],
|
||||
'',
|
||||
);
|
||||
|
||||
const gemini = await callLive(
|
||||
baseUrl,
|
||||
'/v1beta/models/gemini-2.5-flash:generateContent',
|
||||
{
|
||||
contents: [{ role: 'user', parts: [{ text: 'project-id live validation' }] }],
|
||||
},
|
||||
timeoutMs,
|
||||
apiKey,
|
||||
);
|
||||
assert.ok(
|
||||
!gemini.text.toLowerCase().includes('invalid project resource name projects/'),
|
||||
`[gemini-live] still hit invalid empty project path. status=${gemini.status}, body=${gemini.text}`,
|
||||
);
|
||||
console.log(`[PASS] Live Gemini request check (status=${gemini.status})`);
|
||||
|
||||
const anthropic = await callLive(
|
||||
baseUrl,
|
||||
'/v1/messages',
|
||||
{
|
||||
model: 'claude-sonnet-4-5',
|
||||
stream: false,
|
||||
max_tokens: 128,
|
||||
messages: [{ role: 'user', content: 'project-id live validation' }],
|
||||
},
|
||||
timeoutMs,
|
||||
apiKey,
|
||||
);
|
||||
assert.ok(
|
||||
!anthropic.text.toLowerCase().includes('invalid project resource name projects/'),
|
||||
`[anthropic-live] still hit invalid empty project path. status=${anthropic.status}, body=${anthropic.text}`,
|
||||
);
|
||||
console.log(`[PASS] Live Anthropic request check (status=${anthropic.status})`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await validateMapperProjectField();
|
||||
console.log('[PASS] ClaudeRequestMapper project field behavior');
|
||||
|
||||
await validateProxyInternalBuilder();
|
||||
console.log('[PASS] ProxyService internal request project field behavior');
|
||||
|
||||
await validateRuntimeGeminiRequestPath();
|
||||
console.log('[PASS] Runtime Gemini request path omits empty project');
|
||||
|
||||
await validateRuntimeGeminiRequestFromAccountProject();
|
||||
console.log('[PASS] Runtime Gemini request uses project_id from account source');
|
||||
|
||||
if (process.argv.includes('--live')) {
|
||||
await validateLiveRequests();
|
||||
console.log('[PASS] Live request validation');
|
||||
}
|
||||
|
||||
console.log('[DONE] Project ID request validation completed');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[FAIL] Project ID request validation failed');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { CloudMonitorService } from '../../services/CloudMonitorService';
|
||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
||||
import { AutoSwitchService } from '../../services/AutoSwitchService';
|
||||
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -192,3 +193,98 @@ describe('CloudMonitorService', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TokenManagerService project-id hydration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('hydrates missing project_id and persists it', async () => {
|
||||
const account = {
|
||||
id: 'acc-1',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 1,
|
||||
};
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([account] as never);
|
||||
vi.mocked(CloudAccountRepo.getAccount).mockResolvedValue(account as never);
|
||||
vi.mocked(CloudAccountRepo.updateToken).mockResolvedValue(undefined as never);
|
||||
vi.mocked(GoogleAPIService.fetchProjectId).mockResolvedValue('resolved-project' as never);
|
||||
|
||||
const service = new TokenManagerService();
|
||||
const selectedToken = await service.getNextToken();
|
||||
|
||||
expect(GoogleAPIService.fetchProjectId).toHaveBeenCalledWith('access-token');
|
||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalledWith(
|
||||
'acc-1',
|
||||
expect.objectContaining({
|
||||
project_id: 'resolved-project',
|
||||
}),
|
||||
);
|
||||
expect(selectedToken?.token.project_id).toBe('resolved-project');
|
||||
});
|
||||
|
||||
it('keeps existing valid project_id without extra fetch', async () => {
|
||||
const account = {
|
||||
id: 'acc-2',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access-token-2',
|
||||
refresh_token: 'refresh-token-2',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
project_id: 'existing-project',
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 1,
|
||||
};
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([account] as never);
|
||||
vi.mocked(GoogleAPIService.fetchProjectId).mockResolvedValue('new-project' as never);
|
||||
|
||||
const service = new TokenManagerService();
|
||||
const selectedToken = await service.getNextToken();
|
||||
|
||||
expect(GoogleAPIService.fetchProjectId).not.toHaveBeenCalled();
|
||||
expect(selectedToken?.token.project_id).toBe('existing-project');
|
||||
});
|
||||
|
||||
it('keeps request usable when project_id cannot be resolved', async () => {
|
||||
const account = {
|
||||
id: 'acc-3',
|
||||
provider: 'google',
|
||||
email: '[email protected]',
|
||||
token: {
|
||||
access_token: 'access-token-3',
|
||||
refresh_token: 'refresh-token-3',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
|
||||
project_id: 'cloud-code-123',
|
||||
},
|
||||
created_at: 1,
|
||||
last_used: 1,
|
||||
};
|
||||
|
||||
vi.mocked(CloudAccountRepo.getAccounts).mockResolvedValue([account] as never);
|
||||
vi.mocked(GoogleAPIService.fetchProjectId).mockResolvedValue(null as never);
|
||||
|
||||
const service = new TokenManagerService();
|
||||
const selectedToken = await service.getNextToken();
|
||||
|
||||
expect(GoogleAPIService.fetchProjectId).toHaveBeenCalledWith('access-token-3');
|
||||
expect(selectedToken?.token.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -454,10 +454,30 @@ describe('ProxyService Empty Stream Retry Logic', () => {
|
||||
expect(mockTokenManager.getNextToken).toHaveBeenCalledTimes(1);
|
||||
expect(mockGeminiClient.generateInternal).toHaveBeenCalledTimes(2);
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[0][0].project).toBe('project-1');
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[1][0].project).toBe('');
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[1][0].project).toBeUndefined();
|
||||
expect(mockGeminiClient.generateInternal.mock.calls[1][0]).not.toHaveProperty('project');
|
||||
expect((result as any).candidates?.[0]?.content?.parts?.[0]?.text).toBe('ok');
|
||||
});
|
||||
|
||||
it('omits empty project id in Gemini internal payload', async () => {
|
||||
const service = new TestableProxyService();
|
||||
const token = createToken('acc-1');
|
||||
token.token.project_id = '';
|
||||
mockTokenManager.getNextToken.mockResolvedValue(token);
|
||||
mockGeminiClient.generateInternal.mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }],
|
||||
usageMetadata: { totalTokenCount: 5 },
|
||||
});
|
||||
|
||||
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
||||
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
|
||||
} as any);
|
||||
|
||||
const internalPayload = mockGeminiClient.generateInternal.mock.calls[0][0];
|
||||
expect(internalPayload.project).toBeUndefined();
|
||||
expect(internalPayload).not.toHaveProperty('project');
|
||||
});
|
||||
|
||||
it('uses generate-content requestType for Gemini stream internal payload', async () => {
|
||||
const service = new TestableProxyService();
|
||||
mockTokenManager.getNextToken.mockResolvedValue(createToken('acc-1'));
|
||||
|
||||
Reference in new issue
Block a user