Compare commits

...
4 Commits
Author SHA1 Message Date
semantic-release-bot 6f8e8085ef chore(release): 0.9.2 [skip ci]
## [0.9.2](https://github.com/Draculabo/AntigravityManager/compare/v0.9.1...v0.9.2) (2026-02-11)

### 🐛 Bug Fixes

* project id fallback and stream error regression ([#94](https://github.com/Draculabo/AntigravityManager/issues/94)) ([caf9d58](https://github.com/Draculabo/AntigravityManager/commit/caf9d5849ac86f4c83cb7b33804b873e0c2ff545))
2026-02-11 09:13:22 +00:00
vin caf9d5849a fix: project id fallback and stream error regression (#94)
* fix(proxy): harden project id handling for upgraded accounts

* fix(proxy): parse stream error payloads to preserve upstream project errors
2026-02-11 17:11:27 +08:00
semantic-release-bot 4fba53a382 chore(release): 0.9.1 [skip ci]
## [0.9.1](https://github.com/Draculabo/AntigravityManager/compare/v0.9.0...v0.9.1) (2026-02-11)

### 🐛 Bug Fixes

* project id forwarding regression ([#93](https://github.com/Draculabo/AntigravityManager/issues/93)) ([ab78d93](https://github.com/Draculabo/AntigravityManager/commit/ab78d9325213623067b5bd867b35464cd4eefd73))
2026-02-11 04:37:14 +00:00
vin ab78d93252 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
2026-02-11 12:36:01 +08:00
11 changed files with 726 additions and 29 deletions

No files matched your search

+12
View File
@@ -2,6 +2,18 @@
# Changelog
## [0.9.2](https://github.com/Draculabo/AntigravityManager/compare/v0.9.1...v0.9.2) (2026-02-11)
### 🐛 Bug Fixes
* project id fallback and stream error regression ([#94](https://github.com/Draculabo/AntigravityManager/issues/94)) ([caf9d58](https://github.com/Draculabo/AntigravityManager/commit/caf9d5849ac86f4c83cb7b33804b873e0c2ff545))
## [0.9.1](https://github.com/Draculabo/AntigravityManager/compare/v0.9.0...v0.9.1) (2026-02-11)
### 🐛 Bug Fixes
* project id forwarding regression ([#93](https://github.com/Draculabo/AntigravityManager/issues/93)) ([ab78d93](https://github.com/Draculabo/AntigravityManager/commit/ab78d9325213623067b5bd867b35464cd4eefd73))
## [0.9.0](https://github.com/Draculabo/AntigravityManager/compare/v0.8.0...v0.9.0) (2026-02-10)
### ✨ Features
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "antigravity-manager",
"version": "0.9.0",
"version": "0.9.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "antigravity-manager",
"version": "0.9.0",
"version": "0.9.2",
"license": "CC-BY-NC-SA-4.0",
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "antigravity-manager",
"productName": "Antigravity Manager",
"version": "0.9.0",
"version": "0.9.2",
"description": "Antigravity Manager - Electron App",
"main": ".vite/build/main.js",
"private": true,
+7 -2
View File
@@ -43,7 +43,7 @@ interface RequestConfig {
*/
export function transformClaudeRequestIn(
claudeReq: ClaudeRequest,
projectId: string,
projectId?: string,
): GeminiInternalRequest {
// Check for networking tools (server tool or built-in tool)
const hasWebSearchTool = detectsNetworkingTool(claudeReq.tools);
@@ -163,8 +163,9 @@ export function transformClaudeRequestIn(
const requestId = `agent-${uuidv4()}`;
const normalizedProjectId = projectId?.trim();
const body: GeminiInternalRequest = {
project: projectId,
requestId: requestId,
request: innerRequest as GeminiInternalRequest['request'],
model: config.finalModel,
@@ -172,6 +173,10 @@ export function transformClaudeRequestIn(
requestType: config.requestType,
};
if (normalizedProjectId) {
body.project = normalizedProjectId;
}
if (claudeReq.metadata?.user_id) {
body.sessionId = claudeReq.metadata.user_id;
}
+1 -1
View File
@@ -269,7 +269,7 @@ export interface GeminiRequest {
}
export interface GeminiInternalRequest {
project: string;
project?: string;
requestId: string;
request: GeminiRequest;
model: string;
+118 -10
View File
@@ -198,7 +198,7 @@ export class GeminiClient {
const hasNextEndpoint = index < baseUrls.length - 1;
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
this.handleAxiosError(error, operation);
await this.handleAxiosError(error, operation);
}
this.logger.warn(
@@ -209,33 +209,141 @@ export class GeminiClient {
}
}
this.handleAxiosError(lastError, operation);
await this.handleAxiosError(lastError, operation);
throw new Error(`[${operation}] unexpected control flow after upstream error handling`);
}
private handleAxiosError(error: unknown, operation: string): never {
private async handleAxiosError(error: unknown, operation: string): Promise<never> {
if (axios.isAxiosError(error)) {
const responseData = error.response?.data;
const upstreamMessage = this.extractAxiosErrorMessage(responseData);
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
this.logger.error(
`[${operation}] upstream request error: ${error.message} - ${this.safeStringify(responseData)}`,
`[${operation}] upstream request error: ${error.message} - ${this.describeAxiosErrorData(
responseData,
)}`,
);
throw new Error(upstreamMessage || error.message);
}
this.throwAsCleanError(error);
}
private extractAxiosErrorMessage(responseData: unknown): string | null {
if (!responseData || typeof responseData !== 'object') {
private async extractAxiosErrorMessage(responseData: unknown): Promise<string | null> {
const fromObject = this.extractAxiosErrorMessageFromObject(responseData);
if (fromObject) {
return fromObject;
}
if (typeof responseData === 'string') {
return this.extractAxiosErrorMessageFromText(responseData);
}
if (Buffer.isBuffer(responseData)) {
return this.extractAxiosErrorMessageFromText(responseData.toString('utf-8'));
}
if (this.isReadableStream(responseData)) {
const streamText = await this.readStreamAsText(responseData);
return streamText ? this.extractAxiosErrorMessageFromText(streamText) : null;
}
return null;
}
private extractAxiosErrorMessageFromObject(responseData: unknown): string | null {
if (!responseData || typeof responseData !== 'object' || this.isReadableStream(responseData)) {
return null;
}
const errorRecord = (responseData as { error?: unknown }).error;
if (!errorRecord || typeof errorRecord !== 'object') {
if (errorRecord && typeof errorRecord === 'object') {
const message = (errorRecord as { message?: unknown }).message;
if (typeof message === 'string' && message.trim() !== '') {
return message.trim();
}
}
const message = (responseData as { message?: unknown }).message;
if (typeof message === 'string' && message.trim() !== '') {
return message.trim();
}
return null;
}
private extractAxiosErrorMessageFromText(rawText: string): string | null {
const text = rawText.trim();
if (!text) {
return null;
}
const message = (errorRecord as { message?: unknown }).message;
return typeof message === 'string' && message.trim() !== '' ? message : null;
const sseLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('data:'));
for (const line of sseLines) {
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') {
continue;
}
const parsed = this.tryParseJson(payload);
const message = this.extractAxiosErrorMessageFromObject(parsed);
if (message) {
return message;
}
}
const parsed = this.tryParseJson(text);
const fromJson = this.extractAxiosErrorMessageFromObject(parsed);
if (fromJson) {
return fromJson;
}
return null;
}
private tryParseJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return null;
}
}
private isReadableStream(value: unknown): value is NodeJS.ReadableStream {
return (
Boolean(value) &&
typeof value === 'object' &&
typeof (value as { pipe?: unknown }).pipe === 'function'
);
}
private async readStreamAsText(stream: NodeJS.ReadableStream): Promise<string | null> {
return new Promise((resolve) => {
let buffer = '';
const maxChars = 512 * 1024;
stream.on('data', (chunk: Buffer | string) => {
const text = Buffer.isBuffer(chunk) ? chunk.toString('utf-8') : String(chunk);
if (buffer.length >= maxChars) {
return;
}
buffer += text;
if (buffer.length > maxChars) {
buffer = buffer.slice(0, maxChars);
}
});
stream.on('end', () => resolve(buffer));
stream.on('error', () => resolve(null));
});
}
private describeAxiosErrorData(responseData: unknown): string {
if (this.isReadableStream(responseData)) {
return '[stream]';
}
return this.safeStringify(responseData);
}
private resolveAxiosProxy(upstreamProxyUrl?: string): AxiosProxyConfig | false | undefined {
+11 -3
View File
@@ -476,17 +476,24 @@ export class ProxyService {
private createGeminiInternalRequest(
model: string,
request: GeminiRequest,
projectId: string,
projectId: string | undefined,
requestType: string,
): GeminiInternalRequest {
return {
project: projectId,
const normalizedProjectId = projectId?.trim();
const internalRequest: GeminiInternalRequest = {
requestId: uuidv4(),
request: this.toInternalGeminiRequest(request),
model,
userAgent: process.env.PROXY_REQUEST_USER_AGENT ?? this.defaultRequestUserAgent,
requestType,
};
if (normalizedProjectId) {
internalRequest.project = normalizedProjectId;
}
return internalRequest;
}
private normalizeGeminiGenerateResponse(response: GeminiResponse): GeminiResponse {
@@ -1481,6 +1488,7 @@ export class ProxyService {
private isProjectNotFoundError(errorMessage: string): boolean {
const msg = errorMessage.toLowerCase();
return (
msg.includes('invalid project resource name projects/') ||
(msg.includes('resource projects/') && msg.includes('could not be found')) ||
(msg.includes('project') && msg.includes('not found'))
);
@@ -16,9 +16,29 @@ interface TokenData {
upstream_proxy_url?: string;
}
function normalizeProjectId(projectId: string | null | undefined): string | undefined {
if (typeof projectId !== 'string') {
return undefined;
}
const trimmedProjectId = projectId.trim();
if (trimmedProjectId === '' || /^cloud-code-\d+$/i.test(trimmedProjectId)) {
return undefined;
}
// Legacy upgrades may carry malformed resource names (e.g. "projects/").
// The internal gateway expects a plain project id, not a resource path.
if (/^projects(?:\/.*)?$/i.test(trimmedProjectId)) {
return undefined;
}
return trimmedProjectId;
}
@Injectable()
export class TokenManagerService implements OnModuleInit {
private readonly logger = new Logger(TokenManagerService.name);
private readonly defaultFallbackProjectId = 'silver-orbit-5m7qc';
private currentIndex = 0;
private readonly stickySessionTtlMs = 10 * 60 * 1000;
private readonly rateLimitCooldownMs = 5 * 60 * 1000;
@@ -165,6 +185,8 @@ export class TokenManagerService implements OnModuleInit {
sessionKey?: string,
): Promise<CloudAccount | null> {
try {
let effectiveProjectId: string | undefined;
// Check if token needs refresh (expires in < 5 minutes)
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
this.logger.log(`Token for ${tokenData.email} is close to expiry; refreshing...`);
@@ -177,7 +199,7 @@ export class TokenManagerService implements OnModuleInit {
tokenData.expiry_timestamp = nowSeconds + newTokens.expires_in;
// Save to DB
await this.saveRefreshedToken(accountId, tokenData);
await this.saveTokenState(accountId, tokenData);
this.tokens.set(accountId, tokenData);
this.logger.log(`Refreshed token for ${tokenData.email}`);
@@ -186,13 +208,39 @@ export class TokenManagerService implements OnModuleInit {
}
}
if (
typeof tokenData.project_id !== 'string' ||
tokenData.project_id.trim() === '' ||
/^cloud-code-\d+$/i.test(tokenData.project_id.trim())
) {
if (normalizeProjectId(tokenData.project_id) === undefined) {
tokenData.project_id = undefined;
}
effectiveProjectId = tokenData.project_id;
if (!effectiveProjectId) {
try {
const fetchedProjectId = await GoogleAPIService.fetchProjectId(tokenData.access_token);
const normalizedProjectId = normalizeProjectId(fetchedProjectId);
if (normalizedProjectId) {
tokenData.project_id = normalizedProjectId;
effectiveProjectId = normalizedProjectId;
await this.saveTokenState(accountId, tokenData);
this.tokens.set(accountId, tokenData);
this.logger.log(`Resolved project ID for ${tokenData.email}: ${normalizedProjectId}`);
} else {
this.logger.warn(
`Project ID is unavailable for ${tokenData.email}; request will continue without project context`,
);
}
} catch (error) {
this.logger.warn(`Failed to resolve project ID for ${tokenData.email}`, error);
}
}
if (!effectiveProjectId) {
const fallbackProjectId = this.getFallbackProjectId();
effectiveProjectId = fallbackProjectId;
this.logger.warn(
`Using fallback project ID for ${tokenData.email} without persistence: ${fallbackProjectId}`,
);
}
this.logger.log(`Using account: ${tokenData.email}`);
if (sessionKey) {
@@ -214,7 +262,7 @@ export class TokenManagerService implements OnModuleInit {
token_type: tokenData.token_type,
expires_in: tokenData.expires_in,
expiry_timestamp: tokenData.expiry_timestamp,
project_id: tokenData.project_id,
project_id: effectiveProjectId,
session_id: tokenData.session_id,
upstream_proxy_url: tokenData.upstream_proxy_url,
},
@@ -272,7 +320,7 @@ export class TokenManagerService implements OnModuleInit {
);
}
private async saveRefreshedToken(accountId: string, tokenData: TokenData) {
private async saveTokenState(accountId: string, tokenData: TokenData) {
try {
const acc = await CloudAccountRepo.getAccount(accountId);
if (acc && acc.token) {
@@ -281,11 +329,14 @@ export class TokenManagerService implements OnModuleInit {
access_token: tokenData.access_token,
expires_in: tokenData.expires_in,
expiry_timestamp: tokenData.expiry_timestamp,
project_id: tokenData.project_id ?? acc.token.project_id,
session_id: tokenData.session_id ?? acc.token.session_id,
upstream_proxy_url: tokenData.upstream_proxy_url ?? acc.token.upstream_proxy_url,
};
await CloudAccountRepo.updateToken(accountId, newToken);
}
} catch (e) {
this.logger.error('Unable to persist refreshed token to DB', e);
this.logger.error('Unable to persist token state to DB', e);
}
}
@@ -295,4 +346,13 @@ export class TokenManagerService implements OnModuleInit {
getAccountCount(): number {
return this.tokens.size;
}
private getFallbackProjectId(): string {
const fromEnv = process.env.PROXY_FALLBACK_PROJECT_ID?.trim();
const normalizedFromEnv = normalizeProjectId(fromEnv);
if (normalizedFromEnv) {
return normalizedFromEnv;
}
return this.defaultFallbackProjectId;
}
}
@@ -0,0 +1,352 @@
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';
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 validateRuntimeAnthropicRequestFromRealTokenManager(): Promise<void> {
const originalGenerateInternal = mockGeminiClient.generateInternal;
let capturedBody: Record<string, unknown> | null = null;
let selectedToken: any = null;
let observedGetNextTokenOptions: any = null;
mockGeminiClient.generateInternal = async (body: Record<string, unknown>) => {
capturedBody = body;
return {
candidates: [
{
content: {
parts: [{ text: 'ok' }],
},
finishReason: 'STOP',
},
],
usageMetadata: {
totalTokenCount: 1,
},
};
};
const realTokenManager = new TokenManagerService();
await realTokenManager.onModuleInit();
const tokenManagerProxy = {
getNextToken: async (options?: { sessionKey?: string; excludeAccountIds?: string[] }) => {
observedGetNextTokenOptions = options ?? null;
selectedToken = await realTokenManager.getNextToken(options);
return selectedToken;
},
markAsRateLimited: (accountIdOrEmail: string) =>
realTokenManager.markAsRateLimited(accountIdOrEmail),
markAsForbidden: (accountIdOrEmail: string) => realTokenManager.markAsForbidden(accountIdOrEmail),
};
try {
const service = new ProxyService(tokenManagerProxy as any, mockGeminiClient as any);
await service.handleAnthropicMessages({
model: 'claude-sonnet-4-5',
stream: false,
max_tokens: 128,
metadata: {
session_id: 'project-chain-debug-session',
},
messages: [{ role: 'user', content: 'hello from real token manager chain' }],
} as any);
} finally {
mockGeminiClient.generateInternal = originalGenerateInternal;
}
assert.ok(selectedToken, 'Expected TokenManagerService.getNextToken to return a token');
assert.ok(capturedBody, 'Expected captured Gemini internal request');
const captured = capturedBody as Record<string, unknown>;
const tokenProjectId = selectedToken?.token?.project_id;
const normalizedTokenProjectId =
typeof tokenProjectId === 'string' && tokenProjectId.trim() !== '' ? tokenProjectId.trim() : undefined;
if (normalizedTokenProjectId) {
assert.equal(
captured['project'],
normalizedTokenProjectId,
'Expected project field to come from tokenManager.getNextToken().token.project_id',
);
} else {
assert.ok(
!Object.prototype.hasOwnProperty.call(captured, 'project'),
'Expected project field to be omitted when token project_id is unavailable',
);
}
console.log(
'[DEBUG] Observed getNextToken options from ProxyService:',
JSON.stringify(observedGetNextTokenOptions, null, 2),
);
console.log('[DEBUG] Selected token project_id from TokenManagerService:', tokenProjectId ?? null);
console.log(
'[DEBUG] Runtime captured internal request (from handleAnthropicMessages):',
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 validateRuntimeAnthropicRequestFromRealTokenManager();
console.log('[PASS] Runtime Anthropic request uses project_id from TokenManagerService');
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);
});
+132
View File
@@ -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,134 @@ 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('uses fallback project_id 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(CloudAccountRepo.updateToken).not.toHaveBeenCalled();
expect(selectedToken?.token.project_id).toBe('silver-orbit-5m7qc');
});
it('rehydrates malformed legacy resource-style project_id values', async () => {
const account = {
id: 'acc-4',
provider: 'google',
email: '[email protected]',
token: {
access_token: 'access-token-4',
refresh_token: 'refresh-token-4',
token_type: 'Bearer',
expires_in: 3600,
expiry_timestamp: Math.floor(Date.now() / 1000) + 3600,
project_id: 'projects/',
},
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-4' as never);
const service = new TokenManagerService();
const selectedToken = await service.getNextToken();
expect(GoogleAPIService.fetchProjectId).toHaveBeenCalledWith('access-token-4');
expect(CloudAccountRepo.updateToken).toHaveBeenCalledWith(
'acc-4',
expect.objectContaining({
project_id: 'resolved-project-4',
}),
);
expect(selectedToken?.token.project_id).toBe('resolved-project-4');
});
});
+21 -1
View File
@@ -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'));