feat(oauth): add Qwen OAuth integration

This commit is contained in:
mingholy.lmh
2025-08-08 09:48:31 +08:00
parent ffc2d27ca3
commit ea7dcf8347
37 changed files with 7795 additions and 169 deletions

View File

@@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
isQwenQuotaExceededError,
isQwenThrottlingError,
isProQuotaExceededError,
isGenericQuotaExceededError,
isApiError,
isStructuredError,
type ApiError,
} from './quotaErrorDetection.js';
describe('quotaErrorDetection', () => {
describe('isQwenQuotaExceededError', () => {
it('should detect insufficient_quota error message', () => {
const error = new Error('insufficient_quota');
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should detect free allocated quota exceeded error message', () => {
const error = new Error('Free allocated quota exceeded.');
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should detect quota exceeded error message', () => {
const error = new Error('quota exceeded');
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should detect quota exceeded in string error', () => {
const error = 'insufficient_quota';
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should detect quota exceeded in structured error', () => {
const error = { message: 'Free allocated quota exceeded.', status: 429 };
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should detect quota exceeded in API error', () => {
const error: ApiError = {
error: {
code: 429,
message: 'insufficient_quota',
status: 'RESOURCE_EXHAUSTED',
details: [],
},
};
expect(isQwenQuotaExceededError(error)).toBe(true);
});
it('should not detect throttling errors as quota exceeded', () => {
const error = new Error('requests throttling triggered');
expect(isQwenQuotaExceededError(error)).toBe(false);
});
it('should not detect unrelated errors', () => {
const error = new Error('Network error');
expect(isQwenQuotaExceededError(error)).toBe(false);
});
});
describe('isQwenThrottlingError', () => {
it('should detect throttling error with 429 status', () => {
const error = { message: 'throttling', status: 429 };
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect requests throttling triggered with 429 status', () => {
const error = { message: 'requests throttling triggered', status: 429 };
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect rate limit error with 429 status', () => {
const error = { message: 'rate limit exceeded', status: 429 };
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect too many requests with 429 status', () => {
const error = { message: 'too many requests', status: 429 };
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect throttling in string error', () => {
const error = 'throttling';
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect throttling in structured error with 429', () => {
const error = { message: 'requests throttling triggered', status: 429 };
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should detect throttling in API error with 429', () => {
const error: ApiError = {
error: {
code: 429,
message: 'throttling',
status: 'RESOURCE_EXHAUSTED',
details: [],
},
};
expect(isQwenThrottlingError(error)).toBe(true);
});
it('should not detect throttling without 429 status in structured error', () => {
const error = { message: 'throttling', status: 500 };
expect(isQwenThrottlingError(error)).toBe(false);
});
it('should not detect quota exceeded as throttling', () => {
const error = { message: 'insufficient_quota', status: 429 };
expect(isQwenThrottlingError(error)).toBe(false);
});
it('should not detect unrelated errors as throttling', () => {
const error = { message: 'Network error', status: 500 };
expect(isQwenThrottlingError(error)).toBe(false);
});
});
describe('isProQuotaExceededError', () => {
it('should detect Gemini Pro quota exceeded error', () => {
const error = new Error(
"Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
);
expect(isProQuotaExceededError(error)).toBe(true);
});
it('should detect Gemini preview Pro quota exceeded error', () => {
const error = new Error(
"Quota exceeded for quota metric 'Gemini 2.5-preview Pro Requests'",
);
expect(isProQuotaExceededError(error)).toBe(true);
});
it('should not detect non-Pro quota errors', () => {
const error = new Error(
"Quota exceeded for quota metric 'Gemini 1.5 Flash Requests'",
);
expect(isProQuotaExceededError(error)).toBe(false);
});
});
describe('isGenericQuotaExceededError', () => {
it('should detect generic quota exceeded error', () => {
const error = new Error('Quota exceeded for quota metric');
expect(isGenericQuotaExceededError(error)).toBe(true);
});
it('should not detect non-quota errors', () => {
const error = new Error('Network error');
expect(isGenericQuotaExceededError(error)).toBe(false);
});
});
describe('type guards', () => {
describe('isApiError', () => {
it('should detect valid API error', () => {
const error: ApiError = {
error: {
code: 429,
message: 'test error',
status: 'RESOURCE_EXHAUSTED',
details: [],
},
};
expect(isApiError(error)).toBe(true);
});
it('should not detect invalid API error', () => {
const error = { message: 'test error' };
expect(isApiError(error)).toBe(false);
});
});
describe('isStructuredError', () => {
it('should detect valid structured error', () => {
const error = { message: 'test error', status: 429 };
expect(isStructuredError(error)).toBe(true);
});
it('should not detect invalid structured error', () => {
const error = { code: 429 };
expect(isStructuredError(error)).toBe(false);
});
});
});
});

