fbf04d90db
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.
184 lines
6.1 KiB
Dart
184 lines
6.1 KiB
Dart
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 'webview_base_provider.dart';
|
|
|
|
/// Duck.ai / DuckDuckGo chat. REST first; WebView executes the JS anti-bot hash.
|
|
class DuckDuckGo extends WebViewBaseProvider {
|
|
@override
|
|
String get name => 'DuckDuckGo';
|
|
|
|
@override
|
|
bool get working => true;
|
|
|
|
@override
|
|
List<String> get models => [
|
|
'gpt-5.6-luna',
|
|
'gpt-5.4-mini',
|
|
'claude-haiku-4-5',
|
|
'mistralai/Mistral-Small-24B-Instruct-2501',
|
|
'meta-llama/Llama-4-Scout',
|
|
];
|
|
|
|
@override
|
|
String get defaultModel => 'gpt-5.6-luna';
|
|
|
|
@override
|
|
String get baseUrl => 'https://duck.ai/';
|
|
|
|
@override
|
|
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
|
|
|
|
static const _statusUrl = 'https://duckduckgo.com/duckchat/v1/status';
|
|
static const _chatUrl = 'https://duckduckgo.com/duckchat/v1/chat';
|
|
|
|
@override
|
|
Stream<String> createAsyncGenerator(
|
|
String model,
|
|
List<Message> messages, {
|
|
bool stream = false,
|
|
}) async* {
|
|
try {
|
|
await for (final chunk in _tryRest(model, messages, stream: stream)) {
|
|
yield chunk;
|
|
}
|
|
return;
|
|
} catch (_) {
|
|
// 418 challenge — fall through to a real browser context.
|
|
}
|
|
await for (final chunk in super.createAsyncGenerator(model, messages, stream: stream)) {
|
|
yield chunk;
|
|
}
|
|
}
|
|
|
|
Stream<String> _tryRest(
|
|
String model,
|
|
List<Message> messages, {
|
|
required bool stream,
|
|
}) async* {
|
|
final statusReq = http.Request('GET', Uri.parse(_statusUrl));
|
|
statusReq.headers.addAll(browserHeaders(
|
|
origin: 'https://duckduckgo.com',
|
|
referer: 'https://duckduckgo.com/',
|
|
accept: 'application/json',
|
|
extra: {'x-vqd-accept': '1'},
|
|
));
|
|
final statusRes = await g4fHttp.send(statusReq).timeout(const Duration(seconds: 12));
|
|
final vqd4 = statusRes.headers['x-vqd-4'];
|
|
final hash1 = statusRes.headers['x-vqd-hash-1'];
|
|
await statusRes.stream.drain();
|
|
if (vqd4 == null || vqd4.isEmpty) {
|
|
throw Exception('DuckDuckGo: no x-vqd-4 (challenge required)');
|
|
}
|
|
|
|
final chatReq = http.Request('POST', Uri.parse(_chatUrl));
|
|
chatReq.headers.addAll(browserHeaders(
|
|
origin: 'https://duckduckgo.com',
|
|
referer: 'https://duckduckgo.com/',
|
|
contentType: 'application/json',
|
|
accept: 'text/event-stream',
|
|
extra: {
|
|
'x-vqd-4': vqd4,
|
|
if (hash1 != null && hash1.isNotEmpty) 'x-vqd-hash-1': hash1,
|
|
},
|
|
));
|
|
chatReq.body = jsonEncode({
|
|
'model': models.contains(model) ? model : defaultModel,
|
|
'messages': toOpenAiMessages(messages),
|
|
'canUseTools': false,
|
|
'canUseApproxLocation': false,
|
|
});
|
|
|
|
final chatRes = await g4fHttp.send(chatReq).timeout(const Duration(seconds: 25));
|
|
if (chatRes.statusCode != 200) {
|
|
throw Exception('DuckDuckGo chat ${chatRes.statusCode}');
|
|
}
|
|
|
|
final collected = StringBuffer();
|
|
await for (final line in splitSseLines(chatRes.stream.transform(utf8.decoder))) {
|
|
final trimmed = line.trim();
|
|
if (trimmed.isEmpty || !trimmed.startsWith('data:')) continue;
|
|
final content = extractSseContent(trimmed.substring(5).trim());
|
|
if (content != null && content.isNotEmpty) {
|
|
collected.write(content);
|
|
if (stream) yield content;
|
|
}
|
|
}
|
|
final text = collected.toString();
|
|
if (!looksLikeModelOutput(text)) {
|
|
throw Exception('DuckDuckGo empty/challenge response');
|
|
}
|
|
if (!stream) yield text;
|
|
}
|
|
|
|
@override
|
|
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
|
final messagesJson = jsonEncode(toOpenAiMessages(messages));
|
|
final modelJson = jsonEncode(models.contains(model) ? model : defaultModel);
|
|
return '''
|
|
(async function() {
|
|
const status = await fetch('https://duckduckgo.com/duckchat/v1/status', {
|
|
headers: { 'x-vqd-accept': '1', 'Accept': 'application/json' }
|
|
});
|
|
let vqd4 = status.headers.get('x-vqd-4') || '';
|
|
let hash1 = status.headers.get('x-vqd-hash-1') || '';
|
|
if (hash1) {
|
|
try {
|
|
let src = hash1;
|
|
try { src = atob(hash1); } catch (e) {}
|
|
if (src.includes('function') || src.trim().startsWith('(')) {
|
|
const result = await (0, eval)(src);
|
|
const value = (typeof result === 'function') ? await result() : result;
|
|
hash1 = btoa(JSON.stringify(value));
|
|
}
|
|
} catch (e) {
|
|
return JSON.stringify({error: 'hash failed: ' + e});
|
|
}
|
|
}
|
|
const headers = {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'text/event-stream'
|
|
};
|
|
if (vqd4) headers['x-vqd-4'] = vqd4;
|
|
if (hash1) headers['x-vqd-hash-1'] = hash1;
|
|
const resp = await fetch('https://duckduckgo.com/duckchat/v1/chat', {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({
|
|
model: $modelJson,
|
|
messages: $messagesJson,
|
|
canUseTools: false,
|
|
canUseApproxLocation: false
|
|
})
|
|
});
|
|
const body = await resp.text();
|
|
return JSON.stringify({status: resp.status, body: body});
|
|
})();
|
|
''';
|
|
}
|
|
|
|
@override
|
|
String parseResponse(String rawResponse) {
|
|
try {
|
|
final json = jsonDecode(rawResponse);
|
|
if (json is Map && json['error'] != null) {
|
|
throw Exception(json['error']);
|
|
}
|
|
final body = json is Map ? (json['body']?.toString() ?? rawResponse) : rawResponse;
|
|
final text = extractAllSseContent(body).join();
|
|
if (!looksLikeModelOutput(text)) {
|
|
throw Exception('DuckDuckGo WebView unusable: ${body.substring(0, body.length.clamp(0, 180))}');
|
|
}
|
|
return text;
|
|
} catch (e) {
|
|
if (e.toString().contains('DuckDuckGo')) rethrow;
|
|
final text = extractAllSseContent(rawResponse).join();
|
|
if (looksLikeModelOutput(text)) return text;
|
|
throw Exception('Failed to parse DuckDuckGo: $e');
|
|
}
|
|
}
|
|
}
|