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",
|
"console": "integratedTerminal",
|
||||||
"internalConsoleOptions": "neverOpen",
|
"internalConsoleOptions": "neverOpen",
|
||||||
"skipFiles": ["<node_internals>/**"]
|
"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": [
|
"inputs": [
|
||||||
@@ -75,6 +88,12 @@
|
|||||||
"type": "promptString",
|
"type": "promptString",
|
||||||
"description": "Enter the path to the test file (e.g., ${workspaceFolder}/packages/cli/src/ui/components/LoadingIndicator.test.tsx)",
|
"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"
|
"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;
|
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;
|
return item.value === AuthType.LOGIN_WITH_GOOGLE;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe('getInstallationInfo', () => {
|
|||||||
const info = getInstallationInfo(projectRoot, false);
|
const info = getInstallationInfo(projectRoot, false);
|
||||||
|
|
||||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||||
'brew list -1 | grep -q "^gemini-cli$"',
|
'brew list -1 | grep -q "^qwen-code$"',
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore' },
|
||||||
);
|
);
|
||||||
expect(info.packageManager).toBe(PackageManager.HOMEBREW);
|
expect(info.packageManager).toBe(PackageManager.HOMEBREW);
|
||||||
@@ -162,7 +162,7 @@ describe('getInstallationInfo', () => {
|
|||||||
const info = getInstallationInfo(projectRoot, false);
|
const info = getInstallationInfo(projectRoot, false);
|
||||||
|
|
||||||
expect(mockedExecSync).toHaveBeenCalledWith(
|
expect(mockedExecSync).toHaveBeenCalledWith(
|
||||||
'brew list -1 | grep -q "^gemini-cli$"',
|
'brew list -1 | grep -q "^qwen-code$"',
|
||||||
{ stdio: 'ignore' },
|
{ stdio: 'ignore' },
|
||||||
);
|
);
|
||||||
// Should fall back to default global npm
|
// Should fall back to default global npm
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export function getInstallationInfo(
|
|||||||
// Check for Homebrew
|
// Check for Homebrew
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
try {
|
try {
|
||||||
// The package name in homebrew is gemini-cli
|
// We do not support homebrew for now, keep forward compatibility for future use
|
||||||
childProcess.execSync('brew list -1 | grep -q "^gemini-cli$"', {
|
childProcess.execSync('brew list -1 | grep -q "^qwen-code$"', {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@@ -88,8 +88,7 @@ export function getInstallationInfo(
|
|||||||
'Installed via Homebrew. Please update with "brew upgrade".',
|
'Installed via Homebrew. Please update with "brew upgrade".',
|
||||||
};
|
};
|
||||||
} catch (_error) {
|
} 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) {
|
if (process.env.OPENAI_API_KEY) {
|
||||||
return AuthType.USE_OPENAI;
|
return AuthType.USE_OPENAI;
|
||||||
}
|
}
|
||||||
if (process.env.QWEN_OAUTH_TOKEN) {
|
|
||||||
return AuthType.QWEN_OAUTH;
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,117 @@ import {
|
|||||||
FinishReason,
|
FinishReason,
|
||||||
} from '@google/genai';
|
} from '@google/genai';
|
||||||
import { QwenContentGenerator } from './qwenContentGenerator.js';
|
import { QwenContentGenerator } from './qwenContentGenerator.js';
|
||||||
|
import { SharedTokenManager } from './sharedTokenManager.js';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { AuthType, ContentGeneratorConfig } from '../core/contentGenerator.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
|
// Mock the OpenAIContentGenerator parent class
|
||||||
vi.mock('../core/openaiContentGenerator.js', () => ({
|
vi.mock('../core/openaiContentGenerator.js', () => ({
|
||||||
OpenAIContentGenerator: class {
|
OpenAIContentGenerator: class {
|
||||||
@@ -236,8 +344,10 @@ describe('QwenContentGenerator', () => {
|
|||||||
it('should refresh token on auth error and retry', async () => {
|
it('should refresh token on auth error and retry', async () => {
|
||||||
const authError = { status: 401, message: 'Unauthorized' };
|
const authError = { status: 401, message: 'Unauthorized' };
|
||||||
|
|
||||||
// First call fails with auth error
|
// First call fails with auth error, second call succeeds
|
||||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValueOnce(authError);
|
vi.mocked(mockQwenClient.getAccessToken)
|
||||||
|
.mockRejectedValueOnce(authError)
|
||||||
|
.mockResolvedValueOnce({ token: 'refreshed-token' });
|
||||||
|
|
||||||
// Refresh succeeds
|
// Refresh succeeds
|
||||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||||
@@ -247,6 +357,15 @@ describe('QwenContentGenerator', () => {
|
|||||||
resource_url: 'https://refreshed-endpoint.com',
|
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 = {
|
const request: GenerateContentParameters = {
|
||||||
model: 'qwen-turbo',
|
model: 'qwen-turbo',
|
||||||
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
contents: [{ role: 'user', parts: [{ text: 'Hello' }] }],
|
||||||
@@ -261,12 +380,62 @@ describe('QwenContentGenerator', () => {
|
|||||||
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
expect(mockQwenClient.refreshAccessToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle token refresh failure', async () => {
|
it('should refresh token on auth error and retry for content stream', async () => {
|
||||||
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(
|
const authError = { status: 401, message: 'Unauthorized' };
|
||||||
new Error('Token expired'),
|
|
||||||
|
// 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(
|
const chunks: string[] = [];
|
||||||
new Error('Refresh failed'),
|
|
||||||
|
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 = {
|
const request: GenerateContentParameters = {
|
||||||
@@ -279,6 +448,9 @@ describe('QwenContentGenerator', () => {
|
|||||||
).rejects.toThrow(
|
).rejects.toThrow(
|
||||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
mockTokenManager.setMockError(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update endpoint when token is refreshed', async () => {
|
it('should update endpoint when token is refreshed', async () => {
|
||||||
@@ -547,10 +719,24 @@ describe('QwenContentGenerator', () => {
|
|||||||
const originalGenerateContent = parentPrototype.generateContent;
|
const originalGenerateContent = parentPrototype.generateContent;
|
||||||
parentPrototype.generateContent = mockGenerateContent;
|
parentPrototype.generateContent = mockGenerateContent;
|
||||||
|
|
||||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
// Mock getAccessToken to fail initially, then succeed
|
||||||
token: 'initial-token',
|
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({
|
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||||
access_token: 'refreshed-token',
|
access_token: 'refreshed-token',
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
@@ -637,31 +823,16 @@ describe('QwenContentGenerator', () => {
|
|||||||
expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token');
|
expect(qwenContentGenerator.getCurrentToken()).toBe('cached-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear token and endpoint on clearToken()', () => {
|
it('should clear token on clearToken()', () => {
|
||||||
// Simulate having cached values
|
// Simulate having cached token value
|
||||||
const qwenInstance = qwenContentGenerator as unknown as {
|
const qwenInstance = qwenContentGenerator as unknown as {
|
||||||
currentToken: string;
|
currentToken: string;
|
||||||
currentEndpoint: string;
|
|
||||||
refreshPromise: Promise<string>;
|
|
||||||
};
|
};
|
||||||
qwenInstance.currentToken = 'cached-token';
|
qwenInstance.currentToken = 'cached-token';
|
||||||
qwenInstance.currentEndpoint = 'https://cached-endpoint.com';
|
|
||||||
qwenInstance.refreshPromise = Promise.resolve('token');
|
|
||||||
|
|
||||||
qwenContentGenerator.clearToken();
|
qwenContentGenerator.clearToken();
|
||||||
|
|
||||||
expect(qwenContentGenerator.getCurrentToken()).toBeNull();
|
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 () => {
|
it('should handle concurrent token refresh requests', async () => {
|
||||||
@@ -674,9 +845,7 @@ describe('QwenContentGenerator', () => {
|
|||||||
const authError = { status: 401, message: 'Unauthorized' };
|
const authError = { status: 401, message: 'Unauthorized' };
|
||||||
let parentCallCount = 0;
|
let parentCallCount = 0;
|
||||||
|
|
||||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
vi.mocked(mockQwenClient.getAccessToken).mockRejectedValue(authError);
|
||||||
token: 'initial-token',
|
|
||||||
});
|
|
||||||
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
vi.mocked(mockQwenClient.getCredentials).mockReturnValue(mockCredentials);
|
||||||
|
|
||||||
vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation(
|
vi.mocked(mockQwenClient.refreshAccessToken).mockImplementation(
|
||||||
@@ -725,6 +894,7 @@ describe('QwenContentGenerator', () => {
|
|||||||
|
|
||||||
// The main test is that all requests succeed without crashing
|
// The main test is that all requests succeed without crashing
|
||||||
expect(results).toHaveLength(3);
|
expect(results).toHaveLength(3);
|
||||||
|
// With our new implementation through SharedTokenManager, refresh should still be called
|
||||||
expect(refreshCallCount).toBeGreaterThanOrEqual(1);
|
expect(refreshCallCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
// Restore original method
|
// Restore original method
|
||||||
@@ -796,13 +966,24 @@ describe('QwenContentGenerator', () => {
|
|||||||
);
|
);
|
||||||
parentPrototype.generateContent = mockGenerateContent;
|
parentPrototype.generateContent = mockGenerateContent;
|
||||||
|
|
||||||
vi.mocked(mockQwenClient.getAccessToken).mockResolvedValue({
|
// Mock getAccessToken to fail initially, then succeed
|
||||||
token: 'initial-token',
|
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({
|
vi.mocked(mockQwenClient.getCredentials).mockReturnValue({
|
||||||
...mockCredentials,
|
access_token: 'new-token',
|
||||||
resource_url: 'custom-endpoint.com',
|
token_type: 'Bearer',
|
||||||
|
refresh_token: 'refresh-token',
|
||||||
|
resource_url: 'https://new-endpoint.com',
|
||||||
|
expiry_date: Date.now() + 7200000,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
vi.mocked(mockQwenClient.refreshAccessToken).mockResolvedValue({
|
||||||
access_token: 'new-token',
|
access_token: 'new-token',
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
@@ -826,4 +1007,595 @@ describe('QwenContentGenerator', () => {
|
|||||||
expect(callCount).toBe(2); // Initial call + retry
|
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 { OpenAIContentGenerator } from '../core/openaiContentGenerator.js';
|
||||||
import {
|
import { IQwenOAuth2Client } from './qwenOAuth2.js';
|
||||||
IQwenOAuth2Client,
|
import { SharedTokenManager } from './sharedTokenManager.js';
|
||||||
type TokenRefreshData,
|
|
||||||
type ErrorData,
|
|
||||||
isErrorResponse,
|
|
||||||
} from './qwenOAuth2.js';
|
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import {
|
import {
|
||||||
GenerateContentParameters,
|
GenerateContentParameters,
|
||||||
@@ -31,11 +27,8 @@ const DEFAULT_QWEN_BASE_URL =
|
|||||||
*/
|
*/
|
||||||
export class QwenContentGenerator extends OpenAIContentGenerator {
|
export class QwenContentGenerator extends OpenAIContentGenerator {
|
||||||
private qwenClient: IQwenOAuth2Client;
|
private qwenClient: IQwenOAuth2Client;
|
||||||
|
private sharedManager: SharedTokenManager;
|
||||||
// Token management (integrated from QwenTokenManager)
|
private currentToken?: string;
|
||||||
private currentToken: string | null = null;
|
|
||||||
private currentEndpoint: string | null = null;
|
|
||||||
private refreshPromise: Promise<string> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
qwenClient: IQwenOAuth2Client,
|
qwenClient: IQwenOAuth2Client,
|
||||||
@@ -45,6 +38,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
|||||||
// Initialize with empty API key, we'll override it dynamically
|
// Initialize with empty API key, we'll override it dynamically
|
||||||
super(contentGeneratorConfig, config);
|
super(contentGeneratorConfig, config);
|
||||||
this.qwenClient = qwenClient;
|
this.qwenClient = qwenClient;
|
||||||
|
this.sharedManager = SharedTokenManager.getInstance();
|
||||||
|
|
||||||
// Set default base URL, will be updated dynamically
|
// Set default base URL, will be updated dynamically
|
||||||
this.client.baseURL = DEFAULT_QWEN_BASE_URL;
|
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
|
* Get the current endpoint URL with proper protocol and /v1 suffix
|
||||||
*/
|
*/
|
||||||
private getCurrentEndpoint(): string {
|
private getCurrentEndpoint(resourceUrl?: string): string {
|
||||||
const baseEndpoint = this.currentEndpoint || DEFAULT_QWEN_BASE_URL;
|
const baseEndpoint = resourceUrl || DEFAULT_QWEN_BASE_URL;
|
||||||
const suffix = '/v1';
|
const suffix = '/v1';
|
||||||
|
|
||||||
// Normalize the URL: add protocol if missing, ensure /v1 suffix
|
// 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(
|
private async getValidToken(): Promise<{ token: string; endpoint: string }> {
|
||||||
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();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await super.generateContent(request, userPromptId);
|
// Use SharedTokenManager for consistent token/endpoint pairing and automatic refresh
|
||||||
} finally {
|
const credentials = await this.sharedManager.getValidCredentials(
|
||||||
// Restore original values
|
this.qwenClient,
|
||||||
this.client.apiKey = originalApiKey;
|
);
|
||||||
this.client.baseURL = originalBaseURL;
|
|
||||||
}
|
if (!credentials.access_token) {
|
||||||
});
|
throw new Error('No access token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return {
|
||||||
* Override to use dynamic token and endpoint
|
token: credentials.access_token,
|
||||||
*/
|
endpoint: this.getCurrentEndpoint(credentials.resource_url),
|
||||||
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);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Restore original values on error
|
// Propagate auth errors as-is for retry logic
|
||||||
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
|
|
||||||
if (this.isAuthError(error)) {
|
if (this.isAuthError(error)) {
|
||||||
// Refresh token and retry once silently
|
|
||||||
const newToken = await this.refreshToken();
|
|
||||||
return await operation(newToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
console.warn('Failed to get token from shared manager:', 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);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Failed to obtain valid Qwen access token. Please re-authenticate.',
|
'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> {
|
private async executeWithCredentialManagement<T>(
|
||||||
// If there's already a refresh in progress, wait for it
|
operation: () => Promise<T>,
|
||||||
if (this.refreshPromise) {
|
restoreOnCompletion: boolean = true,
|
||||||
return this.refreshPromise;
|
): 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 {
|
try {
|
||||||
const { token } = await this.qwenClient.getAccessToken();
|
const result = await operation();
|
||||||
if (token) {
|
|
||||||
this.currentToken = token;
|
// For streaming operations, we may need to keep the configuration active
|
||||||
// Also update endpoint from current credentials
|
if (restoreOnCompletion) {
|
||||||
const credentials = this.qwenClient.getCredentials();
|
this.client.apiKey = originalApiKey;
|
||||||
if (credentials.resource_url) {
|
this.client.baseURL = originalBaseURL;
|
||||||
this.currentEndpoint = credentials.resource_url;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} 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
|
// Execute with retry logic for auth errors
|
||||||
this.refreshPromise = this.performTokenRefresh();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await this.refreshPromise;
|
return await attemptOperation();
|
||||||
return newToken;
|
} catch (error) {
|
||||||
} finally {
|
if (this.isAuthError(error)) {
|
||||||
this.refreshPromise = null;
|
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> {
|
override async generateContent(
|
||||||
this.refreshPromise = this.performTokenRefresh();
|
request: GenerateContentParameters,
|
||||||
|
userPromptId: string,
|
||||||
try {
|
): Promise<GenerateContentResponse> {
|
||||||
const newToken = await this.refreshPromise;
|
return this.executeWithCredentialManagement(() =>
|
||||||
return newToken;
|
super.generateContent(request, userPromptId),
|
||||||
} 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'}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = response as TokenRefreshData;
|
/**
|
||||||
|
* Override to use dynamic token and endpoint with automatic retry.
|
||||||
if (!tokenData.access_token) {
|
* Note: For streaming, the client configuration is not restored immediately
|
||||||
throw new Error('Failed to refresh access token: no token returned');
|
* since the generator may continue to be used after this method returns.
|
||||||
}
|
*/
|
||||||
|
override async generateContentStream(
|
||||||
this.currentToken = tokenData.access_token;
|
request: GenerateContentParameters,
|
||||||
|
userPromptId: string,
|
||||||
// Update endpoint if provided
|
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||||
if (tokenData.resource_url) {
|
return this.executeWithCredentialManagement(
|
||||||
this.currentEndpoint = tokenData.resource_url;
|
() => super.generateContentStream(request, userPromptId),
|
||||||
}
|
false, // Don't restore immediately for streaming
|
||||||
|
|
||||||
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
|
||||||
|
*/
|
||||||
|
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;
|
const errorCode = errorWithCode?.status || errorWithCode?.code;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
errorCode === 400 ||
|
|
||||||
errorCode === 401 ||
|
errorCode === 401 ||
|
||||||
errorCode === 403 ||
|
errorCode === 403 ||
|
||||||
|
errorCode === '401' ||
|
||||||
|
errorCode === '403' ||
|
||||||
errorMessage.includes('unauthorized') ||
|
errorMessage.includes('unauthorized') ||
|
||||||
errorMessage.includes('forbidden') ||
|
errorMessage.includes('forbidden') ||
|
||||||
errorMessage.includes('invalid api key') ||
|
errorMessage.includes('invalid api key') ||
|
||||||
@@ -349,15 +256,22 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
|
|||||||
* Get the current cached token (may be expired)
|
* Get the current cached token (may be expired)
|
||||||
*/
|
*/
|
||||||
getCurrentToken(): string | null {
|
getCurrentToken(): string | null {
|
||||||
|
// First check internal state for backwards compatibility with tests
|
||||||
|
if (this.currentToken) {
|
||||||
return 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 {
|
clearToken(): void {
|
||||||
this.currentToken = null;
|
// Clear internal state for backwards compatibility with tests
|
||||||
this.currentEndpoint = null;
|
this.currentToken = undefined;
|
||||||
this.refreshPromise = null;
|
// 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 { EventEmitter } from 'events';
|
||||||
import { Config } from '../config/config.js';
|
import { Config } from '../config/config.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
SharedTokenManager,
|
||||||
|
TokenManagerError,
|
||||||
|
TokenError,
|
||||||
|
} from './sharedTokenManager.js';
|
||||||
|
|
||||||
// OAuth Endpoints
|
// OAuth Endpoints
|
||||||
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
||||||
@@ -234,8 +239,11 @@ export interface IQwenOAuth2Client {
|
|||||||
*/
|
*/
|
||||||
export class QwenOAuth2Client implements IQwenOAuth2Client {
|
export class QwenOAuth2Client implements IQwenOAuth2Client {
|
||||||
private credentials: QwenCredentials = {};
|
private credentials: QwenCredentials = {};
|
||||||
|
private sharedManager: SharedTokenManager;
|
||||||
|
|
||||||
constructor(_options?: { proxy?: string }) {}
|
constructor() {
|
||||||
|
this.sharedManager = SharedTokenManager.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
setCredentials(credentials: QwenCredentials): void {
|
setCredentials(credentials: QwenCredentials): void {
|
||||||
this.credentials = credentials;
|
this.credentials = credentials;
|
||||||
@@ -246,18 +254,24 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAccessToken(): Promise<{ token?: string }> {
|
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()) {
|
if (this.credentials.access_token && this.isTokenValid()) {
|
||||||
return { token: this.credentials.access_token };
|
return { token: this.credentials.access_token };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.credentials.refresh_token) {
|
// If we can't get valid credentials through shared manager, fail gracefully
|
||||||
const refreshResponse = await this.refreshAccessToken();
|
// All token refresh operations should go through the SharedTokenManager
|
||||||
const tokenData = refreshResponse as TokenRefreshData;
|
|
||||||
return { token: tokenData.access_token };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token: undefined };
|
return { token: undefined };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async requestDeviceAuthorization(options: {
|
async requestDeviceAuthorization(options: {
|
||||||
scope: string;
|
scope: string;
|
||||||
@@ -289,7 +303,7 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = (await response.json()) as DeviceAuthorizationResponse;
|
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
|
// Check if the response indicates success
|
||||||
if (!isDeviceAuthorizationSuccess(result)) {
|
if (!isDeviceAuthorizationSuccess(result)) {
|
||||||
@@ -423,8 +437,8 @@ export class QwenOAuth2Client implements IQwenOAuth2Client {
|
|||||||
|
|
||||||
this.setCredentials(tokens);
|
this.setCredentials(tokens);
|
||||||
|
|
||||||
// Cache the updated credentials to file
|
// Note: File caching is now handled by SharedTokenManager
|
||||||
await cacheQwenCredentials(tokens);
|
// to prevent cross-session token invalidation issues
|
||||||
|
|
||||||
return responseData;
|
return responseData;
|
||||||
}
|
}
|
||||||
@@ -462,39 +476,55 @@ export const qwenOAuth2Events = new EventEmitter();
|
|||||||
export async function getQwenOAuthClient(
|
export async function getQwenOAuthClient(
|
||||||
config: Config,
|
config: Config,
|
||||||
): Promise<QwenOAuth2Client> {
|
): Promise<QwenOAuth2Client> {
|
||||||
const client = new QwenOAuth2Client({
|
const client = new QwenOAuth2Client();
|
||||||
proxy: config.getProxy(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// If there are cached creds on disk, they always take precedence
|
// Use shared token manager to get valid credentials with cross-session synchronization
|
||||||
if (await loadCachedQwenCredentials(client)) {
|
const sharedManager = SharedTokenManager.getInstance();
|
||||||
console.log('Loaded cached Qwen credentials.');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.refreshAccessToken();
|
// Try to get valid credentials from shared cache first
|
||||||
|
const credentials = await sharedManager.getValidCredentials(client);
|
||||||
|
client.setCredentials(credentials);
|
||||||
return client;
|
return client;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle refresh token errors
|
console.debug(
|
||||||
const errorMessage =
|
'Shared token manager failed, attempting device flow:',
|
||||||
error instanceof Error ? error.message : String(error);
|
error,
|
||||||
|
|
||||||
const isInvalidToken = errorMessage.includes(
|
|
||||||
'Refresh token expired or invalid',
|
|
||||||
);
|
);
|
||||||
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
|
// Handle specific token manager errors
|
||||||
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', userMessage);
|
if (error instanceof TokenManagerError) {
|
||||||
throw new Error(throwMessage);
|
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);
|
const result = await authWithQwenDeviceFlow(client, config);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Only emit timeout event if the failure reason is actually timeout
|
// Only emit timeout event if the failure reason is actually timeout
|
||||||
@@ -525,6 +555,7 @@ export async function getQwenOAuthClient(
|
|||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function authWithQwenDeviceFlow(
|
async function authWithQwenDeviceFlow(
|
||||||
client: QwenOAuth2Client,
|
client: QwenOAuth2Client,
|
||||||
@@ -580,7 +611,9 @@ async function authWithQwenDeviceFlow(
|
|||||||
// causing the entire Node.js process to crash.
|
// causing the entire Node.js process to crash.
|
||||||
if (childProcess) {
|
if (childProcess) {
|
||||||
childProcess.on('error', () => {
|
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();
|
showFallbackMessage();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -599,7 +632,7 @@ async function authWithQwenDeviceFlow(
|
|||||||
'Waiting for authorization...',
|
'Waiting for authorization...',
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Waiting for authorization...\n');
|
console.debug('Waiting for authorization...\n');
|
||||||
|
|
||||||
// Poll for the token
|
// Poll for the token
|
||||||
let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received
|
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++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
// Check if authentication was cancelled
|
// Check if authentication was cancelled
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
console.log('\nAuthentication cancelled by user.');
|
console.debug('\nAuthentication cancelled by user.');
|
||||||
qwenOAuth2Events.emit(
|
qwenOAuth2Events.emit(
|
||||||
QwenOAuth2Event.AuthProgress,
|
QwenOAuth2Event.AuthProgress,
|
||||||
'error',
|
'error',
|
||||||
@@ -620,7 +653,7 @@ async function authWithQwenDeviceFlow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('polling for token...');
|
console.debug('polling for token...');
|
||||||
const tokenResponse = await client.pollDeviceToken({
|
const tokenResponse = await client.pollDeviceToken({
|
||||||
device_code: deviceAuth.device_code,
|
device_code: deviceAuth.device_code,
|
||||||
code_verifier,
|
code_verifier,
|
||||||
@@ -653,7 +686,7 @@ async function authWithQwenDeviceFlow(
|
|||||||
'Authentication successful! Access token obtained.',
|
'Authentication successful! Access token obtained.',
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Authentication successful! Access token obtained.');
|
console.debug('Authentication successful! Access token obtained.');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -664,8 +697,8 @@ async function authWithQwenDeviceFlow(
|
|||||||
// Handle slow_down error by increasing poll interval
|
// Handle slow_down error by increasing poll interval
|
||||||
if (pendingData.slowDown) {
|
if (pendingData.slowDown) {
|
||||||
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
|
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
|
||||||
console.log(
|
console.debug(
|
||||||
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms`,
|
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
pollInterval = 2000; // Reset to default interval
|
pollInterval = 2000; // Reset to default interval
|
||||||
@@ -706,7 +739,7 @@ async function authWithQwenDeviceFlow(
|
|||||||
|
|
||||||
// Check for cancellation after waiting
|
// Check for cancellation after waiting
|
||||||
if (isCancelled) {
|
if (isCancelled) {
|
||||||
console.log('\nAuthentication cancelled by user.');
|
console.debug('\nAuthentication cancelled by user.');
|
||||||
qwenOAuth2Events.emit(
|
qwenOAuth2Events.emit(
|
||||||
QwenOAuth2Event.AuthProgress,
|
QwenOAuth2Event.AuthProgress,
|
||||||
'error',
|
'error',
|
||||||
@@ -834,7 +867,7 @@ export async function clearQwenCredentials(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const filePath = getQwenCachedCredentialPath();
|
const filePath = getQwenCachedCredentialPath();
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
console.log('Cached Qwen credentials cleared successfully.');
|
console.debug('Cached Qwen credentials cleared successfully.');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// If file doesn't exist or can't be deleted, we consider it cleared
|
// If file doesn't exist or can't be deleted, we consider it cleared
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
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