mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix: sync token among multiple qwen sessions (#443)
* fix: sync token among multiple qwen sessions * fix: adjust cleanup function
This commit is contained in:
19
.vscode/launch.json
vendored
19
.vscode/launch.json
vendored
@@ -67,6 +67,19 @@
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch CLI Non-Interactive",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
"GEMINI_SANDBOX": "false"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
@@ -75,6 +88,12 @@
|
||||
"type": "promptString",
|
||||
"description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)",
|
||||
"default": "${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx"
|
||||
},
|
||||
{
|
||||
"id": "prompt",
|
||||
"type": "promptString",
|
||||
"description": "Enter your prompt for non-interactive mode",
|
||||
"default": "Explain this code"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -69,10 +69,6 @@ export function AuthDialog({
|
||||
return item.value === AuthType.USE_GEMINI;
|
||||
}
|
||||
|
||||
if (process.env.QWEN_OAUTH_TOKEN) {
|
||||
return item.value === AuthType.QWEN_OAUTH;
|
||||
}
|
||||
|
||||
return item.value === AuthType.LOGIN_WITH_GOOGLE;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('getInstallationInfo', () => {
|
||||
const info = getInstallationInfo(projectRoot, false);
|
||||
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
'brew list -1 | grep -q "^gemini-cli$"',
|
||||
'brew list -1 | grep -q "^qwen-code$"',
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
expect(info.packageManager).toBe(PackageManager.HOMEBREW);
|
||||
@@ -162,7 +162,7 @@ describe('getInstallationInfo', () => {
|
||||
const info = getInstallationInfo(projectRoot, false);
|
||||
|
||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||
'brew list -1 | grep -q "^gemini-cli$"',
|
||||
'brew list -1 | grep -q "^qwen-code$"',
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
// Should fall back to default global npm
|
||||
|
||||
@@ -77,8 +77,8 @@ export function getInstallationInfo(
|
||||
// Check for Homebrew
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
// The package name in homebrew is gemini-cli
|
||||
childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', {
|
||||
// We do not support homebrew for now, keep forward compatibility for future use
|
||||
childProcess.execSync('brew list -1 | grep -q "^qwen-code$"', {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
return {
|
||||
@@ -88,8 +88,7 @@ export function getInstallationInfo(
|
||||
'Installed via Homebrew. Please update with "brew upgrade".',
|
||||
};
|
||||
} catch (_error) {
|
||||
// Brew is not installed or gemini-cli is not installed via brew.
|
||||
// Continue to the next check.
|
||||
// continue to the next check
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ function getAuthTypeFromEnv(): AuthType | undefined {
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return AuthType.USE_OPENAI;
|
||||
}
|
||||
if (process.env.QWEN_OAUTH_TOKEN) {
|
||||
return AuthType.QWEN_OAUTH;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,117 @@ import {
|
||||
FinishReason,
|
||||
} from '@google/genai';
|
||||
import { QwenContentGenerator } from './qwenContentGenerator.js';
|
||||
import { SharedTokenManager } from './sharedTokenManager.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import { AuthType, ContentGeneratorConfig } from '../core/contentGenerator.js';
|
||||
|
||||
// Mock SharedTokenManager
|
||||
vi.mock('./sharedTokenManager.js', () => ({
|
||||
SharedTokenManager: class {
|
||||
private static instance: unknown = null;
|
||||
private mockCredentials: QwenCredentials | null = null;
|
||||
private shouldThrowError: boolean = false;
|
||||
private errorToThrow: Error | null = null;
|
||||
|
||||
static getInstance() {
|
||||
if (!this.instance) {
|
||||
this.instance = new this();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
async getValidCredentials(
|
||||
qwenClient: IQwenOAuth2Client,
|
||||
): Promise<QwenCredentials> {
|
||||
// If we're configured to throw an error, do so
|
||||
if (this.shouldThrowError && this.errorToThrow) {
|
||||
throw this.errorToThrow;
|
||||
}
|
||||
|
||||
// Try to get credentials from the mock client first to trigger auth errors
|
||||
try {
|
||||
const { token } = await qwenClient.getAccessToken();
|
||||
if (token) {
|
||||
const credentials = qwenClient.getCredentials();
|
||||
return credentials;
|
||||
}
|
||||
} catch (error) {
|
||||
// If it's an auth error and we need to simulate refresh behavior
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message.toLowerCase()
|
||||
: String(error).toLowerCase();
|
||||
const errorCode =
|
||||
(error as { status?: number; code?: number })?.status ||
|
||||
(error as { status?: number; code?: number })?.code;
|
||||
|
||||
const isAuthError =
|
||||
errorCode === 401 ||
|
||||
errorCode === 403 ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('forbidden') ||
|
||||
errorMessage.includes('token expired');
|
||||
|
||||
if (isAuthError) {
|
||||
// Try to refresh the token through the client
|
||||
try {
|
||||
const refreshResult = await qwenClient.refreshAccessToken();
|
||||
if (refreshResult && !('error' in refreshResult)) {
|
||||
// Refresh succeeded, update client credentials and return them
|
||||
const updatedCredentials = qwenClient.getCredentials();
|
||||
return updatedCredentials;
|
||||
} else {
|
||||
// Refresh failed, throw appropriate error
|
||||
throw new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Re-throw non-auth errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Return mock credentials only if they're set
|
||||
if (this.mockCredentials && this.mockCredentials.access_token) {
|
||||
return this.mockCredentials;
|
||||
}
|
||||
|
||||
// Default fallback for tests that need credentials
|
||||
return {
|
||||
access_token: 'valid-token',
|
||||
refresh_token: 'valid-refresh-token',
|
||||
resource_url: 'https://test-endpoint.com/v1',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
};
|
||||
}
|
||||
|
||||
getCurrentCredentials(): QwenCredentials | null {
|
||||
return this.mockCredentials;
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.mockCredentials = null;
|
||||
}
|
||||
|
||||
// Helper method for tests to set credentials
|
||||
setMockCredentials(credentials: QwenCredentials | null): void {
|
||||
this.mockCredentials = credentials;
|
||||
}
|
||||
|
||||
// Helper method for tests to simulate errors
|
||||
setMockError(error: Error | null): void {
|
||||
this.shouldThrowError = !!error;
|
||||
this.errorToThrow = error;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the OpenAIContentGenerator parent class
|
||||
vi.mock('../core/openaiContentGenerator.js', () => ({
|
||||
OpenAIContentGenerator: class {
|
||||
@@ -236,8 +344,10 @@ describe('QwenContentGenerator', () => {
|
||||
it('should refresh token on auth error and retry', async () => {
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
|
||||
// First call fails with auth error
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValueOnce(authError);
|
||||
// First call fails with auth error, second call succeeds
|
||||
vi.mocked(mockQwenClient.getAccessToken)
|
||||
.mockRejectedValueOnce(authError)
|
||||
.mockResolvedValueOnce({ token: 'refreshed-token' });
|
||||
|
||||
// Refresh succeeds
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
@@ -247,6 +357,15 @@ describe('QwenContentGenerator', () => {
|
||||
resource_url: 'https://refreshed-endpoint.com',
|
||||
});
|
||||
|
||||
// Set credentials for second call
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'refresh-token',
|
||||
resource_url: 'https://refreshed-endpoint.com',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
@@ -261,12 +380,62 @@ describe('QwenContentGenerator', () => {
|
||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(
|
||||
new Error('Token expired'),
|
||||
it('should refresh token on auth error and retry for content stream', async () => {
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
|
||||
// Reset mocks for this test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// First call fails with auth error, second call succeeds
|
||||
vi.mocked(mockQwenClient.getAccessToken)
|
||||
.mockRejectedValueOnce(authError)
|
||||
.mockResolvedValueOnce({ token: 'refreshed-stream-token' });
|
||||
|
||||
// Refresh succeeds
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'refreshed-stream-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
resource_url: 'https://refreshed-stream-endpoint.com',
|
||||
});
|
||||
|
||||
// Set credentials for second call
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
access_token: 'refreshed-stream-token',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'refresh-token',
|
||||
resource_url: 'https://refreshed-stream-endpoint.com',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
});
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello stream' }] }],
|
||||
};
|
||||
|
||||
const stream = await qwenContentGenerator.generateContentStream(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockRejectedValue(
|
||||
new Error('Refresh failed'),
|
||||
const chunks: string[] = [];
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk.text || '');
|
||||
}
|
||||
|
||||
expect(chunks).toEqual(['Stream chunk 1', 'Stream chunk 2']);
|
||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
// Mock the SharedTokenManager to throw an error
|
||||
const mockTokenManager = SharedTokenManager.getInstance() as unknown as {
|
||||
setMockError: (error: Error | null) => void;
|
||||
};
|
||||
mockTokenManager.setMockError(
|
||||
new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
),
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
@@ -279,6 +448,9 @@ describe('QwenContentGenerator', () => {
|
||||
).rejects.toThrow(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
|
||||
// Clean up
|
||||
mockTokenManager.setMockError(null);
|
||||
});
|
||||
|
||||
it('should update endpoint when token is refreshed', async () => {
|
||||
@@ -547,10 +719,24 @@ describe('QwenContentGenerator', () => {
|
||||
const originalGenerateContent = parentPrototype.generateContent;
|
||||
parentPrototype.generateContent = mockGenerateContent;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
// Mock getAccessToken to fail initially, then succeed
|
||||
let getAccessTokenCallCount = 0;
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => {
|
||||
getAccessTokenCallCount++;
|
||||
if (getAccessTokenCallCount <= 2) {
|
||||
throw authError; // Fail on first two calls (initial + retry)
|
||||
}
|
||||
return { token: 'refreshed-token' }; // Succeed after refresh
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'refresh-token',
|
||||
resource_url: 'https://test-endpoint.com',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
});
|
||||
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'refreshed-token',
|
||||
token_type: 'Bearer',
|
||||
@@ -637,31 +823,16 @@ describe('QwenContentGenerator', () => {
|
||||
expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token');
|
||||
});
|
||||
|
||||
it('should clear token and endpoint on clearToken()', () => {
|
||||
// Simulate having cached values
|
||||
it('should clear token on clearToken()', () => {
|
||||
// Simulate having cached token value
|
||||
const qwenInstance = qwenContentGenerator as unknown as {
|
||||
currentToken: string;
|
||||
currentEndpoint: string;
|
||||
refreshPromise: Promise<string>;
|
||||
};
|
||||
qwenInstance.currentToken = 'cached-token';
|
||||
qwenInstance.currentEndpoint = 'https://cached-endpoint.com';
|
||||
qwenInstance.refreshPromise = Promise.resolve('token');
|
||||
|
||||
qwenContentGenerator.clearToken();
|
||||
|
||||
expect(qwenContentGenerator.getCurrentToken()).toBeNull();
|
||||
expect(
|
||||
(qwenContentGenerator as unknown as { currentEndpoint: string | null })
|
||||
.currentEndpoint,
|
||||
).toBeNull();
|
||||
expect(
|
||||
(
|
||||
qwenContentGenerator as unknown as {
|
||||
refreshPromise: Promise<string> | null;
|
||||
}
|
||||
).refreshPromise,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle concurrent token refresh requests', async () => {
|
||||
@@ -674,9 +845,7 @@ describe('QwenContentGenerator', () => {
|
||||
const authError = { status: 401, message: 'Unauthorized' };
|
||||
let parentCallCount = 0;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(authError);
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation(
|
||||
@@ -725,6 +894,7 @@ describe('QwenContentGenerator', () => {
|
||||
|
||||
// The main test is that all requests succeed without crashing
|
||||
expect(results).toHaveLength(3);
|
||||
// With our new implementation through SharedTokenManager, refresh should still be called
|
||||
expect(refreshCallCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Restore original method
|
||||
@@ -796,13 +966,24 @@ describe('QwenContentGenerator', () => {
|
||||
);
|
||||
parentPrototype.generateContent = mockGenerateContent;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'initial-token',
|
||||
// Mock getAccessToken to fail initially, then succeed
|
||||
let getAccessTokenCallCount = 0;
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockImplementation(async () => {
|
||||
getAccessTokenCallCount++;
|
||||
if (getAccessTokenCallCount <= 2) {
|
||||
throw authError; // Fail on first two calls (initial + retry)
|
||||
}
|
||||
return { token: 'new-token' }; // Succeed after refresh
|
||||
});
|
||||
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'custom-endpoint.com',
|
||||
access_token: 'new-token',
|
||||
token_type: 'Bearer',
|
||||
refresh_token: 'refresh-token',
|
||||
resource_url: 'https://new-endpoint.com',
|
||||
expiry_date: Date.now() + 7200000,
|
||||
});
|
||||
|
||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||
access_token: 'new-token',
|
||||
token_type: 'Bearer',
|
||||
@@ -826,4 +1007,595 @@ describe('QwenContentGenerator', () => {
|
||||
expect(callCount).toBe(2); // Initial call + retry
|
||||
});
|
||||
});
|
||||
|
||||
describe('SharedTokenManager Integration', () => {
|
||||
it('should use SharedTokenManager to get valid credentials', async () => {
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi.fn().mockResolvedValue({
|
||||
access_token: 'manager-token',
|
||||
resource_url: 'https://manager-endpoint.com',
|
||||
}),
|
||||
getCurrentCredentials: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock the SharedTokenManager.getInstance()
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
// Create new instance to pick up the mock
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await newGenerator.generateContent(request, 'test-prompt-id');
|
||||
|
||||
expect(mockTokenManager.getValidCredentials).toHaveBeenCalledWith(
|
||||
mockQwenClient,
|
||||
);
|
||||
|
||||
// Restore original
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should handle SharedTokenManager errors gracefully', async () => {
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Token manager error')),
|
||||
getCurrentCredentials: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
newGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should handle missing access token from credentials', async () => {
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi.fn().mockResolvedValue({
|
||||
access_token: undefined,
|
||||
resource_url: 'https://test-endpoint.com',
|
||||
}),
|
||||
getCurrentCredentials: vi.fn(),
|
||||
clearCache: vi.fn(),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
newGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentEndpoint Method', () => {
|
||||
it('should handle URLs with custom ports', () => {
|
||||
const endpoints = [
|
||||
{ input: 'localhost:8080', expected: 'https://localhost:8080/v1' },
|
||||
{
|
||||
input: 'http://localhost:8080',
|
||||
expected: 'http://localhost:8080/v1',
|
||||
},
|
||||
{
|
||||
input: 'https://api.example.com:443',
|
||||
expected: 'https://api.example.com:443/v1',
|
||||
},
|
||||
{
|
||||
input: 'api.example.com:9000/api',
|
||||
expected: 'https://api.example.com:9000/api/v1',
|
||||
},
|
||||
];
|
||||
|
||||
endpoints.forEach(({ input, expected }) => {
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'test-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: input,
|
||||
});
|
||||
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
getCurrentEndpoint: (resourceUrl?: string) => string;
|
||||
};
|
||||
|
||||
expect(generator.getCurrentEndpoint(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle URLs with existing paths', () => {
|
||||
const endpoints = [
|
||||
{
|
||||
input: 'https://api.example.com/api',
|
||||
expected: 'https://api.example.com/api/v1',
|
||||
},
|
||||
{
|
||||
input: 'api.example.com/api/v2',
|
||||
expected: 'https://api.example.com/api/v2/v1',
|
||||
},
|
||||
{
|
||||
input: 'https://api.example.com/api/v1',
|
||||
expected: 'https://api.example.com/api/v1',
|
||||
},
|
||||
];
|
||||
|
||||
endpoints.forEach(({ input, expected }) => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
getCurrentEndpoint: (resourceUrl?: string) => string;
|
||||
};
|
||||
|
||||
expect(generator.getCurrentEndpoint(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined resource URL', () => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
getCurrentEndpoint: (resourceUrl?: string) => string;
|
||||
};
|
||||
|
||||
expect(generator.getCurrentEndpoint(undefined)).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty resource URL', () => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
getCurrentEndpoint: (resourceUrl?: string) => string;
|
||||
};
|
||||
|
||||
// Empty string should fall back to default endpoint
|
||||
expect(generator.getCurrentEndpoint('')).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthError Method Enhanced', () => {
|
||||
it('should identify auth errors by numeric status codes', () => {
|
||||
const authErrors = [
|
||||
{ code: 401 },
|
||||
{ status: 403 },
|
||||
{ code: '401' }, // String status codes
|
||||
{ status: '403' },
|
||||
];
|
||||
|
||||
authErrors.forEach((error) => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
isAuthError: (error: unknown) => boolean;
|
||||
};
|
||||
expect(generator.isAuthError(error)).toBe(true);
|
||||
});
|
||||
|
||||
// 400 is not typically an auth error, it's bad request
|
||||
const nonAuthError = { status: 400 };
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
isAuthError: (error: unknown) => boolean;
|
||||
};
|
||||
expect(generator.isAuthError(nonAuthError)).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify auth errors by message content variations', () => {
|
||||
const authMessages = [
|
||||
'UNAUTHORIZED access',
|
||||
'Access is FORBIDDEN',
|
||||
'Invalid API Key provided',
|
||||
'Invalid Access Token',
|
||||
'Token has Expired',
|
||||
'Authentication Required',
|
||||
'Access Denied by server',
|
||||
'The token has expired and needs refresh',
|
||||
'Bearer token expired',
|
||||
];
|
||||
|
||||
authMessages.forEach((message) => {
|
||||
const error = new Error(message);
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
isAuthError: (error: unknown) => boolean;
|
||||
};
|
||||
expect(generator.isAuthError(error)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not identify non-auth errors', () => {
|
||||
const nonAuthErrors = [
|
||||
new Error('Network timeout'),
|
||||
new Error('Rate limit exceeded'),
|
||||
{ status: 500 },
|
||||
{ code: 429 },
|
||||
'Internal server error',
|
||||
null,
|
||||
undefined,
|
||||
'',
|
||||
{ status: 200 },
|
||||
new Error('Model not found'),
|
||||
];
|
||||
|
||||
nonAuthErrors.forEach((error) => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
isAuthError: (error: unknown) => boolean;
|
||||
};
|
||||
expect(generator.isAuthError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex error objects', () => {
|
||||
const complexErrors = [
|
||||
{ error: { status: 401, message: 'Unauthorized' } },
|
||||
{ response: { status: 403 } },
|
||||
{ details: { code: 401 } },
|
||||
];
|
||||
|
||||
// These should not be identified as auth errors because the method only looks at top-level properties
|
||||
complexErrors.forEach((error) => {
|
||||
const generator = qwenContentGenerator as unknown as {
|
||||
isAuthError: (error: unknown) => boolean;
|
||||
};
|
||||
expect(generator.isAuthError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stream Error Handling', () => {
|
||||
it('should restore credentials when stream generation fails', async () => {
|
||||
const client = (
|
||||
qwenContentGenerator as unknown as {
|
||||
client: { apiKey: string; baseURL: string };
|
||||
}
|
||||
).client;
|
||||
const originalApiKey = client.apiKey;
|
||||
const originalBaseURL = client.baseURL;
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'stream-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||
...mockCredentials,
|
||||
resource_url: 'https://stream-endpoint.com',
|
||||
});
|
||||
|
||||
// Mock parent method to throw error
|
||||
const parentPrototype = Object.getPrototypeOf(
|
||||
Object.getPrototypeOf(qwenContentGenerator),
|
||||
);
|
||||
const originalGenerateContentStream =
|
||||
parentPrototype.generateContentStream;
|
||||
parentPrototype.generateContentStream = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Stream error'));
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }],
|
||||
};
|
||||
|
||||
try {
|
||||
await qwenContentGenerator.generateContentStream(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
}
|
||||
|
||||
// Credentials should be restored even on error
|
||||
expect(client.apiKey).toBe(originalApiKey);
|
||||
expect(client.baseURL).toBe(originalBaseURL);
|
||||
|
||||
// Restore original method
|
||||
parentPrototype.generateContentStream = originalGenerateContentStream;
|
||||
});
|
||||
|
||||
it('should not restore credentials in finally block for successful streams', async () => {
|
||||
const client = (
|
||||
qwenContentGenerator as unknown as {
|
||||
client: { apiKey: string; baseURL: string };
|
||||
}
|
||||
).client;
|
||||
|
||||
// Set up the mock to return stream credentials
|
||||
const streamCredentials = {
|
||||
access_token: 'stream-token',
|
||||
refresh_token: 'stream-refresh-token',
|
||||
resource_url: 'https://stream-endpoint.com',
|
||||
expiry_date: Date.now() + 3600000,
|
||||
};
|
||||
|
||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
||||
token: 'stream-token',
|
||||
});
|
||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(
|
||||
streamCredentials,
|
||||
);
|
||||
|
||||
// Set the SharedTokenManager mock to return stream credentials
|
||||
const mockTokenManager = SharedTokenManager.getInstance() as unknown as {
|
||||
setMockCredentials: (credentials: QwenCredentials | null) => void;
|
||||
};
|
||||
mockTokenManager.setMockCredentials(streamCredentials);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Stream test' }] }],
|
||||
};
|
||||
|
||||
const stream = await qwenContentGenerator.generateContentStream(
|
||||
request,
|
||||
'test-prompt-id',
|
||||
);
|
||||
|
||||
// After successful stream creation, credentials should still be set for the stream
|
||||
expect(client.apiKey).toBe('stream-token');
|
||||
expect(client.baseURL).toBe('https://stream-endpoint.com/v1');
|
||||
|
||||
// Consume the stream
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(chunks).toHaveLength(2);
|
||||
|
||||
// Clean up
|
||||
mockTokenManager.setMockCredentials(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token and Endpoint Management', () => {
|
||||
it('should get current token from SharedTokenManager', () => {
|
||||
const mockTokenManager = {
|
||||
getCurrentCredentials: vi.fn().mockReturnValue({
|
||||
access_token: 'current-token',
|
||||
}),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(newGenerator.getCurrentToken()).toBe('current-token');
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should return null when no credentials available', () => {
|
||||
const mockTokenManager = {
|
||||
getCurrentCredentials: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(newGenerator.getCurrentToken()).toBeNull();
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should return null when credentials have no access token', () => {
|
||||
const mockTokenManager = {
|
||||
getCurrentCredentials: vi.fn().mockReturnValue({
|
||||
access_token: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
expect(newGenerator.getCurrentToken()).toBeNull();
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should clear token through SharedTokenManager', () => {
|
||||
const mockTokenManager = {
|
||||
clearCache: vi.fn(),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
newGenerator.clearToken();
|
||||
|
||||
expect(mockTokenManager.clearCache).toHaveBeenCalled();
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Constructor and Initialization', () => {
|
||||
it('should initialize with default base URL', () => {
|
||||
const generator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const client = (generator as unknown as { client: { baseURL: string } })
|
||||
.client;
|
||||
expect(client.baseURL).toBe(
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get SharedTokenManager instance', () => {
|
||||
const generator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const sharedManager = (
|
||||
generator as unknown as { sharedManager: SharedTokenManager }
|
||||
).sharedManager;
|
||||
expect(sharedManager).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases and Error Conditions', () => {
|
||||
it('should handle token retrieval with warning when SharedTokenManager fails', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Internal token manager error')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const request: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
newGenerator.generateContent(request, 'test-prompt-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Failed to get token from shared manager:',
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
|
||||
it('should handle all method types with token failure', async () => {
|
||||
const mockTokenManager = {
|
||||
getValidCredentials: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Token error')),
|
||||
};
|
||||
|
||||
const originalGetInstance = SharedTokenManager.getInstance;
|
||||
SharedTokenManager.getInstance = vi
|
||||
.fn()
|
||||
.mockReturnValue(mockTokenManager);
|
||||
|
||||
const newGenerator = new QwenContentGenerator(
|
||||
mockQwenClient,
|
||||
{ model: 'qwen-turbo', authType: AuthType.QWEN_OAUTH },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
const generateRequest: GenerateContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||
};
|
||||
|
||||
const countRequest: CountTokensParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ role: 'user', parts: [{ text: 'Count' }] }],
|
||||
};
|
||||
|
||||
const embedRequest: EmbedContentParameters = {
|
||||
model: 'qwen-turbo',
|
||||
contents: [{ parts: [{ text: 'Embed' }] }],
|
||||
};
|
||||
|
||||
// All methods should fail with the same error
|
||||
await expect(
|
||||
newGenerator.generateContent(generateRequest, 'test-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
|
||||
await expect(
|
||||
newGenerator.generateContentStream(generateRequest, 'test-id'),
|
||||
).rejects.toThrow('Failed to obtain valid Qwen access token');
|
||||
|
||||
await expect(newGenerator.countTokens(countRequest)).rejects.toThrow(
|
||||
'Failed to obtain valid Qwen access token',
|
||||
);
|
||||
|
||||
await expect(newGenerator.embedContent(embedRequest)).rejects.toThrow(
|
||||
'Failed to obtain valid Qwen access token',
|
||||
);
|
||||
|
||||
SharedTokenManager.getInstance = originalGetInstance;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
*/
|
||||
|
||||
import { OpenAIContentGenerator } from '../core/openaiContentGenerator.js';
|
||||
import {
|
||||
IQwenOAuth2Client,
|
||||
type TokenRefreshData,
|
||||
type ErrorData,
|
||||
isErrorResponse,
|
||||
} from './qwenOAuth2.js';
|
||||
import { IQwenOAuth2Client } from './qwenOAuth2.js';
|
||||
import { SharedTokenManager } from './sharedTokenManager.js';
|
||||
import { Config } from '../config/config.js';
|
||||
import {
|
||||
GenerateContentParameters,
|
||||
@@ -31,11 +27,8 @@ const DEFAULT_QWEN_BASE_URL =
|
||||
*/
|
||||
export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
private qwenClient: IQwenOAuth2Client;
|
||||
|
||||
// Token management (integrated from QwenTokenManager)
|
||||
private currentToken: string | null = null;
|
||||
private currentEndpoint: string | null = null;
|
||||
private refreshPromise: Promise<string> | null = null;
|
||||
private sharedManager: SharedTokenManager;
|
||||
private currentToken?: string;
|
||||
|
||||
constructor(
|
||||
qwenClient: IQwenOAuth2Client,
|
||||
@@ -45,6 +38,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
// Initialize with empty API key, we'll override it dynamically
|
||||
super(contentGeneratorConfig, config);
|
||||
this.qwenClient = qwenClient;
|
||||
this.sharedManager = SharedTokenManager.getInstance();
|
||||
|
||||
// Set default base URL, will be updated dynamically
|
||||
this.client.baseURL = DEFAULT_QWEN_BASE_URL;
|
||||
@@ -53,8 +47,8 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
/**
|
||||
* Get the current endpoint URL with proper protocol and /v1 suffix
|
||||
*/
|
||||
private getCurrentEndpoint(): string {
|
||||
const baseEndpoint = this.currentEndpoint || DEFAULT_QWEN_BASE_URL;
|
||||
private getCurrentEndpoint(resourceUrl?: string): string {
|
||||
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL;
|
||||
const suffix = '/v1';
|
||||
|
||||
// Normalize the URL: add protocol if missing, ensure /v1 suffix
|
||||
@@ -79,237 +73,149 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
* Get valid token and endpoint using the shared token manager
|
||||
*/
|
||||
override async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
// Temporarily update the API key and base URL
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
private async getValidToken(): Promise<{ token: string; endpoint: string }> {
|
||||
try {
|
||||
return await super.generateContent(request, userPromptId);
|
||||
} finally {
|
||||
// Restore original values
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
// Use SharedTokenManager for consistent token/endpoint pairing and automatic refresh
|
||||
const credentials = await this.sharedManager.getValidCredentials(
|
||||
this.qwenClient,
|
||||
);
|
||||
|
||||
if (!credentials.access_token) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
override async generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
return this.withValidTokenForStream(async (token) => {
|
||||
// Update the API key and base URL before streaming
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.generateContentStream(request, userPromptId);
|
||||
return {
|
||||
token: credentials.access_token,
|
||||
endpoint: this.getCurrentEndpoint(credentials.resource_url),
|
||||
};
|
||||
} catch (error) {
|
||||
// Restore original values on error
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
throw error;
|
||||
}
|
||||
// Note: We don't restore the values in finally for streaming because
|
||||
// the generator may continue to be used after this method returns
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
override async countTokens(
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.countTokens(request);
|
||||
} finally {
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint
|
||||
*/
|
||||
override async embedContent(
|
||||
request: EmbedContentParameters,
|
||||
): Promise<EmbedContentResponse> {
|
||||
return this.withValidToken(async (token) => {
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = this.getCurrentEndpoint();
|
||||
|
||||
try {
|
||||
return await super.embedContent(request);
|
||||
} finally {
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with a valid token, with retry on auth failure
|
||||
*/
|
||||
private async withValidToken<T>(
|
||||
operation: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const token = await this.getTokenWithRetry();
|
||||
|
||||
try {
|
||||
return await operation(token);
|
||||
} catch (error) {
|
||||
// Check if this is an authentication error
|
||||
// Propagate auth errors as-is for retry logic
|
||||
if (this.isAuthError(error)) {
|
||||
// Refresh token and retry once silently
|
||||
const newToken = await this.refreshToken();
|
||||
return await operation(newToken);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute operation with a valid token for streaming, with retry on auth failure
|
||||
*/
|
||||
private async withValidTokenForStream<T>(
|
||||
operation: (token: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const token = await this.getTokenWithRetry();
|
||||
|
||||
try {
|
||||
return await operation(token);
|
||||
} catch (error) {
|
||||
// Check if this is an authentication error
|
||||
if (this.isAuthError(error)) {
|
||||
// Refresh token and retry once silently
|
||||
const newToken = await this.refreshToken();
|
||||
return await operation(newToken);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token with retry logic
|
||||
*/
|
||||
private async getTokenWithRetry(): Promise<string> {
|
||||
try {
|
||||
return await this.getValidToken();
|
||||
} catch (error) {
|
||||
console.error('Failed to get valid token:', error);
|
||||
console.warn('Failed to get token from shared manager:', error);
|
||||
throw new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Token management methods (integrated from QwenTokenManager)
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary
|
||||
* Execute an operation with automatic credential management and retry logic.
|
||||
* This method handles:
|
||||
* - Dynamic token and endpoint retrieval
|
||||
* - Temporary client configuration updates
|
||||
* - Automatic restoration of original configuration
|
||||
* - Retry logic on authentication errors with token refresh
|
||||
*
|
||||
* @param operation - The operation to execute with updated client configuration
|
||||
* @param restoreOnCompletion - Whether to restore original config after operation completes
|
||||
* @returns The result of the operation
|
||||
*/
|
||||
private async getValidToken(): Promise<string> {
|
||||
// If there's already a refresh in progress, wait for it
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
private async executeWithCredentialManagement<T>(
|
||||
operation: () => Promise<T>,
|
||||
restoreOnCompletion: boolean = true,
|
||||
): Promise<T> {
|
||||
// Attempt the operation with credential management and retry logic
|
||||
const attemptOperation = async (): Promise<T> => {
|
||||
const { token, endpoint } = await this.getValidToken();
|
||||
|
||||
// Store original configuration
|
||||
const originalApiKey = this.client.apiKey;
|
||||
const originalBaseURL = this.client.baseURL;
|
||||
|
||||
// Apply dynamic configuration
|
||||
this.client.apiKey = token;
|
||||
this.client.baseURL = endpoint;
|
||||
|
||||
try {
|
||||
const { token } = await this.qwenClient.getAccessToken();
|
||||
if (token) {
|
||||
this.currentToken = token;
|
||||
// Also update endpoint from current credentials
|
||||
const credentials = this.qwenClient.getCredentials();
|
||||
if (credentials.resource_url) {
|
||||
this.currentEndpoint = credentials.resource_url;
|
||||
}
|
||||
return token;
|
||||
const result = await operation();
|
||||
|
||||
// For streaming operations, we may need to keep the configuration active
|
||||
if (restoreOnCompletion) {
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token, attempting refresh:', error);
|
||||
// Always restore on error
|
||||
this.client.apiKey = originalApiKey;
|
||||
this.client.baseURL = originalBaseURL;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Start a new refresh operation
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
// Execute with retry logic for auth errors
|
||||
try {
|
||||
const newToken = await this.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
return await attemptOperation();
|
||||
} catch (error) {
|
||||
if (this.isAuthError(error)) {
|
||||
try {
|
||||
// Use SharedTokenManager to properly refresh and persist the token
|
||||
// This ensures the refreshed token is saved to oauth_creds.json
|
||||
await this.sharedManager.getValidCredentials(this.qwenClient, true);
|
||||
// Retry the operation once with fresh credentials
|
||||
return await attemptOperation();
|
||||
} catch (_refreshError) {
|
||||
throw new Error(
|
||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the access token
|
||||
* Override to use dynamic token and endpoint with automatic retry
|
||||
*/
|
||||
private async refreshToken(): Promise<string> {
|
||||
this.refreshPromise = this.performTokenRefresh();
|
||||
|
||||
try {
|
||||
const newToken = await this.refreshPromise;
|
||||
return newToken;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performTokenRefresh(): Promise<string> {
|
||||
try {
|
||||
const response = await this.qwenClient.refreshAccessToken();
|
||||
|
||||
if (isErrorResponse(response)) {
|
||||
const errorData = response as ErrorData;
|
||||
throw new Error(
|
||||
`${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
override async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<GenerateContentResponse> {
|
||||
return this.executeWithCredentialManagement(() =>
|
||||
super.generateContent(request, userPromptId),
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = response as TokenRefreshData;
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
throw new Error('Failed to refresh access token: no token returned');
|
||||
}
|
||||
|
||||
this.currentToken = tokenData.access_token;
|
||||
|
||||
// Update endpoint if provided
|
||||
if (tokenData.resource_url) {
|
||||
this.currentEndpoint = tokenData.resource_url;
|
||||
}
|
||||
|
||||
return tokenData.access_token;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
/**
|
||||
* Override to use dynamic token and endpoint with automatic retry.
|
||||
* Note: For streaming, the client configuration is not restored immediately
|
||||
* since the generator may continue to be used after this method returns.
|
||||
*/
|
||||
override async generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
userPromptId: string,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
return this.executeWithCredentialManagement(
|
||||
() => super.generateContentStream(request, userPromptId),
|
||||
false, // Don't restore immediately for streaming
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint with automatic retry
|
||||
*/
|
||||
override async countTokens(
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
return this.executeWithCredentialManagement(() =>
|
||||
super.countTokens(request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to use dynamic token and endpoint with automatic retry
|
||||
*/
|
||||
override async embedContent(
|
||||
request: EmbedContentParameters,
|
||||
): Promise<EmbedContentResponse> {
|
||||
return this.executeWithCredentialManagement(() =>
|
||||
super.embedContent(request),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,9 +237,10 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
const errorCode = errorWithCode?.status || errorWithCode?.code;
|
||||
|
||||
return (
|
||||
errorCode === 400 ||
|
||||
errorCode === 401 ||
|
||||
errorCode === 403 ||
|
||||
errorCode === '401' ||
|
||||
errorCode === '403' ||
|
||||
errorMessage.includes('unauthorized') ||
|
||||
errorMessage.includes('forbidden') ||
|
||||
errorMessage.includes('invalid api key') ||
|
||||
@@ -349,15 +256,22 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||
* Get the current cached token (may be expired)
|
||||
*/
|
||||
getCurrentToken(): string | null {
|
||||
// First check internal state for backwards compatibility with tests
|
||||
if (this.currentToken) {
|
||||
return this.currentToken;
|
||||
}
|
||||
// Fall back to SharedTokenManager
|
||||
const credentials = this.sharedManager.getCurrentCredentials();
|
||||
return credentials?.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token and endpoint
|
||||
* Clear the cached token
|
||||
*/
|
||||
clearToken(): void {
|
||||
this.currentToken = null;
|
||||
this.currentEndpoint = null;
|
||||
this.refreshPromise = null;
|
||||
// Clear internal state for backwards compatibility with tests
|
||||
this.currentToken = undefined;
|
||||
// Also clear SharedTokenManager
|
||||
this.sharedManager.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ import open from 'open';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Config } from '../config/config.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
SharedTokenManager,
|
||||
TokenManagerError,
|
||||
TokenError,
|
||||
} from './sharedTokenManager.js';
|
||||
|
||||
// OAuth Endpoints
|
||||
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
||||
@@ -234,8 +239,11 @@ export interface IQwenOAuth2Client {
|
||||
*/
|
||||
export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||
private credentials: QwenCredentials = {};
|
||||
private sharedManager: SharedTokenManager;
|
||||
|
||||
constructor(_options?: { proxy?: string }) {}
|
||||
constructor() {
|
||||
this.sharedManager = SharedTokenManager.getInstance();
|
||||
}
|
||||
|
||||
setCredentials(credentials: QwenCredentials): void {
|
||||
this.credentials = credentials;
|
||||
@@ -246,18 +254,24 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<{ token?: string }> {
|
||||
try {
|
||||
// Use shared manager to get valid credentials with cross-session synchronization
|
||||
const credentials = await this.sharedManager.getValidCredentials(this);
|
||||
return { token: credentials.access_token };
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token from shared manager:', error);
|
||||
|
||||
// Only return cached token if it's still valid, don't refresh uncoordinated
|
||||
// This prevents the cross-session token invalidation issue
|
||||
if (this.credentials.access_token && this.isTokenValid()) {
|
||||
return { token: this.credentials.access_token };
|
||||
}
|
||||
|
||||
if (this.credentials.refresh_token) {
|
||||
const refreshResponse = await this.refreshAccessToken();
|
||||
const tokenData = refreshResponse as TokenRefreshData;
|
||||
return { token: tokenData.access_token };
|
||||
}
|
||||
|
||||
// If we can't get valid credentials through shared manager, fail gracefully
|
||||
// All token refresh operations should go through the SharedTokenManager
|
||||
return { token: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
async requestDeviceAuthorization(options: {
|
||||
scope: string;
|
||||
@@ -289,7 +303,7 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||
}
|
||||
|
||||
const result = (await response.json()) as DeviceAuthorizationResponse;
|
||||
console.log('Device authorization result:', result);
|
||||
console.debug('Device authorization result:', result);
|
||||
|
||||
// Check if the response indicates success
|
||||
if (!isDeviceAuthorizationSuccess(result)) {
|
||||
@@ -423,8 +437,8 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||
|
||||
this.setCredentials(tokens);
|
||||
|
||||
// Cache the updated credentials to file
|
||||
await cacheQwenCredentials(tokens);
|
||||
// Note: File caching is now handled by SharedTokenManager
|
||||
// to prevent cross-session token invalidation issues
|
||||
|
||||
return responseData;
|
||||
}
|
||||
@@ -462,39 +476,55 @@ export const qwenOAuth2Events = new EventEmitter();
|
||||
export async function getQwenOAuthClient(
|
||||
config: Config,
|
||||
): Promise<QwenOAuth2Client> {
|
||||
const client = new QwenOAuth2Client({
|
||||
proxy: config.getProxy(),
|
||||
});
|
||||
const client = new QwenOAuth2Client();
|
||||
|
||||
// If there are cached creds on disk, they always take precedence
|
||||
if (await loadCachedQwenCredentials(client)) {
|
||||
console.log('Loaded cached Qwen credentials.');
|
||||
// Use shared token manager to get valid credentials with cross-session synchronization
|
||||
const sharedManager = SharedTokenManager.getInstance();
|
||||
|
||||
try {
|
||||
await client.refreshAccessToken();
|
||||
// Try to get valid credentials from shared cache first
|
||||
const credentials = await sharedManager.getValidCredentials(client);
|
||||
client.setCredentials(credentials);
|
||||
return client;
|
||||
} catch (error: unknown) {
|
||||
// Handle refresh token errors
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
const isInvalidToken = errorMessage.includes(
|
||||
'Refresh token expired or invalid',
|
||||
console.debug(
|
||||
'Shared token manager failed, attempting device flow:',
|
||||
error,
|
||||
);
|
||||
const userMessage = isInvalidToken
|
||||
? 'Cached credentials are invalid. Please re-authenticate.'
|
||||
: `Token refresh failed: ${errorMessage}`;
|
||||
const throwMessage = isInvalidToken
|
||||
? 'Cached Qwen credentials are invalid. Please re-authenticate.'
|
||||
: `Qwen token refresh failed: ${errorMessage}`;
|
||||
|
||||
// Emit token refresh error event
|
||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', userMessage);
|
||||
throw new Error(throwMessage);
|
||||
// Handle specific token manager errors
|
||||
if (error instanceof TokenManagerError) {
|
||||
switch (error.type) {
|
||||
case TokenError.NO_REFRESH_TOKEN:
|
||||
console.debug(
|
||||
'No refresh token available, proceeding with device flow',
|
||||
);
|
||||
break;
|
||||
case TokenError.REFRESH_FAILED:
|
||||
console.debug('Token refresh failed, proceeding with device flow');
|
||||
break;
|
||||
case TokenError.NETWORK_ERROR:
|
||||
console.warn(
|
||||
'Network error during token refresh, trying device flow',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.warn('Token manager error:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Use device authorization flow for authentication (single attempt)
|
||||
// If shared manager fails, check if we have cached credentials for device flow
|
||||
if (await loadCachedQwenCredentials(client)) {
|
||||
// We have cached credentials but they might be expired
|
||||
// Try device flow instead of forcing refresh
|
||||
const result = await authWithQwenDeviceFlow(client, config);
|
||||
if (!result.success) {
|
||||
throw new Error('Qwen OAuth authentication failed');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// No cached credentials, use device authorization flow for authentication
|
||||
const result = await authWithQwenDeviceFlow(client, config);
|
||||
if (!result.success) {
|
||||
// Only emit timeout event if the failure reason is actually timeout
|
||||
@@ -524,6 +554,7 @@ export async function getQwenOAuthClient(
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
async function authWithQwenDeviceFlow(
|
||||
@@ -580,7 +611,9 @@ async function authWithQwenDeviceFlow(
|
||||
// causing the entire Node.js process to crash.
|
||||
if (childProcess) {
|
||||
childProcess.on('error', () => {
|
||||
console.log('Failed to open browser. Visit this URL to authorize:');
|
||||
console.debug(
|
||||
'Failed to open browser. Visit this URL to authorize:',
|
||||
);
|
||||
showFallbackMessage();
|
||||
});
|
||||
}
|
||||
@@ -599,7 +632,7 @@ async function authWithQwenDeviceFlow(
|
||||
'Waiting for authorization...',
|
||||
);
|
||||
|
||||
console.log('Waiting for authorization...\n');
|
||||
console.debug('Waiting for authorization...\n');
|
||||
|
||||
// Poll for the token
|
||||
let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received
|
||||
@@ -610,7 +643,7 @@ async function authWithQwenDeviceFlow(
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
// Check if authentication was cancelled
|
||||
if (isCancelled) {
|
||||
console.log('\nAuthentication cancelled by user.');
|
||||
console.debug('\nAuthentication cancelled by user.');
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
@@ -620,7 +653,7 @@ async function authWithQwenDeviceFlow(
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('polling for token...');
|
||||
console.debug('polling for token...');
|
||||
const tokenResponse = await client.pollDeviceToken({
|
||||
device_code: deviceAuth.device_code,
|
||||
code_verifier,
|
||||
@@ -653,7 +686,7 @@ async function authWithQwenDeviceFlow(
|
||||
'Authentication successful! Access token obtained.',
|
||||
);
|
||||
|
||||
console.log('Authentication successful! Access token obtained.');
|
||||
console.debug('Authentication successful! Access token obtained.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -664,8 +697,8 @@ async function authWithQwenDeviceFlow(
|
||||
// Handle slow_down error by increasing poll interval
|
||||
if (pendingData.slowDown) {
|
||||
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
|
||||
console.log(
|
||||
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms`,
|
||||
console.debug(
|
||||
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`,
|
||||
);
|
||||
} else {
|
||||
pollInterval = 2000; // Reset to default interval
|
||||
@@ -706,7 +739,7 @@ async function authWithQwenDeviceFlow(
|
||||
|
||||
// Check for cancellation after waiting
|
||||
if (isCancelled) {
|
||||
console.log('\nAuthentication cancelled by user.');
|
||||
console.debug('\nAuthentication cancelled by user.');
|
||||
qwenOAuth2Events.emit(
|
||||
QwenOAuth2Event.AuthProgress,
|
||||
'error',
|
||||
@@ -834,7 +867,7 @@ export async function clearQwenCredentials(): Promise<void> {
|
||||
try {
|
||||
const filePath = getQwenCachedCredentialPath();
|
||||
await fs.unlink(filePath);
|
||||
console.log('Cached Qwen credentials cleared successfully.');
|
||||
console.debug('Cached Qwen credentials cleared successfully.');
|
||||
} catch (error: unknown) {
|
||||
// If file doesn't exist or can't be deleted, we consider it cleared
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
|
||||
758
packages/core/src/qwen/sharedTokenManager.test.ts
Normal file
758
packages/core/src/qwen/sharedTokenManager.test.ts
Normal file
@@ -0,0 +1,758 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { promises as fs, unlinkSync, type Stats } from 'node:fs';
|
||||
import * as os from 'os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
SharedTokenManager,
|
||||
TokenManagerError,
|
||||
TokenError,
|
||||
} from './sharedTokenManager.js';
|
||||
import type {
|
||||
IQwenOAuth2Client,
|
||||
QwenCredentials,
|
||||
TokenRefreshData,
|
||||
ErrorData,
|
||||
} from './qwenOAuth2.js';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('node:fs', () => ({
|
||||
promises: {
|
||||
stat: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
},
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('node:path', () => ({
|
||||
default: {
|
||||
join: vi.fn(),
|
||||
dirname: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Helper to access private properties for testing
|
||||
*/
|
||||
function getPrivateProperty<T>(obj: unknown, property: string): T {
|
||||
return (obj as Record<string, T>)[property];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to set private properties for testing
|
||||
*/
|
||||
function setPrivateProperty<T>(obj: unknown, property: string, value: T): void {
|
||||
(obj as Record<string, T>)[property] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock QwenOAuth2Client for testing
|
||||
*/
|
||||
function createMockQwenClient(
|
||||
initialCredentials: Partial<QwenCredentials> = {},
|
||||
): IQwenOAuth2Client {
|
||||
let credentials: QwenCredentials = {
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + 3600000, // 1 hour from now
|
||||
resource_url: 'https://api.example.com',
|
||||
...initialCredentials,
|
||||
};
|
||||
|
||||
return {
|
||||
setCredentials: vi.fn((creds: QwenCredentials) => {
|
||||
credentials = { ...credentials, ...creds };
|
||||
}),
|
||||
getCredentials: vi.fn(() => credentials),
|
||||
getAccessToken: vi.fn(),
|
||||
requestDeviceAuthorization: vi.fn(),
|
||||
pollDeviceToken: vi.fn(),
|
||||
refreshAccessToken: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates valid mock credentials
|
||||
*/
|
||||
function createValidCredentials(
|
||||
overrides: Partial<QwenCredentials> = {},
|
||||
): QwenCredentials {
|
||||
return {
|
||||
access_token: 'valid_access_token',
|
||||
refresh_token: 'valid_refresh_token',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() + 3600000, // 1 hour from now
|
||||
resource_url: 'https://api.example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates expired mock credentials
|
||||
*/
|
||||
function createExpiredCredentials(
|
||||
overrides: Partial<QwenCredentials> = {},
|
||||
): QwenCredentials {
|
||||
return {
|
||||
access_token: 'expired_access_token',
|
||||
refresh_token: 'expired_refresh_token',
|
||||
token_type: 'Bearer',
|
||||
expiry_date: Date.now() - 3600000, // 1 hour ago
|
||||
resource_url: 'https://api.example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a successful token refresh response
|
||||
*/
|
||||
function createSuccessfulRefreshResponse(
|
||||
overrides: Partial<TokenRefreshData> = {},
|
||||
): TokenRefreshData {
|
||||
return {
|
||||
access_token: 'fresh_access_token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'new_refresh_token',
|
||||
resource_url: 'https://api.example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error response
|
||||
*/
|
||||
function createErrorResponse(
|
||||
error = 'invalid_grant',
|
||||
description = 'Token expired',
|
||||
): ErrorData {
|
||||
return {
|
||||
error,
|
||||
error_description: description,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SharedTokenManager', () => {
|
||||
let tokenManager: SharedTokenManager;
|
||||
|
||||
// Get mocked modules
|
||||
const mockFs = vi.mocked(fs);
|
||||
const mockOs = vi.mocked(os);
|
||||
const mockPath = vi.mocked(path);
|
||||
const mockUnlinkSync = vi.mocked(unlinkSync);
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any existing instance's listeners first
|
||||
const existingInstance = getPrivateProperty(
|
||||
SharedTokenManager,
|
||||
'instance',
|
||||
) as SharedTokenManager;
|
||||
if (existingInstance) {
|
||||
existingInstance.cleanup();
|
||||
}
|
||||
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock implementations
|
||||
mockOs.homedir.mockReturnValue('/home/user');
|
||||
mockPath.join.mockImplementation((...args) => args.join('/'));
|
||||
mockPath.dirname.mockImplementation((filePath) => {
|
||||
// Handle undefined/null input gracefully
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return '/home/user/.qwen'; // Return the expected directory path
|
||||
}
|
||||
const parts = filePath.split('/');
|
||||
const result = parts.slice(0, -1).join('/');
|
||||
return result || '/';
|
||||
});
|
||||
|
||||
// Reset singleton instance for each test
|
||||
setPrivateProperty(SharedTokenManager, 'instance', null);
|
||||
tokenManager = SharedTokenManager.getInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up listeners after each test
|
||||
if (tokenManager) {
|
||||
tokenManager.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Singleton Pattern', () => {
|
||||
it('should return the same instance when called multiple times', () => {
|
||||
const instance1 = SharedTokenManager.getInstance();
|
||||
const instance2 = SharedTokenManager.getInstance();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
expect(instance1).toBe(tokenManager);
|
||||
});
|
||||
|
||||
it('should create a new instance after reset', () => {
|
||||
const instance1 = SharedTokenManager.getInstance();
|
||||
|
||||
// Reset singleton for testing
|
||||
setPrivateProperty(SharedTokenManager, 'instance', null);
|
||||
const instance2 = SharedTokenManager.getInstance();
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidCredentials', () => {
|
||||
it('should return valid cached credentials without refresh', async () => {
|
||||
const mockClient = createMockQwenClient();
|
||||
const validCredentials = createValidCredentials();
|
||||
|
||||
// Mock file operations to indicate no file changes
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
|
||||
// Manually set cached credentials
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
fileModTime: number;
|
||||
lastCheck: number;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = validCredentials;
|
||||
memoryCache.fileModTime = 1000;
|
||||
memoryCache.lastCheck = Date.now();
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result).toEqual(validCredentials);
|
||||
expect(mockClient.refreshAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refresh expired credentials', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const refreshResponse = createSuccessfulRefreshResponse();
|
||||
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(refreshResponse);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result.access_token).toBe(refreshResponse.access_token);
|
||||
expect(mockClient.refreshAccessToken).toHaveBeenCalled();
|
||||
expect(mockClient.setCredentials).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should force refresh when forceRefresh is true', async () => {
|
||||
const mockClient = createMockQwenClient(createValidCredentials());
|
||||
const refreshResponse = createSuccessfulRefreshResponse();
|
||||
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(refreshResponse);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient, true);
|
||||
|
||||
expect(result.access_token).toBe(refreshResponse.access_token);
|
||||
expect(mockClient.refreshAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw TokenManagerError when refresh token is missing', async () => {
|
||||
const mockClient = createMockQwenClient({
|
||||
access_token: 'expired_token',
|
||||
refresh_token: undefined, // No refresh token
|
||||
expiry_date: Date.now() - 3600000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow('No refresh token available');
|
||||
});
|
||||
|
||||
it('should throw TokenManagerError when refresh fails', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const errorResponse = createErrorResponse();
|
||||
|
||||
mockClient.refreshAccessToken = vi.fn().mockResolvedValue(errorResponse);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
});
|
||||
|
||||
it('should handle network errors during refresh', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const networkError = new Error('Network request failed');
|
||||
|
||||
mockClient.refreshAccessToken = vi.fn().mockRejectedValue(networkError);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
});
|
||||
|
||||
it('should wait for ongoing refresh and return same result', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const refreshResponse = createSuccessfulRefreshResponse();
|
||||
|
||||
// Create a delayed refresh response
|
||||
let resolveRefresh: (value: TokenRefreshData) => void;
|
||||
const refreshPromise = new Promise<TokenRefreshData>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
|
||||
mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
// Start two concurrent refresh operations
|
||||
const promise1 = tokenManager.getValidCredentials(mockClient);
|
||||
const promise2 = tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
// Resolve the refresh
|
||||
resolveRefresh!(refreshResponse);
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
expect(mockClient.refreshAccessToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reload credentials from file when file is modified', async () => {
|
||||
const mockClient = createMockQwenClient();
|
||||
const fileCredentials = createValidCredentials({
|
||||
access_token: 'file_access_token',
|
||||
});
|
||||
|
||||
// Mock file operations to simulate file modification
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 2000 } as Stats);
|
||||
mockFs.readFile.mockResolvedValue(JSON.stringify(fileCredentials));
|
||||
|
||||
// Set initial cache state
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
|
||||
tokenManager,
|
||||
'memoryCache',
|
||||
);
|
||||
memoryCache.fileModTime = 1000; // Older than file
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result.access_token).toBe('file_access_token');
|
||||
expect(mockFs.readFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Management', () => {
|
||||
it('should clear cache', () => {
|
||||
// Set some cache data
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = createValidCredentials();
|
||||
|
||||
tokenManager.clearCache();
|
||||
|
||||
expect(tokenManager.getCurrentCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return current credentials from cache', () => {
|
||||
const credentials = createValidCredentials();
|
||||
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = credentials;
|
||||
|
||||
expect(tokenManager.getCurrentCredentials()).toEqual(credentials);
|
||||
});
|
||||
|
||||
it('should return null when no credentials are cached', () => {
|
||||
tokenManager.clearCache();
|
||||
|
||||
expect(tokenManager.getCurrentCredentials()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh Status', () => {
|
||||
it('should return false when no refresh is in progress', () => {
|
||||
expect(tokenManager.isRefreshInProgress()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when refresh is in progress', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
|
||||
// Clear cache to ensure refresh is triggered
|
||||
tokenManager.clearCache();
|
||||
|
||||
// Mock stat for file check to fail (no file initially)
|
||||
mockFs.stat.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
// Create a delayed refresh response
|
||||
let resolveRefresh: (value: TokenRefreshData) => void;
|
||||
const refreshPromise = new Promise<TokenRefreshData>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
});
|
||||
|
||||
mockClient.refreshAccessToken = vi.fn().mockReturnValue(refreshPromise);
|
||||
|
||||
// Mock file operations for lock and save
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
|
||||
// Start refresh
|
||||
const refreshOperation = tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
// Wait a tick to ensure the refresh promise is set
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(tokenManager.isRefreshInProgress()).toBe(true);
|
||||
|
||||
// Complete refresh
|
||||
resolveRefresh!(createSuccessfulRefreshResponse());
|
||||
await refreshOperation;
|
||||
|
||||
expect(tokenManager.isRefreshInProgress()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Debug Info', () => {
|
||||
it('should return complete debug information', () => {
|
||||
const credentials = createValidCredentials();
|
||||
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = credentials;
|
||||
|
||||
const debugInfo = tokenManager.getDebugInfo();
|
||||
|
||||
expect(debugInfo).toHaveProperty('hasCredentials', true);
|
||||
expect(debugInfo).toHaveProperty('credentialsExpired', false);
|
||||
expect(debugInfo).toHaveProperty('isRefreshing', false);
|
||||
expect(debugInfo).toHaveProperty('cacheAge');
|
||||
expect(typeof debugInfo.cacheAge).toBe('number');
|
||||
});
|
||||
|
||||
it('should indicate expired credentials in debug info', () => {
|
||||
const expiredCredentials = createExpiredCredentials();
|
||||
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = expiredCredentials;
|
||||
|
||||
const debugInfo = tokenManager.getDebugInfo();
|
||||
|
||||
expect(debugInfo.hasCredentials).toBe(true);
|
||||
expect(debugInfo.credentialsExpired).toBe(true);
|
||||
});
|
||||
|
||||
it('should indicate no credentials in debug info', () => {
|
||||
tokenManager.clearCache();
|
||||
|
||||
const debugInfo = tokenManager.getDebugInfo();
|
||||
|
||||
expect(debugInfo.hasCredentials).toBe(false);
|
||||
expect(debugInfo.credentialsExpired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should create TokenManagerError with correct type and message', () => {
|
||||
const error = new TokenManagerError(
|
||||
TokenError.REFRESH_FAILED,
|
||||
'Token refresh failed',
|
||||
new Error('Original error'),
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(TokenManagerError);
|
||||
expect(error.type).toBe(TokenError.REFRESH_FAILED);
|
||||
expect(error.message).toBe('Token refresh failed');
|
||||
expect(error.name).toBe('TokenManagerError');
|
||||
expect(error.originalError).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('should handle file access errors gracefully', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
|
||||
// Mock file stat to throw access error
|
||||
const accessError = new Error(
|
||||
'Permission denied',
|
||||
) as NodeJS.ErrnoException;
|
||||
accessError.code = 'EACCES';
|
||||
mockFs.stat.mockRejectedValue(accessError);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
});
|
||||
|
||||
it('should handle missing file gracefully', async () => {
|
||||
const mockClient = createMockQwenClient();
|
||||
const validCredentials = createValidCredentials();
|
||||
|
||||
// Mock file stat to throw file not found error
|
||||
const notFoundError = new Error(
|
||||
'File not found',
|
||||
) as NodeJS.ErrnoException;
|
||||
notFoundError.code = 'ENOENT';
|
||||
mockFs.stat.mockRejectedValue(notFoundError);
|
||||
|
||||
// Set valid credentials in cache
|
||||
const memoryCache = getPrivateProperty<{
|
||||
credentials: QwenCredentials | null;
|
||||
}>(tokenManager, 'memoryCache');
|
||||
memoryCache.credentials = validCredentials;
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result).toEqual(validCredentials);
|
||||
});
|
||||
|
||||
it('should handle lock timeout scenarios', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
|
||||
// Configure shorter timeouts for testing
|
||||
tokenManager.setLockConfig({
|
||||
maxAttempts: 3,
|
||||
attemptInterval: 50,
|
||||
});
|
||||
|
||||
// Mock stat for file check to pass (no file initially)
|
||||
mockFs.stat.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
// Mock writeFile to always throw EEXIST for lock file writes (flag: 'wx')
|
||||
// but succeed for regular file writes
|
||||
const lockError = new Error('File exists') as NodeJS.ErrnoException;
|
||||
lockError.code = 'EEXIST';
|
||||
|
||||
mockFs.writeFile.mockImplementation((path, data, options) => {
|
||||
if (typeof options === 'object' && options?.flag === 'wx') {
|
||||
return Promise.reject(lockError);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
// Mock stat to return recent lock file (not stale) when checking lock age
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: Date.now() } as Stats);
|
||||
|
||||
// Mock unlink to simulate lock file removal attempts
|
||||
mockFs.unlink.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
}, 500); // 500ms timeout for lock test (3 attempts × 50ms = ~150ms + buffer)
|
||||
|
||||
it('should handle refresh response without access token', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const invalidResponse = {
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
// access_token is missing, so we use undefined explicitly
|
||||
access_token: undefined,
|
||||
} as Partial<TokenRefreshData>;
|
||||
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(invalidResponse);
|
||||
|
||||
// Mock stat for file check to pass (no file initially)
|
||||
mockFs.stat.mockRejectedValueOnce(
|
||||
Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
|
||||
);
|
||||
|
||||
// Mock file operations for lock acquisition
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
// Clear cache to force refresh
|
||||
tokenManager.clearCache();
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow(TokenManagerError);
|
||||
|
||||
await expect(
|
||||
tokenManager.getValidCredentials(mockClient),
|
||||
).rejects.toThrow('no token returned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File System Operations', () => {
|
||||
it('should handle file reload failures gracefully', async () => {
|
||||
const mockClient = createMockQwenClient();
|
||||
|
||||
// Mock successful refresh for when cache is cleared
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createSuccessfulRefreshResponse());
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat
|
||||
.mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded
|
||||
.mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations
|
||||
mockFs.readFile.mockRejectedValue(new Error('Read failed'));
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
// Set initial cache state to trigger reload
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
|
||||
tokenManager,
|
||||
'memoryCache',
|
||||
);
|
||||
memoryCache.fileModTime = 1000;
|
||||
|
||||
// Should not throw error, should refresh and get new credentials
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.access_token).toBe('fresh_access_token');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON in credentials file', async () => {
|
||||
const mockClient = createMockQwenClient();
|
||||
|
||||
// Mock successful refresh for when cache is cleared
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(createSuccessfulRefreshResponse());
|
||||
|
||||
// Mock file operations with invalid JSON
|
||||
mockFs.stat
|
||||
.mockResolvedValueOnce({ mtimeMs: 2000 } as Stats) // For checkAndReloadIfNeeded
|
||||
.mockResolvedValue({ mtimeMs: 1000 } as Stats); // For later operations
|
||||
mockFs.readFile.mockResolvedValue('invalid json content');
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
// Set initial cache state to trigger reload
|
||||
tokenManager.clearCache();
|
||||
const memoryCache = getPrivateProperty<{ fileModTime: number }>(
|
||||
tokenManager,
|
||||
'memoryCache',
|
||||
);
|
||||
memoryCache.fileModTime = 1000;
|
||||
|
||||
// Should handle JSON parse error gracefully, then refresh and get new credentials
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.access_token).toBe('fresh_access_token');
|
||||
});
|
||||
|
||||
it('should handle directory creation during save', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const refreshResponse = createSuccessfulRefreshResponse();
|
||||
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(refreshResponse);
|
||||
|
||||
// Mock file operations
|
||||
mockFs.stat.mockResolvedValue({ mtimeMs: 1000 } as Stats);
|
||||
mockFs.writeFile.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(mockFs.mkdir).toHaveBeenCalledWith(expect.any(String), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
expect(mockFs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lock File Management', () => {
|
||||
it('should clean up lock file during process cleanup', () => {
|
||||
// Create a new instance to trigger cleanup handler registration
|
||||
SharedTokenManager.getInstance();
|
||||
|
||||
// Access the private cleanup method for testing
|
||||
const cleanupHandlers = process.listeners('exit');
|
||||
const cleanup = cleanupHandlers[cleanupHandlers.length - 1] as () => void;
|
||||
|
||||
// Should not throw when lock file doesn't exist
|
||||
expect(() => cleanup()).not.toThrow();
|
||||
expect(mockUnlinkSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle stale lock cleanup', async () => {
|
||||
const mockClient = createMockQwenClient(createExpiredCredentials());
|
||||
const refreshResponse = createSuccessfulRefreshResponse();
|
||||
|
||||
mockClient.refreshAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue(refreshResponse);
|
||||
|
||||
// First writeFile call throws EEXIST (lock exists)
|
||||
// Second writeFile call succeeds (after stale lock cleanup)
|
||||
const lockError = new Error('File exists') as NodeJS.ErrnoException;
|
||||
lockError.code = 'EEXIST';
|
||||
mockFs.writeFile
|
||||
.mockRejectedValueOnce(lockError)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
// Mock stat to return stale lock (old timestamp)
|
||||
mockFs.stat
|
||||
.mockResolvedValueOnce({ mtimeMs: Date.now() - 20000 } as Stats) // Stale lock
|
||||
.mockResolvedValueOnce({ mtimeMs: 1000 } as Stats); // Credentials file
|
||||
|
||||
// Mock unlink to succeed
|
||||
mockFs.unlink.mockResolvedValue(undefined);
|
||||
mockFs.mkdir.mockResolvedValue(undefined);
|
||||
|
||||
const result = await tokenManager.getValidCredentials(mockClient);
|
||||
|
||||
expect(result.access_token).toBe(refreshResponse.access_token);
|
||||
expect(mockFs.unlink).toHaveBeenCalled(); // Stale lock removed
|
||||
});
|
||||
});
|
||||
});
|
||||
662
packages/core/src/qwen/sharedTokenManager.ts
Normal file
662
packages/core/src/qwen/sharedTokenManager.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import { promises as fs, unlinkSync } from 'node:fs';
|
||||
import * as os from 'os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
IQwenOAuth2Client,
|
||||
type QwenCredentials,
|
||||
type TokenRefreshData,
|
||||
type ErrorData,
|
||||
isErrorResponse,
|
||||
} from './qwenOAuth2.js';
|
||||
|
||||
// File System Configuration
|
||||
const QWEN_DIR = '.qwen';
|
||||
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
||||
const QWEN_LOCK_FILENAME = 'oauth_creds.lock';
|
||||
|
||||
// Token and Cache Configuration
|
||||
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; // 30 seconds
|
||||
const LOCK_TIMEOUT_MS = 10000; // 10 seconds lock timeout
|
||||
const CACHE_CHECK_INTERVAL_MS = 1000; // 1 second cache check interval
|
||||
|
||||
// Lock acquisition configuration (can be overridden for testing)
|
||||
interface LockConfig {
|
||||
maxAttempts: number;
|
||||
attemptInterval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_CONFIG: LockConfig = {
|
||||
maxAttempts: 50,
|
||||
attemptInterval: 200,
|
||||
};
|
||||
|
||||
/**
|
||||
* Token manager error types for better error classification
|
||||
*/
|
||||
export enum TokenError {
|
||||
REFRESH_FAILED = 'REFRESH_FAILED',
|
||||
NO_REFRESH_TOKEN = 'NO_REFRESH_TOKEN',
|
||||
LOCK_TIMEOUT = 'LOCK_TIMEOUT',
|
||||
FILE_ACCESS_ERROR = 'FILE_ACCESS_ERROR',
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for token manager operations
|
||||
*/
|
||||
export class TokenManagerError extends Error {
|
||||
constructor(
|
||||
public type: TokenError,
|
||||
message: string,
|
||||
public originalError?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TokenManagerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the memory cache state
|
||||
*/
|
||||
interface MemoryCache {
|
||||
credentials: QwenCredentials | null;
|
||||
fileModTime: number;
|
||||
lastCheck: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the given data is a valid QwenCredentials object
|
||||
*
|
||||
* @param data - The data to validate
|
||||
* @returns The validated credentials object
|
||||
* @throws Error if the data is invalid
|
||||
*/
|
||||
function validateCredentials(data: unknown): QwenCredentials {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid credentials format');
|
||||
}
|
||||
|
||||
const creds = data as Partial<QwenCredentials>;
|
||||
const requiredFields = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token_type',
|
||||
] as const;
|
||||
|
||||
// Check required string fields
|
||||
for (const field of requiredFields) {
|
||||
if (!creds[field] || typeof creds[field] !== 'string') {
|
||||
throw new Error(`Invalid credentials: missing ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check expiry_date
|
||||
if (!creds.expiry_date || typeof creds.expiry_date !== 'number') {
|
||||
throw new Error('Invalid credentials: missing expiry_date');
|
||||
}
|
||||
|
||||
return creds as QwenCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages OAuth tokens across multiple processes using file-based caching and locking
|
||||
*/
|
||||
export class SharedTokenManager {
|
||||
private static instance: SharedTokenManager | null = null;
|
||||
|
||||
/**
|
||||
* In-memory cache for credentials and file state tracking
|
||||
*/
|
||||
private memoryCache: MemoryCache = {
|
||||
credentials: null,
|
||||
fileModTime: 0,
|
||||
lastCheck: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Promise tracking any ongoing token refresh operation
|
||||
*/
|
||||
private refreshPromise: Promise<QwenCredentials> | null = null;
|
||||
|
||||
/**
|
||||
* Whether cleanup handlers have been registered
|
||||
*/
|
||||
private cleanupHandlersRegistered = false;
|
||||
|
||||
/**
|
||||
* Reference to cleanup functions for proper removal
|
||||
*/
|
||||
private cleanupFunction: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Lock configuration for testing purposes
|
||||
*/
|
||||
private lockConfig: LockConfig = DEFAULT_LOCK_CONFIG;
|
||||
|
||||
/**
|
||||
* Private constructor for singleton pattern
|
||||
*/
|
||||
private constructor() {
|
||||
this.registerCleanupHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance
|
||||
* @returns The shared token manager instance
|
||||
*/
|
||||
static getInstance(): SharedTokenManager {
|
||||
if (!SharedTokenManager.instance) {
|
||||
SharedTokenManager.instance = new SharedTokenManager();
|
||||
}
|
||||
return SharedTokenManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up handlers to clean up lock files when the process exits
|
||||
*/
|
||||
private registerCleanupHandlers(): void {
|
||||
if (this.cleanupHandlersRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupFunction = () => {
|
||||
try {
|
||||
const lockPath = this.getLockFilePath();
|
||||
// Use synchronous unlink for process exit handlers
|
||||
unlinkSync(lockPath);
|
||||
} catch (_error) {
|
||||
// Ignore cleanup errors - lock file might not exist or already be cleaned up
|
||||
}
|
||||
};
|
||||
|
||||
process.on('exit', this.cleanupFunction);
|
||||
process.on('SIGINT', this.cleanupFunction);
|
||||
process.on('SIGTERM', this.cleanupFunction);
|
||||
process.on('uncaughtException', this.cleanupFunction);
|
||||
process.on('unhandledRejection', this.cleanupFunction);
|
||||
|
||||
this.cleanupHandlersRegistered = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid OAuth credentials, refreshing them if necessary
|
||||
*
|
||||
* @param qwenClient - The OAuth2 client instance
|
||||
* @param forceRefresh - If true, refresh token even if current one is still valid
|
||||
* @returns Promise resolving to valid credentials
|
||||
* @throws TokenManagerError if unable to obtain valid credentials
|
||||
*/
|
||||
async getValidCredentials(
|
||||
qwenClient: IQwenOAuth2Client,
|
||||
forceRefresh = false,
|
||||
): Promise<QwenCredentials> {
|
||||
try {
|
||||
// Check if credentials file has been updated by other sessions
|
||||
await this.checkAndReloadIfNeeded();
|
||||
|
||||
// Return valid cached credentials if available (unless force refresh is requested)
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.memoryCache.credentials &&
|
||||
this.isTokenValid(this.memoryCache.credentials)
|
||||
) {
|
||||
return this.memoryCache.credentials;
|
||||
}
|
||||
|
||||
// If refresh is already in progress, wait for it to complete
|
||||
if (this.refreshPromise) {
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
// Start new refresh operation with distributed locking
|
||||
this.refreshPromise = this.performTokenRefresh(qwenClient, forceRefresh);
|
||||
|
||||
try {
|
||||
const credentials = await this.refreshPromise;
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
// Ensure refreshPromise is cleared on error before re-throwing
|
||||
this.refreshPromise = null;
|
||||
throw error;
|
||||
} finally {
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Convert generic errors to TokenManagerError for better error handling
|
||||
if (error instanceof TokenManagerError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new TokenManagerError(
|
||||
TokenError.REFRESH_FAILED,
|
||||
`Failed to get valid credentials: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the credentials file was updated by another process and reload if so
|
||||
*/
|
||||
private async checkAndReloadIfNeeded(): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
// Limit check frequency to avoid excessive disk I/O
|
||||
if (now - this.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.memoryCache.lastCheck = now;
|
||||
|
||||
try {
|
||||
const filePath = this.getCredentialFilePath();
|
||||
const stats = await fs.stat(filePath);
|
||||
const fileModTime = stats.mtimeMs;
|
||||
|
||||
// Reload credentials if file has been modified since last cache
|
||||
if (fileModTime > this.memoryCache.fileModTime) {
|
||||
await this.reloadCredentialsFromFile();
|
||||
this.memoryCache.fileModTime = fileModTime;
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle file access errors
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code !== 'ENOENT'
|
||||
) {
|
||||
// Clear cache for non-missing file errors
|
||||
this.memoryCache.credentials = null;
|
||||
this.memoryCache.fileModTime = 0;
|
||||
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
`Failed to access credentials file: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// For missing files (ENOENT), just reset file modification time
|
||||
// but keep existing valid credentials in memory if they exist
|
||||
this.memoryCache.fileModTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from the file system into memory cache
|
||||
*/
|
||||
private async reloadCredentialsFromFile(): Promise<void> {
|
||||
try {
|
||||
const filePath = this.getCredentialFilePath();
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsedData = JSON.parse(content);
|
||||
const credentials = validateCredentials(parsedData);
|
||||
this.memoryCache.credentials = credentials;
|
||||
} catch (error) {
|
||||
// Log validation errors for debugging but don't throw
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes('Invalid credentials')
|
||||
) {
|
||||
console.warn(`Failed to validate credentials file: ${error.message}`);
|
||||
}
|
||||
this.memoryCache.credentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the OAuth token using file locking to prevent concurrent refreshes
|
||||
*
|
||||
* @param qwenClient - The OAuth2 client instance
|
||||
* @param forceRefresh - If true, skip checking if token is already valid after getting lock
|
||||
* @returns Promise resolving to refreshed credentials
|
||||
* @throws TokenManagerError if refresh fails or lock cannot be acquired
|
||||
*/
|
||||
private async performTokenRefresh(
|
||||
qwenClient: IQwenOAuth2Client,
|
||||
forceRefresh = false,
|
||||
): Promise<QwenCredentials> {
|
||||
const lockPath = this.getLockFilePath();
|
||||
|
||||
try {
|
||||
// Check if we have a refresh token before attempting refresh
|
||||
const currentCredentials = qwenClient.getCredentials();
|
||||
if (!currentCredentials.refresh_token) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.NO_REFRESH_TOKEN,
|
||||
'No refresh token available for token refresh',
|
||||
);
|
||||
}
|
||||
|
||||
// Acquire distributed file lock
|
||||
await this.acquireLock(lockPath);
|
||||
|
||||
// Double-check if another process already refreshed the token (unless force refresh is requested)
|
||||
await this.checkAndReloadIfNeeded();
|
||||
|
||||
// Use refreshed credentials if they're now valid (unless force refresh is requested)
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.memoryCache.credentials &&
|
||||
this.isTokenValid(this.memoryCache.credentials)
|
||||
) {
|
||||
qwenClient.setCredentials(this.memoryCache.credentials);
|
||||
return this.memoryCache.credentials;
|
||||
}
|
||||
|
||||
// Perform the actual token refresh
|
||||
const response = await qwenClient.refreshAccessToken();
|
||||
|
||||
if (!response || isErrorResponse(response)) {
|
||||
const errorData = response as ErrorData;
|
||||
throw new TokenManagerError(
|
||||
TokenError.REFRESH_FAILED,
|
||||
`Token refresh failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = response as TokenRefreshData;
|
||||
|
||||
if (!tokenData.access_token) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.REFRESH_FAILED,
|
||||
'Failed to refresh access token: no token returned',
|
||||
);
|
||||
}
|
||||
|
||||
// Create updated credentials object
|
||||
const credentials: QwenCredentials = {
|
||||
access_token: tokenData.access_token,
|
||||
token_type: tokenData.token_type,
|
||||
refresh_token:
|
||||
tokenData.refresh_token || currentCredentials.refresh_token,
|
||||
resource_url: tokenData.resource_url,
|
||||
expiry_date: Date.now() + tokenData.expires_in * 1000,
|
||||
};
|
||||
|
||||
// Update memory cache and client credentials
|
||||
this.memoryCache.credentials = credentials;
|
||||
qwenClient.setCredentials(credentials);
|
||||
|
||||
// Persist to file and update modification time
|
||||
await this.saveCredentialsToFile(credentials);
|
||||
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof TokenManagerError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle network-related errors
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('fetch') ||
|
||||
error.message.includes('network') ||
|
||||
error.message.includes('timeout'))
|
||||
) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.NETWORK_ERROR,
|
||||
`Network error during token refresh: ${error.message}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
throw new TokenManagerError(
|
||||
TokenError.REFRESH_FAILED,
|
||||
`Unexpected error during token refresh: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
// Always release the file lock
|
||||
await this.releaseLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to file and update the cached file modification time
|
||||
*
|
||||
* @param credentials - The credentials to save
|
||||
*/
|
||||
private async saveCredentialsToFile(
|
||||
credentials: QwenCredentials,
|
||||
): Promise<void> {
|
||||
const filePath = this.getCredentialFilePath();
|
||||
const dirPath = path.dirname(filePath);
|
||||
|
||||
// Create directory with restricted permissions
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
} catch (error) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
`Failed to create credentials directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const credString = JSON.stringify(credentials, null, 2);
|
||||
|
||||
try {
|
||||
// Write file with restricted permissions (owner read/write only)
|
||||
await fs.writeFile(filePath, credString, { mode: 0o600 });
|
||||
} catch (error) {
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
`Failed to write credentials file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Update cached file modification time to avoid unnecessary reloads
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
this.memoryCache.fileModTime = stats.mtimeMs;
|
||||
} catch (error) {
|
||||
// Non-fatal error, just log it
|
||||
console.warn(
|
||||
`Failed to update file modification time: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the token is valid and not expired
|
||||
*
|
||||
* @param credentials - The credentials to validate
|
||||
* @returns true if token is valid and not expired, false otherwise
|
||||
*/
|
||||
private isTokenValid(credentials: QwenCredentials): boolean {
|
||||
if (!credentials.expiry_date || !credentials.access_token) {
|
||||
return false;
|
||||
}
|
||||
return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the credentials file
|
||||
*
|
||||
* @returns The absolute path to the credentials file
|
||||
*/
|
||||
private getCredentialFilePath(): string {
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to the lock file
|
||||
*
|
||||
* @returns The absolute path to the lock file
|
||||
*/
|
||||
private getLockFilePath(): string {
|
||||
return path.join(os.homedir(), QWEN_DIR, QWEN_LOCK_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a file lock to prevent other processes from refreshing tokens simultaneously
|
||||
*
|
||||
* @param lockPath - Path to the lock file
|
||||
* @throws TokenManagerError if lock cannot be acquired within timeout period
|
||||
*/
|
||||
private async acquireLock(lockPath: string): Promise<void> {
|
||||
const { maxAttempts, attemptInterval } = this.lockConfig;
|
||||
const lockId = randomUUID(); // Use random UUID instead of PID for security
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
// Attempt to create lock file atomically (exclusive mode)
|
||||
await fs.writeFile(lockPath, lockId, { flag: 'wx' });
|
||||
return; // Successfully acquired lock
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
// Lock file already exists, check if it's stale
|
||||
try {
|
||||
const stats = await fs.stat(lockPath);
|
||||
const lockAge = Date.now() - stats.mtimeMs;
|
||||
|
||||
// Remove stale locks that exceed timeout
|
||||
if (lockAge > LOCK_TIMEOUT_MS) {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
console.warn(
|
||||
`Removed stale lock file: ${lockPath} (age: ${lockAge}ms)`,
|
||||
);
|
||||
continue; // Retry lock acquisition
|
||||
} catch (unlinkError) {
|
||||
// Log the error but continue trying - another process might have removed it
|
||||
console.warn(
|
||||
`Failed to remove stale lock file ${lockPath}: ${unlinkError instanceof Error ? unlinkError.message : String(unlinkError)}`,
|
||||
);
|
||||
// Still continue - the lock might have been removed by another process
|
||||
}
|
||||
}
|
||||
} catch (statError) {
|
||||
// Can't stat lock file, it might have been removed, continue trying
|
||||
console.warn(
|
||||
`Failed to stat lock file ${lockPath}: ${statError instanceof Error ? statError.message : String(statError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, attemptInterval));
|
||||
} else {
|
||||
throw new TokenManagerError(
|
||||
TokenError.FILE_ACCESS_ERROR,
|
||||
`Failed to create lock file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new TokenManagerError(
|
||||
TokenError.LOCK_TIMEOUT,
|
||||
'Failed to acquire file lock for token refresh: timeout exceeded',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the file lock
|
||||
*
|
||||
* @param lockPath - Path to the lock file
|
||||
*/
|
||||
private async releaseLock(lockPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(lockPath);
|
||||
} catch (error) {
|
||||
// Lock file might already be removed by another process or timeout cleanup
|
||||
// This is not an error condition, but log for debugging
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
console.warn(
|
||||
`Failed to release lock file ${lockPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data and reset the manager to initial state
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.memoryCache = {
|
||||
credentials: null,
|
||||
fileModTime: 0,
|
||||
lastCheck: 0,
|
||||
};
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cached credentials (may be expired)
|
||||
*
|
||||
* @returns The currently cached credentials or null
|
||||
*/
|
||||
getCurrentCredentials(): QwenCredentials | null {
|
||||
return this.memoryCache.credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an ongoing refresh operation
|
||||
*
|
||||
* @returns true if refresh is in progress, false otherwise
|
||||
*/
|
||||
isRefreshInProgress(): boolean {
|
||||
return this.refreshPromise !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set lock configuration for testing purposes
|
||||
* @param config - Lock configuration
|
||||
*/
|
||||
setLockConfig(config: Partial<LockConfig>): void {
|
||||
this.lockConfig = { ...DEFAULT_LOCK_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up event listeners (primarily for testing)
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.cleanupFunction && this.cleanupHandlersRegistered) {
|
||||
this.cleanupFunction();
|
||||
|
||||
process.removeListener('exit', this.cleanupFunction);
|
||||
process.removeListener('SIGINT', this.cleanupFunction);
|
||||
process.removeListener('SIGTERM', this.cleanupFunction);
|
||||
process.removeListener('uncaughtException', this.cleanupFunction);
|
||||
process.removeListener('unhandledRejection', this.cleanupFunction);
|
||||
|
||||
this.cleanupHandlersRegistered = false;
|
||||
this.cleanupFunction = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of the current state for debugging
|
||||
*
|
||||
* @returns Object containing current state information
|
||||
*/
|
||||
getDebugInfo(): {
|
||||
hasCredentials: boolean;
|
||||
credentialsExpired: boolean;
|
||||
isRefreshing: boolean;
|
||||
cacheAge: number;
|
||||
} {
|
||||
const hasCredentials = !!this.memoryCache.credentials;
|
||||
const credentialsExpired = hasCredentials
|
||||
? !this.isTokenValid(this.memoryCache.credentials!)
|
||||
: false;
|
||||
|
||||
return {
|
||||
hasCredentials,
|
||||
credentialsExpired,
|
||||
isRefreshing: this.isRefreshInProgress(),
|
||||
cacheAge: Date.now() - this.memoryCache.lastCheck,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user