View File

@@ -101,3 +101,70 @@ export function isGenericQuotaExceededError(error: unknown): boolean {
return false;
}
export function isQwenQuotaExceededError(error: unknown): boolean {
// Check for Qwen insufficient quota errors (should not retry)
const checkMessage = (message: string): boolean => {
const lowerMessage = message.toLowerCase();
return (
lowerMessage.includes('insufficient_quota') ||
lowerMessage.includes('free allocated quota exceeded') ||
(lowerMessage.includes('quota') && lowerMessage.includes('exceeded'))
);
};
if (typeof error === 'string') {
return checkMessage(error);
}
if (isStructuredError(error)) {
return checkMessage(error.message);
}
if (isApiError(error)) {
return checkMessage(error.error.message);
}
return false;
}
export function isQwenThrottlingError(error: unknown): boolean {
// Check for Qwen throttling errors (should retry)
const checkMessage = (message: string): boolean => {
const lowerMessage = message.toLowerCase();
return (
lowerMessage.includes('throttling') ||
lowerMessage.includes('requests throttling triggered') ||
lowerMessage.includes('rate limit') ||
lowerMessage.includes('too many requests')
);
};
// Check status code
const getStatusCode = (error: unknown): number | undefined => {
if (error && typeof error === 'object') {
const errorObj = error as { status?: number; code?: number };
return errorObj.status || errorObj.code;
}
return undefined;
};
const statusCode = getStatusCode(error);
if (typeof error === 'string') {
return (
(statusCode === 429 && checkMessage(error)) ||
error.includes('throttling')
);
}
if (isStructuredError(error)) {
return statusCode === 429 && checkMessage(error.message);
}
if (isApiError(error)) {
return error.error.code === 429 && checkMessage(error.error.message);
}
return false;
}

View File

