refactor: pull validateConnection / validateModelName / error mapping into base

Form Template Method / Pull Up Method from the Refactoring Guru catalog.

The three SDK-based providers (Claude, OpenAI, Gemini) each had their own
near-identical implementations of:

  - validateConnection: try a tiny request, catch errors, map a handful
    of patterns, warn on the rest
  - validateModelName: iterate regex patterns, warn on no match
  - handle{X}Error: switch on HTTP status, map to AIProviderError with
    brand-flavored messages

These collapse into three protected hooks on BaseAIProvider:

  - getModelNamePatterns(): RegExp[]
  - sendValidationProbe(): Promise<void>
  - mapProviderError(error): AIProviderError | null
  - providerErrorMessages(): Partial<Record<number, string>>

The base owns the wrapping logic (warning, status -> error-type map,
common message patterns). Providers only contribute their unique parts.

Side effects:

  - normalizeError now consults mapProviderError before generic mapping,
    so provider-specific patterns work via the existing try/catch in
    BaseAIProvider.complete/stream. The per-provider try/catch wrappers
    around doComplete/doStream are removed.
  - The unknown-error message becomes `${providerName} error: ${msg}`
    instead of the hardcoded `Provider error:` / `Claude API error:` etc.
  - OpenWebUI's HTTP-based validateConnection is preserved (override),
    since its non-SDK shape doesn't fit the SDK probe pattern.
This commit is contained in:
2026-05-21 13:43:07 +02:00
parent 8e73296ac2
commit b36d57711b
5 changed files with 352 additions and 717 deletions

View File

