feat: embed a Flutter G4F client with Cronet, cookie jar, and origin WebView
Port the Oracle off the Python g4f container onto on-device providers: Chromium HTTP via Cronet, a shared cookie session, IterList retry, hosted OpenAI-compat aggregators, and same-origin WebView fallbacks instead of CORS fetch.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../http_client.dart';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import '../sse.dart';
|
||||
import 'base_provider.dart';
|
||||
|
||||
/// Port of g4f.Provider.OperaAria — anonymous OAuth, collect SSE to one reply.
|
||||
class OperaAria extends BaseProvider {
|
||||
@override
|
||||
String get name => 'OperaAria';
|
||||
|
||||
@override
|
||||
bool get working => true;
|
||||
|
||||
@override
|
||||
List<String> get models => ['aria'];
|
||||
|
||||
@override
|
||||
String get defaultModel => 'aria';
|
||||
|
||||
static const _tokenUrl = 'https://oauth2.opera-api.com/oauth2/v1/token/';
|
||||
static const _signupUrl =
|
||||
'https://auth.opera.com/account/v2/external/anonymous/signup';
|
||||
static const _chatUrl = 'https://composer.opera-api.com/api/v2/a-chat';
|
||||
static const _secret =
|
||||
'Pcc5NvlCrxl02pMw32kO6WrnhpS0pUZ95YrDP8XNKJJQvFht4wQDkFJ7v9x5hn7C';
|
||||
|
||||
@override
|
||||
Stream<String> createAsyncGenerator(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
bool stream = false,
|
||||
}) async* {
|
||||
final tokenHeaders = {
|
||||
'User-Agent': 'okhttp/5.3.2',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
'x-opera-client-cache': '1',
|
||||
};
|
||||
final anon = await g4fHttp.post(
|
||||
Uri.parse(_tokenUrl),
|
||||
headers: tokenHeaders,
|
||||
body: {
|
||||
'client_id': 'mini-client',
|
||||
'client_secret': _secret,
|
||||
'grant_type': 'client_credentials',
|
||||
'scope': 'anonymous_account',
|
||||
},
|
||||
).timeout(const Duration(seconds: 12));
|
||||
if (anon.statusCode != 200) {
|
||||
throw Exception('Opera anon ${anon.statusCode}');
|
||||
}
|
||||
final anonToken = jsonDecode(anon.body)['access_token'] as String;
|
||||
|
||||
final signup = await g4fHttp.post(
|
||||
Uri.parse(_signupUrl),
|
||||
headers: {
|
||||
'User-Agent': 'okhttp/5.3.2',
|
||||
'Authorization': 'Bearer $anonToken',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
'x-opera-client-cache': '1',
|
||||
},
|
||||
body: jsonEncode({'client_id': 'mini'}),
|
||||
).timeout(const Duration(seconds: 12));
|
||||
if (signup.statusCode != 200) {
|
||||
throw Exception('Opera signup ${signup.statusCode}');
|
||||
}
|
||||
final authToken = jsonDecode(signup.body)['token'] as String;
|
||||
|
||||
final refresh = await g4fHttp.post(
|
||||
Uri.parse(_tokenUrl),
|
||||
headers: tokenHeaders,
|
||||
body: {
|
||||
'auth_token': authToken,
|
||||
'client_id': 'mini',
|
||||
'grant_type': 'auth_token',
|
||||
'scope': 'shodan:aria',
|
||||
},
|
||||
).timeout(const Duration(seconds: 12));
|
||||
if (refresh.statusCode != 200) {
|
||||
throw Exception('Opera refresh ${refresh.statusCode}');
|
||||
}
|
||||
final access = jsonDecode(refresh.body)['access_token'] as String;
|
||||
|
||||
final req = http.Request('POST', Uri.parse(_chatUrl));
|
||||
req.headers.addAll({
|
||||
'Accept': 'text/event-stream',
|
||||
'Authorization': 'Bearer $access',
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://composer.opera-api.com',
|
||||
'Referer': 'https://composer.opera-api.com/assets/aria/index.html',
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Linux; U; Android 14; Pixel 8 Pro Build/UQ1A.240205.004; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Mobile Safari/537.36 OPR/99.0.2254.81922',
|
||||
'X-Opera-Timezone': '+03:00',
|
||||
'X-Opera-UI-Language': 'en',
|
||||
'X-Requested-With': 'com.opera.mini.native',
|
||||
});
|
||||
req.body = jsonEncode({
|
||||
'query': formatPrompt(messages),
|
||||
'sia': true,
|
||||
'think_harder': false,
|
||||
'supported_features': [],
|
||||
'file_attachments': [],
|
||||
});
|
||||
final res = await g4fHttp.send(req).timeout(requestTimeout);
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Opera chat ${res.statusCode}');
|
||||
}
|
||||
final raw = utf8.decode(await res.stream.toBytes().timeout(requestTimeout));
|
||||
final buf = StringBuffer();
|
||||
for (final piece in extractAllSseContent(raw)) {
|
||||
buf.write(piece);
|
||||
}
|
||||
if (buf.isEmpty) {
|
||||
// v2 envelope: {"response":{"message":"..."}}
|
||||
for (final line in raw.split('\n')) {
|
||||
if (!line.contains('{')) continue;
|
||||
try {
|
||||
final data = jsonDecode(line.startsWith('data:') ? line.substring(5) : line);
|
||||
if (data is Map) {
|
||||
final msg = data['response'] is Map
|
||||
? (data['response'] as Map)['message']
|
||||
: data['message'];
|
||||
if (msg is String) buf.write(msg);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
final text = buf.toString().trim();
|
||||
if (!looksLikeModelOutput(text)) throw Exception('Opera empty');
|
||||
yield text;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user