Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8e8085ef | ||
|
|
caf9d5849a |
No files matched your search
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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)
|
## [0.9.1](https://github.com/Draculabo/AntigravityManager/compare/v0.9.0...v0.9.1) (2026-02-11)
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.3",
|
"@grpc/grpc-js": "^1.14.3",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "antigravity-manager",
|
"name": "antigravity-manager",
|
||||||
"productName": "Antigravity Manager",
|
"productName": "Antigravity Manager",
|
||||||
"version": "0.9.1",
|
"version": "0.9.2",
|
||||||
"description": "Antigravity Manager - Electron App",
|
"description": "Antigravity Manager - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export class GeminiClient {
|
|||||||
const hasNextEndpoint = index < baseUrls.length - 1;
|
const hasNextEndpoint = index < baseUrls.length - 1;
|
||||||
|
|
||||||
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
|
if (!hasNextEndpoint || !this.shouldSwitchEndpointOnError(error)) {
|
||||||
this.handleAxiosError(error, operation);
|
await this.handleAxiosError(error, operation);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.warn(
|
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)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const responseData = error.response?.data;
|
const responseData = error.response?.data;
|
||||||
const upstreamMessage = this.extractAxiosErrorMessage(responseData);
|
const upstreamMessage = await this.extractAxiosErrorMessage(responseData);
|
||||||
this.logger.error(
|
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);
|
throw new Error(upstreamMessage || error.message);
|
||||||
}
|
}
|
||||||
this.throwAsCleanError(error);
|
this.throwAsCleanError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractAxiosErrorMessage(responseData: unknown): string | null {
|
private async extractAxiosErrorMessage(responseData: unknown): Promise<string | null> {
|
||||||
if (!responseData || typeof responseData !== 'object') {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorRecord = (responseData as { error?: unknown }).error;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = (errorRecord as { message?: unknown }).message;
|
private extractAxiosErrorMessageFromText(rawText: string): string | null {
|
||||||
return typeof message === 'string' && message.trim() !== '' ? message : null;
|
const text = rawText.trim();
|
||||||
|
if (!text) {
|
||||||
|
return 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 {
|
private resolveAxiosProxy(upstreamProxyUrl?: string): AxiosProxyConfig | false | undefined {
|
||||||
|
|||||||
@@ -26,12 +26,19 @@ function normalizeProjectId(projectId: string | null | undefined): string | unde
|
|||||||
return undefined;
|
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;
|
return trimmedProjectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenManagerService implements OnModuleInit {
|
export class TokenManagerService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TokenManagerService.name);
|
private readonly logger = new Logger(TokenManagerService.name);
|
||||||
|
private readonly defaultFallbackProjectId = 'silver-orbit-5m7qc';
|
||||||
private currentIndex = 0;
|
private currentIndex = 0;
|
||||||
private readonly stickySessionTtlMs = 10 * 60 * 1000;
|
private readonly stickySessionTtlMs = 10 * 60 * 1000;
|
||||||
private readonly rateLimitCooldownMs = 5 * 60 * 1000;
|
private readonly rateLimitCooldownMs = 5 * 60 * 1000;
|
||||||
@@ -178,6 +185,8 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
sessionKey?: string,
|
sessionKey?: string,
|
||||||
): Promise<CloudAccount | null> {
|
): Promise<CloudAccount | null> {
|
||||||
try {
|
try {
|
||||||
|
let effectiveProjectId: string | undefined;
|
||||||
|
|
||||||
// Check if token needs refresh (expires in < 5 minutes)
|
// Check if token needs refresh (expires in < 5 minutes)
|
||||||
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
|
if (nowSeconds >= tokenData.expiry_timestamp - 300) {
|
||||||
this.logger.log(`Token for ${tokenData.email} is close to expiry; refreshing...`);
|
this.logger.log(`Token for ${tokenData.email} is close to expiry; refreshing...`);
|
||||||
@@ -199,19 +208,19 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (normalizeProjectId(tokenData.project_id) === undefined) {
|
||||||
normalizeProjectId(tokenData.project_id) === undefined
|
|
||||||
) {
|
|
||||||
tokenData.project_id = undefined;
|
tokenData.project_id = undefined;
|
||||||
}
|
}
|
||||||
|
effectiveProjectId = tokenData.project_id;
|
||||||
|
|
||||||
if (!tokenData.project_id) {
|
if (!effectiveProjectId) {
|
||||||
try {
|
try {
|
||||||
const fetchedProjectId = await GoogleAPIService.fetchProjectId(tokenData.access_token);
|
const fetchedProjectId = await GoogleAPIService.fetchProjectId(tokenData.access_token);
|
||||||
const normalizedProjectId = normalizeProjectId(fetchedProjectId);
|
const normalizedProjectId = normalizeProjectId(fetchedProjectId);
|
||||||
|
|
||||||
if (normalizedProjectId) {
|
if (normalizedProjectId) {
|
||||||
tokenData.project_id = normalizedProjectId;
|
tokenData.project_id = normalizedProjectId;
|
||||||
|
effectiveProjectId = normalizedProjectId;
|
||||||
await this.saveTokenState(accountId, tokenData);
|
await this.saveTokenState(accountId, tokenData);
|
||||||
this.tokens.set(accountId, tokenData);
|
this.tokens.set(accountId, tokenData);
|
||||||
this.logger.log(`Resolved project ID for ${tokenData.email}: ${normalizedProjectId}`);
|
this.logger.log(`Resolved project ID for ${tokenData.email}: ${normalizedProjectId}`);
|
||||||
@@ -225,6 +234,14 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}`);
|
this.logger.log(`Using account: ${tokenData.email}`);
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
this.sessionBindings.set(sessionKey, {
|
this.sessionBindings.set(sessionKey, {
|
||||||
@@ -245,7 +262,7 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
token_type: tokenData.token_type,
|
token_type: tokenData.token_type,
|
||||||
expires_in: tokenData.expires_in,
|
expires_in: tokenData.expires_in,
|
||||||
expiry_timestamp: tokenData.expiry_timestamp,
|
expiry_timestamp: tokenData.expiry_timestamp,
|
||||||
project_id: tokenData.project_id,
|
project_id: effectiveProjectId,
|
||||||
session_id: tokenData.session_id,
|
session_id: tokenData.session_id,
|
||||||
upstream_proxy_url: tokenData.upstream_proxy_url,
|
upstream_proxy_url: tokenData.upstream_proxy_url,
|
||||||
},
|
},
|
||||||
@@ -329,4 +346,13 @@ export class TokenManagerService implements OnModuleInit {
|
|||||||
getAccountCount(): number {
|
getAccountCount(): number {
|
||||||
return this.tokens.size;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,6 @@ import assert from 'node:assert/strict';
|
|||||||
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
|
import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper';
|
||||||
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
import { ProxyService } from '../../server/modules/proxy/proxy.service';
|
||||||
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
import { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
|
||||||
import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
|
|
||||||
import { GoogleAPIService } from '../../services/GoogleAPIService';
|
|
||||||
|
|
||||||
const mockTokenManager: any = {
|
const mockTokenManager: any = {
|
||||||
getNextToken: async () => null,
|
getNextToken: async () => null,
|
||||||
@@ -199,43 +197,12 @@ async function validateRuntimeGeminiRequestPath(): Promise<void> {
|
|||||||
assert.ok(!Object.prototype.hasOwnProperty.call(capturedBody, 'project'));
|
assert.ok(!Object.prototype.hasOwnProperty.call(capturedBody, 'project'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateRuntimeGeminiRequestFromAccountProject(): Promise<void> {
|
async function validateRuntimeAnthropicRequestFromRealTokenManager(): 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;
|
const originalGenerateInternal = mockGeminiClient.generateInternal;
|
||||||
|
|
||||||
let capturedBody: Record<string, unknown> | null = null;
|
let capturedBody: Record<string, unknown> | null = null;
|
||||||
let fetchProjectIdCalled = false;
|
let selectedToken: any = null;
|
||||||
|
let observedGetNextTokenOptions: any = null;
|
||||||
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>) => {
|
mockGeminiClient.generateInternal = async (body: Record<string, unknown>) => {
|
||||||
capturedBody = body;
|
capturedBody = body;
|
||||||
@@ -254,36 +221,63 @@ async function validateRuntimeGeminiRequestFromAccountProject(): Promise<void> {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const tokenManager = new TokenManagerService();
|
const service = new ProxyService(tokenManagerProxy as any, mockGeminiClient as any);
|
||||||
const service = new ProxyService(tokenManager as any, mockGeminiClient as any);
|
await service.handleAnthropicMessages({
|
||||||
await service.handleGeminiGenerateContent('models/gemini-2.5-flash', {
|
model: 'claude-sonnet-4-5',
|
||||||
contents: [{ role: 'user', parts: [{ text: 'hello from account source' }] }],
|
stream: false,
|
||||||
|
max_tokens: 128,
|
||||||
|
metadata: {
|
||||||
|
session_id: 'project-chain-debug-session',
|
||||||
|
},
|
||||||
|
messages: [{ role: 'user', content: 'hello from real token manager chain' }],
|
||||||
} as any);
|
} as any);
|
||||||
} finally {
|
} finally {
|
||||||
cloudAccountRepo.getAccounts = originalGetAccounts;
|
|
||||||
cloudAccountRepo.getAccount = originalGetAccount;
|
|
||||||
cloudAccountRepo.updateToken = originalUpdateToken;
|
|
||||||
googleApiService.fetchProjectId = originalFetchProjectId;
|
|
||||||
mockGeminiClient.generateInternal = originalGenerateInternal;
|
mockGeminiClient.generateInternal = originalGenerateInternal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.ok(selectedToken, 'Expected TokenManagerService.getNextToken to return a token');
|
||||||
assert.ok(capturedBody, 'Expected captured Gemini internal request');
|
assert.ok(capturedBody, 'Expected captured Gemini internal request');
|
||||||
|
|
||||||
const captured = capturedBody as Record<string, unknown>;
|
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(
|
assert.equal(
|
||||||
captured['project'],
|
captured['project'],
|
||||||
sourceProjectId,
|
normalizedTokenProjectId,
|
||||||
'Expected project field to come from account.token.project_id',
|
'Expected project field to come from tokenManager.getNextToken().token.project_id',
|
||||||
);
|
);
|
||||||
assert.equal(
|
} else {
|
||||||
fetchProjectIdCalled,
|
assert.ok(
|
||||||
false,
|
!Object.prototype.hasOwnProperty.call(captured, 'project'),
|
||||||
'fetchProjectId should not be called when account already has project_id',
|
'Expected project field to be omitted when token project_id is unavailable',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[DEBUG] Account source project_id:', sourceProjectId);
|
|
||||||
console.log(
|
console.log(
|
||||||
'[DEBUG] Runtime captured internal request (project from account):',
|
'[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),
|
JSON.stringify(captured, null, 2),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -340,8 +334,8 @@ async function main(): Promise<void> {
|
|||||||
await validateRuntimeGeminiRequestPath();
|
await validateRuntimeGeminiRequestPath();
|
||||||
console.log('[PASS] Runtime Gemini request path omits empty project');
|
console.log('[PASS] Runtime Gemini request path omits empty project');
|
||||||
|
|
||||||
await validateRuntimeGeminiRequestFromAccountProject();
|
await validateRuntimeAnthropicRequestFromRealTokenManager();
|
||||||
console.log('[PASS] Runtime Gemini request uses project_id from account source');
|
console.log('[PASS] Runtime Anthropic request uses project_id from TokenManagerService');
|
||||||
|
|
||||||
if (process.argv.includes('--live')) {
|
if (process.argv.includes('--live')) {
|
||||||
await validateLiveRequests();
|
await validateLiveRequests();
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ describe('TokenManagerService project-id hydration', () => {
|
|||||||
expect(selectedToken?.token.project_id).toBe('existing-project');
|
expect(selectedToken?.token.project_id).toBe('existing-project');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps request usable when project_id cannot be resolved', async () => {
|
it('uses fallback project_id when project_id cannot be resolved', async () => {
|
||||||
const account = {
|
const account = {
|
||||||
id: 'acc-3',
|
id: 'acc-3',
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
@@ -285,6 +285,42 @@ describe('TokenManagerService project-id hydration', () => {
|
|||||||
const selectedToken = await service.getNextToken();
|
const selectedToken = await service.getNextToken();
|
||||||
|
|
||||||
expect(GoogleAPIService.fetchProjectId).toHaveBeenCalledWith('access-token-3');
|
expect(GoogleAPIService.fetchProjectId).toHaveBeenCalledWith('access-token-3');
|
||||||
expect(selectedToken?.token.project_id).toBeUndefined();
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user