@@ -134,20 +134,16 @@ export class GeminiProvider extends BaseAIProvider {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
}
try {
const { systemInstruction, contents } = this.convertMessages(params.messages);
const model = params.model || this.defaultModel;
const { systemInstruction, contents } = this.convertMessages(params.messages);
const model = params.model || this.defaultModel;
const response = await this.client.models.generateContent({
model,
contents,
config: this.buildConfig(params, systemInstruction)
});
const response = await this.client.models.generateContent({
model,
contents,
config: this.buildConfig(params, systemInstruction)
});
return this.formatCompletionResponse(response, model);
} catch (error) {
throw this.handleGeminiError(error as Error);
}
return this.formatCompletionResponse(response, model);
}
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
@@ -155,20 +151,16 @@ export class GeminiProvider extends BaseAIProvider {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
}
try {
const { systemInstruction, contents } = this.convertMessages(params.messages);
const model = params.model || this.defaultModel;
const { systemInstruction, contents } = this.convertMessages(params.messages);
const model = params.model || this.defaultModel;
const stream = await this.client.models.generateContentStream({
model,
contents,
config: this.buildConfig(params, systemInstruction)
});
const stream = await this.client.models.generateContentStream({
model,
contents,
config: this.buildConfig(params, systemInstruction)
});
yield* this.processStreamChunks(stream);
} catch (error) {
throw this.handleGeminiError(error as Error);
}
yield* this.processStreamChunks(stream);
}
// ========================================================================
@@ -205,55 +197,8 @@ export class GeminiProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS
// ========================================================================
private async validateConnection(): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
try {
await this.client.models.generateContent({
model: this.defaultModel,
contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
config: { maxOutputTokens: 1 }
});
} catch (error: any) {
if (error.message?.includes('API key')) {
throw new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.message?.includes('quota') || error.message?.includes('billing')) {
throw new AIProviderError(
'API quota exceeded or billing issue. Please check your Google Cloud billing and API quotas.',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.message?.includes('model')) {
throw new AIProviderError(
`Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`,
AIErrorType.MODEL_NOT_FOUND,
error.status
);
}
console.warn('Gemini connection validation warning:', error.message);
}
}
private validateModelName(modelName: string): void {
if (!modelName || typeof modelName !== 'string') {
throw new AIProviderError(
'Model name must be a non-empty string',
AIErrorType.INVALID_REQUEST
);
}
const validPatterns = [
protected override getModelNamePatterns(): RegExp[] {
return [
/^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
/^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
/^gemini-1\.5-pro(?:-latest|-vision)?$/,
@@ -262,12 +207,72 @@ export class GeminiProvider extends BaseAIProvider {
/^gemini-pro(?:-vision)?$/,
/^models\/gemini-.+$/
];
}
const isValid = validPatterns.some(pattern => pattern.test(modelName));
if (!isValid) {
console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`);
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) {
throw new Error('Client not initialized');
}
await this.client.models.generateContent({
model: this.defaultModel,
contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
config: { maxOutputTokens: 1 }
});
}
protected override providerErrorMessages(): Partial<Record<number, string>> {
return {
401: 'Authentication failed with Gemini API. Please check your API key and permissions.',
403: 'Authentication failed with Gemini API. Please check your API key and permissions.',
404: 'Gemini API endpoint not found. Please check the model name and API version.',
429: 'Rate limit exceeded. Please reduce request frequency.',
500: 'Gemini service temporarily unavailable. Please try again in a few moments.',
502: 'Gemini service temporarily unavailable. Please try again in a few moments.',
503: 'Gemini service temporarily unavailable. Please try again in a few moments.'
};
}
protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
const status = error.status || error.statusCode;
if (message.includes('API key')) {
return new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION, status, error
);
}
if (message.includes('quota') || message.includes('limit exceeded')) {
return new AIProviderError(
'API quota exceeded. Please check your Google Cloud quotas and billing.',
AIErrorType.RATE_LIMIT, status, error
);
}
if (message.includes('model not found') || message.includes('invalid model')) {
return new AIProviderError(
'Model not found. The specified Gemini model may not exist or be available to your API key.',
AIErrorType.MODEL_NOT_FOUND, status, error
);
}
if (message.includes('safety') || message.includes('blocked')) {
return new AIProviderError(
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
AIErrorType.INVALID_REQUEST, status, error
);
}
if (message.includes('length') || message.includes('token')) {
return new AIProviderError(
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
AIErrorType.INVALID_REQUEST, status, error
);
}
return null;
}
private convertMessages(messages: AIMessage[]): ProcessedMessages {
@@ -389,128 +394,4 @@ export class GeminiProvider extends BaseAIProvider {
};
}
private handleGeminiError(error: any): AIProviderError {
if (error instanceof AIProviderError) {
return error;
}
const message = error.message || 'Unknown Gemini API error';
const status = error.status || error.statusCode;
if (message.includes('API key')) {
return new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION,
status,
error
);
}
if (message.includes('quota') || message.includes('limit exceeded')) {
return new AIProviderError(
'API quota exceeded. Please check your Google Cloud quotas and billing.',
AIErrorType.RATE_LIMIT,
status,
error
);
}
if (message.includes('model not found') || message.includes('invalid model')) {
return new AIProviderError(
'Model not found. The specified Gemini model may not exist or be available to your API key.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
if (message.includes('safety') || message.includes('blocked')) {
return new AIProviderError(
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('length') || message.includes('token')) {
return new AIProviderError(
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('timeout')) {
return new AIProviderError(
'Request timed out. Gemini may be experiencing high load.',
AIErrorType.TIMEOUT,
status,
error
);
}
if (message.includes('network') || message.includes('connection')) {
return new AIProviderError(
'Network error connecting to Gemini servers.',
AIErrorType.NETWORK,
status,
error
);
}
switch (status) {
case 400:
return new AIProviderError(
`Invalid request to Gemini API: ${message}`,
AIErrorType.INVALID_REQUEST,
status,
error
);
case 401:
case 403:
return new AIProviderError(
'Authentication failed with Gemini API. Please check your API key and permissions.',
AIErrorType.AUTHENTICATION,
status,
error
);
case 404:
return new AIProviderError(
'Gemini API endpoint not found. Please check the model name and API version.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
case 429:
return new AIProviderError(
'Rate limit exceeded. Please reduce request frequency.',
AIErrorType.RATE_LIMIT,
status,
error
);
case 500:
case 502:
case 503:
return new AIProviderError(
'Gemini service temporarily unavailable. Please try again in a few moments.',
AIErrorType.NETWORK,
status,
error
);
default:
return new AIProviderError(
`Gemini API error: ${message}`,
AIErrorType.UNKNOWN,
status,
error
);
}
}
}