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 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://duckduckgo.com/'; @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 createAsyncGenerator( String model, List 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 _tryRest( String model, List 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 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'); } } }