mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
feat(oauth): add Qwen OAuth integration
This commit is contained in:
194
packages/core/src/utils/quotaErrorDetection.test.ts
Normal file
194
packages/core/src/utils/quotaErrorDetection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user