tasq/lib/services/gemini_service.dart

145 lines
5.0 KiB
Dart

import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:http/http.dart' as http;
class GeminiService {
static final GeminiService _instance = GeminiService._internal();
late final String _apiKey;
/// Cache of valid model IDs (flash/lite, supporting generateContent).
List<String> _validModels = [];
factory GeminiService() => _instance;
GeminiService._internal() {
final apiKey = dotenv.env['GEMINI_API_KEY'];
if (apiKey == null || apiKey.isEmpty) {
throw Exception('GEMINI_API_KEY not found in .env file');
}
_apiKey = apiKey;
}
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
/// Queries the Gemini REST API for available models and caches those that
/// - support `generateContent`, AND
/// - contain "flash" or "lite" in their name (free-tier / fast models).
///
/// Returns a stable cached list on subsequent calls.
Future<List<String>> _getValidModels() async {
if (_validModels.isNotEmpty) return _validModels;
try {
final uri = Uri.parse(
'https://generativelanguage.googleapis.com/v1beta/models?key=$_apiKey',
);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rawModels = (data['models'] as List<dynamic>?) ?? [];
final discovered = <String>[];
for (final m in rawModels) {
final fullName = (m['name'] as String? ?? '');
final lower = fullName.toLowerCase();
final methods =
(m['supportedGenerationMethods'] as List<dynamic>?) ?? [];
if (methods.contains('generateContent') &&
(lower.contains('flash') || lower.contains('lite'))) {
// Strip the "models/" prefix so it can be passed directly to
// GenerativeModel(model: ...).
final id = fullName.startsWith('models/')
? fullName.substring('models/'.length)
: fullName;
discovered.add(id);
}
}
// Sort descending so newer/more-capable models are tried first.
discovered.sort((a, b) => b.compareTo(a));
_validModels = discovered;
}
} catch (_) {
// Fall back to hard-coded list of known free-tier models below.
}
// If discovery failed or returned nothing, use safe known fallbacks.
if (_validModels.isEmpty) {
_validModels = [
'gemini-2.5-flash-lite',
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-1.5-flash',
];
}
return _validModels;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Fixes spelling/grammar, improves clarity, and translates [text] to
/// professional English.
///
/// Provide a custom [promptInstruction] to give Gemini field-specific
/// context (subject, description, action-taken). If omitted a sensible
/// default is used.
///
/// Automatically retries with the next available model on 429 / quota
/// errors to minimise wasted quota calls.
Future<String> enhanceText(String text, {String? promptInstruction}) async {
if (text.trim().isEmpty) return text;
final instruction =
promptInstruction ??
'Fix spelling and grammar, improve clarity, and translate to '
'professional English. Return ONLY the improved text, no explanations:';
final prompt = '$instruction\n\n"$text"';
return _generateWithRetry(prompt, fallback: text);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Sends [prompt] to Gemini, retrying across all valid models on 429 errors.
/// Returns the model response on success, or throws if all models fail.
Future<String> _generateWithRetry(
String prompt, {
required String fallback,
}) async {
final models = await _getValidModels();
for (int i = 0; i < models.length; i++) {
try {
final model = GenerativeModel(model: models[i], apiKey: _apiKey);
final response = await model.generateContent([Content.text(prompt)]);
return response.text ?? fallback;
} catch (e) {
final msg = e.toString().toLowerCase();
final is429 =
msg.contains('429') ||
msg.contains('quota') ||
msg.contains('resource_exhausted');
if (!is429 || i == models.length - 1) {
// Non-quota error or last model — give up.
throw Exception('Gemini request failed: $e');
}
// Quota hit — try the next model.
}
}
// Should not reach here, but safety fallback.
throw Exception('All Gemini models exhausted');
}
}