@@ -8,6 +8,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { retryWithBackoff, HttpError } from './retry.js';
import { setSimulate429 } from './testUtils.js';
import { AuthType } from '../core/contentGenerator.js';
// Helper to create a mock function that fails a certain number of times
const createFailingFunction = (
@@ -399,4 +400,173 @@ describe('retryWithBackoff', () => {
expect(fallbackCallback).toHaveBeenCalledWith('oauth-personal');
});
});
describe('Qwen OAuth 429 error handling', () => {
it('should retry for Qwen OAuth 429 errors that are throttling-related', async () => {
const errorWith429: HttpError = new Error('Rate limit exceeded');
errorWith429.status = 429;
const fn = vi
.fn()
.mockRejectedValueOnce(errorWith429)
.mockResolvedValue('success');
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 1000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
// Fast-forward time for delays
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
// Should be called twice (1 failure + 1 success)
expect(fn).toHaveBeenCalledTimes(2);
});
it('should throw immediately for Qwen OAuth with insufficient_quota message', async () => {
const errorWithInsufficientQuota = new Error('insufficient_quota');
const fn = vi.fn().mockRejectedValue(errorWithInsufficientQuota);
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 1000,
maxDelayMs: 5000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
// Should be called only once (no retries)
expect(fn).toHaveBeenCalledTimes(1);
});
it('should throw immediately for Qwen OAuth with free allocated quota exceeded message', async () => {
const errorWithQuotaExceeded = new Error(
'Free allocated quota exceeded.',
);
const fn = vi.fn().mockRejectedValue(errorWithQuotaExceeded);
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 1000,
maxDelayMs: 5000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
// Should be called only once (no retries)
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry for Qwen OAuth with throttling message', async () => {
const throttlingError: HttpError = new Error(
'requests throttling triggered',
);
throttlingError.status = 429;
const fn = vi
.fn()
.mockRejectedValueOnce(throttlingError)
.mockRejectedValueOnce(throttlingError)
.mockResolvedValue('success');
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 1000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
// Fast-forward time for delays
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
// Should be called 3 times (2 failures + 1 success)
expect(fn).toHaveBeenCalledTimes(3);
});
it('should retry for Qwen OAuth with throttling error', async () => {
const throttlingError: HttpError = new Error('throttling');
throttlingError.status = 429;
const fn = vi
.fn()
.mockRejectedValueOnce(throttlingError)
.mockResolvedValue('success');
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 1000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
// Fast-forward time for delays
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
// Should be called 2 times (1 failure + 1 success)
expect(fn).toHaveBeenCalledTimes(2);
});
it('should throw immediately for Qwen OAuth with quota message', async () => {
const errorWithQuota = new Error('quota exceeded');
const fn = vi.fn().mockRejectedValue(errorWithQuota);
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 1000,
maxDelayMs: 5000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
await expect(promise).rejects.toThrow(/Qwen API quota exceeded/);
// Should be called only once (no retries)
expect(fn).toHaveBeenCalledTimes(1);
});
it('should retry normal errors for Qwen OAuth (not quota-related)', async () => {
const normalError: HttpError = new Error('Network error');
normalError.status = 500;
const fn = createFailingFunction(2, 'success');
// Replace the default 500 error with our normal error
fn.mockRejectedValueOnce(normalError)
.mockRejectedValueOnce(normalError)
.mockResolvedValue('success');
const promise = retryWithBackoff(fn, {
maxAttempts: 5,
initialDelayMs: 100,
maxDelayMs: 1000,
shouldRetry: () => true,
authType: AuthType.QWEN_OAUTH,
});
// Fast-forward time for delays
await vi.runAllTimersAsync();
await expect(promise).resolves.toBe('success');
// Should be called 3 times (2 failures + 1 success)
expect(fn).toHaveBeenCalledTimes(3);
});
});
});

View File

@@ -8,6 +8,8 @@ import { AuthType } from '../core/contentGenerator.js';
import {
isProQuotaExceededError,
isGenericQuotaExceededError,
isQwenQuotaExceededError,
isQwenThrottlingError,
} from './quotaErrorDetection.js';
export interface HttpError extends Error {
@@ -150,9 +152,23 @@ export async function retryWithBackoff<T>(
}
}
// Track consecutive 429 errors
// Check for Qwen OAuth quota exceeded error - throw immediately without retry
if (authType === AuthType.QWEN_OAUTH && isQwenQuotaExceededError(error)) {
throw new Error(
`Qwen API quota exceeded: Your Qwen API quota has been exhausted. Please wait for your quota to reset.`,
);
}
// Track consecutive 429 errors, but handle Qwen throttling differently
if (errorStatus === 429) {
consecutive429Count++;
// For Qwen throttling errors, we still want to track them for exponential backoff
// but not for quota fallback logic (since Qwen doesn't have model fallback)
if (authType === AuthType.QWEN_OAUTH && isQwenThrottlingError(error)) {
// Keep track of 429s but reset the consecutive count to avoid fallback logic
consecutive429Count = 0;
} else {
consecutive429Count++;
}
} else {
consecutive429Count = 0;
}