)}
diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx
index f4057a4f2b8..0c05bec75ad 100644
--- a/apps/desktop/src/app/skills/index.test.tsx
+++ b/apps/desktop/src/app/skills/index.test.tsx
@@ -2,6 +2,7 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
+import type * as ReactRouterDom from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesApi from '@/hermes'
@@ -35,6 +36,15 @@ vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
+// The vision detail navigates to Settings → Models via useNavigate; spy on it
+// so the deep-link target is assertable.
+const navigateSpy = vi.fn()
+
+vi.mock('react-router-dom', async importOriginal => ({
+ ...(await importOriginal()),
+ useNavigate: () => navigateSpy
+}))
+
function toolset(overrides: Record = {}) {
return {
name: 'web',
@@ -115,4 +125,32 @@ describe('SkillsView toolset management', () => {
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
+
+ it('shows a vision explainer that deep-links to Settings → Models', async () => {
+ // Vision has no TOOL_CATEGORIES provider matrix — its model lives in the
+ // auxiliary model config, so the detail pane must point there instead of
+ // rendering an empty panel.
+ getToolsets.mockResolvedValue([
+ toolset({
+ name: 'vision',
+ label: 'Vision / Image Analysis',
+ description: 'vision_analyze',
+ tools: ['vision_analyze']
+ })
+ ])
+ getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
+
+ await renderSkills()
+
+ expect(await screen.findByText(/auxiliary model configuration/)).toBeTruthy()
+ const link = screen.getByRole('button', { name: /Choose vision model in Settings/ })
+
+ await act(async () => {
+ fireEvent.click(link)
+ })
+
+ // Internal route change into the Models section with the aux slot target —
+ // consumed by ModelSettings' deep-link highlight. Never an external URL.
+ await waitFor(() => expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=config:model&aux=vision'))
+ })
})
diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx
index ddaf9e6c898..6349e39a00a 100644
--- a/apps/desktop/src/app/skills/index.tsx
+++ b/apps/desktop/src/app/skills/index.tsx
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-dialog'
import { CodeEditor } from '@/components/chat/code-editor'
@@ -46,6 +47,7 @@ import {
} from '../master-detail'
import { PanelEmpty, PanelPill } from '../overlays/panel'
import { PageSearchShell } from '../page-search-shell'
+import { SETTINGS_ROUTE } from '../routes'
import { ComputerUsePanel } from '../settings/computer-use-panel'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { TerminalBackendPanel } from '../settings/terminal-backend-panel'
@@ -760,6 +762,7 @@ function ToolsetDetail({
onConfiguredChange: () => void
}) {
const { t } = useI18n()
+ const navigate = useNavigate()
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
@@ -783,6 +786,26 @@ function ToolsetDetail({
))}
)}
+ {toolset.name === 'vision' && (
+ // Vision has no provider matrix — model resolution runs through the
+ // auxiliary model config. Point at the actual home (Settings → Models,
+ // aux "vision" row) via an internal deep link instead of leaving the
+ // detail pane empty.
+
+
+ {t.skills.visionModelHint}
+
+
+
+
+
+ )}
{toolset.name === 'computer_use' && }
{toolset.name === 'terminal' && }
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 9f8de233598..f86843cd3b5 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -869,6 +869,8 @@ export interface SelectToolsetProviderResponse {
ok: boolean
name: string
provider: string
+ /** Present when the selection was scoped to one web capability. */
+ capability?: string
/** Present (true) when a managed Nous row was selected but the Portal
* entitlement is missing — the row won't activate until the user signs
* in to Nous Portal. */
@@ -877,12 +879,16 @@ export interface SelectToolsetProviderResponse {
feature?: string
}
-export function selectToolsetProvider(name: string, provider: string): Promise {
+export function selectToolsetProvider(
+ name: string,
+ provider: string,
+ capability?: 'search' | 'extract'
+): Promise {
return window.hermesDesktop.api({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
- body: { provider }
+ body: capability ? { provider, capability } : { provider }
})
}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 6adeee5cde2..7765c0f7468 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -537,6 +537,7 @@ export const en: Translations = {
envActions: {
actionsFor: label => `Actions for ${label}`,
credentialActions: 'Credential actions',
+ manageInKeys: 'Manage in API Keys',
docs: 'Docs',
hideValue: 'Hide value',
revealValue: 'Reveal value',
@@ -832,7 +833,7 @@ export const en: Translations = {
noApiKeyRequired: 'No API key required.',
postSetupHint: step =>
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
- postSetupInstalledHint: 'This backend is installed on this machine.',
+ postSetupInstalledHint: 'Installed. Re-run setup only if something is broken.',
postSetupRun: 'Run setup',
postSetupRerun: 'Re-run setup',
postSetupInstalled: 'Installed',
@@ -843,6 +844,15 @@ export const en: Translations = {
postSetupErrorTitle: 'Setup finished with errors',
postSetupErrorMessage: step => `Check the ${step} log.`,
postSetupFailed: step => `Failed to run ${step} setup`,
+ webSearchActive: backend => `Search: ${backend}`,
+ webExtractActive: backend => `Extract: ${backend}`,
+ webCapabilityUnset: 'not set',
+ webUseForSearch: 'Use for Search',
+ webUseForExtract: 'Use for Extract',
+ webUsedForSearch: 'Search backend',
+ webUsedForExtract: 'Extract backend',
+ webCapabilitySelectedMessage: (provider, capability) => `${provider} now handles web ${capability}.`,
+ failedSelectCapability: provider => `Failed to set ${provider}`,
loadingModels: 'Loading model catalog...',
modelSectionTitle: 'Model',
modelCount: count => `${count} model${count === 1 ? '' : 's'}`,
@@ -886,6 +896,9 @@ export const en: Translations = {
noDescription: 'No description.',
configured: 'Configured',
needsKeys: 'Needs keys',
+ visionModelHint:
+ 'Vision uses your auxiliary model configuration — the image-capable model is picked there, not per-provider here.',
+ visionModelLink: 'Choose vision model in Settings → Models',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
configureToolset: label => `Configure ${label}`,
toggleToolset: label => `Toggle ${label} toolset`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 235ae45970b..fcc27776201 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -635,6 +635,7 @@ export const ja = defineLocale({
envActions: {
actionsFor: label => `${label} のアクション`,
credentialActions: '認証情報のアクション',
+ manageInKeys: 'API キーで管理',
docs: 'ドキュメント',
hideValue: '値を非表示',
revealValue: '値を表示',
@@ -872,7 +873,7 @@ export const ja = defineLocale({
noApiKeyRequired: 'API キーは不要です。',
postSetupHint: step =>
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
- postSetupInstalledHint: 'このバックエンドはこのマシンにインストール済みです。',
+ postSetupInstalledHint: 'インストール済みです。問題がある場合のみセットアップを再実行してください。',
postSetupRun: 'セットアップを実行',
postSetupRerun: 'セットアップを再実行',
postSetupInstalled: 'インストール済み',
@@ -883,6 +884,16 @@ export const ja = defineLocale({
postSetupErrorTitle: 'セットアップはエラーで終了しました',
postSetupErrorMessage: step => `${step} のログを確認してください。`,
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`,
+ webSearchActive: backend => `検索: ${backend}`,
+ webExtractActive: backend => `抽出: ${backend}`,
+ webCapabilityUnset: '未設定',
+ webUseForSearch: '検索に使用',
+ webUseForExtract: '抽出に使用',
+ webUsedForSearch: '検索バックエンド',
+ webUsedForExtract: '抽出バックエンド',
+ webCapabilitySelectedMessage: (provider, capability) =>
+ `${provider} がウェブ${capability === 'search' ? '検索' : '抽出'}を担当します。`,
+ failedSelectCapability: provider => `${provider} の設定に失敗しました`,
terminalBackend: {
sectionTitle: '実行バックエンド',
loading: '実行バックエンドを確認中…',
@@ -916,6 +927,9 @@ export const ja = defineLocale({
noDescription: '説明はありません。',
configured: '設定済み',
needsKeys: 'キーが必要',
+ visionModelHint:
+ 'ビジョンは補助モデル設定を使用します。画像対応モデルはそこで選択され、ここでプロバイダーごとに選ぶものではありません。',
+ visionModelLink: '設定 → モデル でビジョンモデルを選択',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} ツールセットが有効`,
configureToolset: label => `${label} を設定`,
toggleToolset: label => `${label} ツールセットを切り替え`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 11cb6a534da..40f224f2b24 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -448,6 +448,7 @@ export interface Translations {
envActions: {
actionsFor: (label: string) => string
credentialActions: string
+ manageInKeys: string
docs: string
hideValue: string
revealValue: string
@@ -731,6 +732,15 @@ export interface Translations {
postSetupErrorTitle: string
postSetupErrorMessage: (step: string) => string
postSetupFailed: (step: string) => string
+ webSearchActive: (backend: string) => string
+ webExtractActive: (backend: string) => string
+ webCapabilityUnset: string
+ webUseForSearch: string
+ webUseForExtract: string
+ webUsedForSearch: string
+ webUsedForExtract: string
+ webCapabilitySelectedMessage: (provider: string, capability: string) => string
+ failedSelectCapability: (provider: string) => string
loadingModels: string
modelSectionTitle: string
modelCount: (count: number) => string
@@ -774,6 +784,8 @@ export interface Translations {
noDescription: string
configured: string
needsKeys: string
+ visionModelHint: string
+ visionModelLink: string
toolsetsEnabled: (enabled: number, total: number) => string
configureToolset: (label: string) => string
toggleToolset: (label: string) => string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 625073d8589..4c543feb986 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -623,6 +623,7 @@ export const zhHant = defineLocale({
envActions: {
actionsFor: label => `${label} 的動作`,
credentialActions: '憑證動作',
+ manageInKeys: '在 API 金鑰中管理',
docs: '文件',
hideValue: '隱藏值',
revealValue: '顯示值',
@@ -843,7 +844,7 @@ export const zhHant = defineLocale({
nousAuthFailed: 'Nous Portal 登入未完成',
noApiKeyRequired: '不需要 API 金鑰。',
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
- postSetupInstalledHint: '此後端已在此機器上安裝。',
+ postSetupInstalledHint: '已安裝。僅在出現問題時才需要重新執行安裝。',
postSetupRun: '執行設定',
postSetupRerun: '重新執行設定',
postSetupInstalled: '已安裝',
@@ -854,6 +855,16 @@ export const zhHant = defineLocale({
postSetupErrorTitle: '設定完成但有錯誤',
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
postSetupFailed: step => `執行 ${step} 設定失敗`,
+ webSearchActive: backend => `搜尋:${backend}`,
+ webExtractActive: backend => `擷取:${backend}`,
+ webCapabilityUnset: '未設定',
+ webUseForSearch: '用於搜尋',
+ webUseForExtract: '用於擷取',
+ webUsedForSearch: '搜尋後端',
+ webUsedForExtract: '擷取後端',
+ webCapabilitySelectedMessage: (provider, capability) =>
+ `${provider} 現在負責網頁${capability === 'search' ? '搜尋' : '擷取'}。`,
+ failedSelectCapability: provider => `無法設定 ${provider}`,
terminalBackend: {
sectionTitle: '執行後端',
loading: '正在檢查執行後端…',
@@ -887,6 +898,8 @@ export const zhHant = defineLocale({
noDescription: '無可用描述。',
configured: '已設定',
needsKeys: '需要金鑰',
+ visionModelHint: '視覺功能使用你的輔助模型設定——支援影像的模型在那裡選擇,而不是在此處按供應商選擇。',
+ visionModelLink: '在 設定 → 模型 中選擇視覺模型',
toolsetsEnabled: (enabled, total) => `已啟用 ${enabled}/${total} 個工具集`,
configureToolset: label => `設定 ${label}`,
toggleToolset: label => `切換 ${label} 工具集`,
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index dce07395e44..07c01b7395e 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -734,6 +734,7 @@ export const zh: Translations = {
envActions: {
actionsFor: label => `${label} 的操作`,
credentialActions: '凭据操作',
+ manageInKeys: '在 API 密钥中管理',
docs: '文档',
hideValue: '隐藏值',
revealValue: '显示值',
@@ -1021,7 +1022,7 @@ export const zh: Translations = {
nousAuthFailed: 'Nous Portal 登录未完成',
noApiKeyRequired: '不需要 API 密钥。',
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
- postSetupInstalledHint: '此后端已在此机器上安装。',
+ postSetupInstalledHint: '已安装。仅在出现问题时才需要重新运行安装。',
postSetupRun: '运行设置',
postSetupRerun: '重新运行设置',
postSetupInstalled: '已安装',
@@ -1032,6 +1033,16 @@ export const zh: Translations = {
postSetupErrorTitle: '设置完成但有错误',
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
postSetupFailed: step => `运行 ${step} 设置失败`,
+ webSearchActive: backend => `搜索:${backend}`,
+ webExtractActive: backend => `提取:${backend}`,
+ webCapabilityUnset: '未设置',
+ webUseForSearch: '用于搜索',
+ webUseForExtract: '用于提取',
+ webUsedForSearch: '搜索后端',
+ webUsedForExtract: '提取后端',
+ webCapabilitySelectedMessage: (provider, capability) =>
+ `${provider} 现在负责网页${capability === 'search' ? '搜索' : '提取'}。`,
+ failedSelectCapability: provider => `无法设置 ${provider}`,
loadingModels: '正在加载模型目录…',
modelSectionTitle: '模型',
modelCount: count => `${count} 个模型`,
@@ -1075,6 +1086,8 @@ export const zh: Translations = {
noDescription: '暂无描述。',
configured: '已配置',
needsKeys: '需要密钥',
+ visionModelHint: '视觉功能使用你的辅助模型配置——支持图像的模型在那里选择,而不是在此处按提供商选择。',
+ visionModelLink: '在 设置 → 模型 中选择视觉模型',
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
configureToolset: label => `配置 ${label}`,
toggleToolset: label => `切换 ${label} 工具集`,
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 0bfa3edc358..dc2a71f1dff 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -714,14 +714,29 @@ export interface ToolProvider {
/** Honest readiness computed server-side (keys ∧ Nous entitlement ∧
* post-setup install state). Optional for older backends. */
status?: ToolProviderStatus
+ /** Web toolset only: the backend key written to web.*backend config
+ * (e.g. 'searxng'). Absent on other toolsets and older backends. */
+ web_backend?: string
+ /** Web toolset only: capabilities this backend can serve. Search-only
+ * providers (ddgs, brave-free) report ['search']. */
+ capabilities?: WebCapability[]
}
+/** A web toolset capability — the runtime dispatches web_search and
+ * web_extract to independently configurable backends. */
+export type WebCapability = 'search' | 'extract'
+
export interface ToolsetConfig {
name: string
has_category: boolean
providers: ToolProvider[]
/** Name of the currently active provider, or null if none is configured. */
active_provider: string | null
+ /** Web toolset only: backend the web_search tool resolves to right now
+ * (web.search_backend → web.backend → credential auto-detect). */
+ active_search_backend?: string | null
+ /** Web toolset only: backend the web_extract tool resolves to right now. */
+ active_extract_backend?: string | null
}
/** Health status of a terminal execution backend row.
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 5f8d305b5d2..9c82d5a1725 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -2417,6 +2417,33 @@ def _plugin_web_search_providers() -> list[dict]:
return rows
+def web_provider_capabilities(backend: str) -> list:
+ """Return the capabilities (``search`` / ``extract``) a web backend supports.
+
+ Consults the plugin registry's provider instance (``supports_search`` /
+ ``supports_extract``) so the Capabilities GUI can offer per-capability
+ selection (``web.search_backend`` / ``web.extract_backend``) only where it
+ makes sense — e.g. ddgs and brave-free are search-only. Falls back to both
+ capabilities when the backend isn't registered (hardcoded setup-flow rows
+ like the managed Firecrawl entries resolve before plugin discovery in some
+ test contexts, and firecrawl itself supports both).
+ """
+ try:
+ from agent.web_search_registry import get_provider
+
+ provider = get_provider(backend)
+ if provider is not None:
+ caps = []
+ if provider.supports_search():
+ caps.append("search")
+ if provider.supports_extract():
+ caps.append("extract")
+ return caps
+ except Exception:
+ pass
+ return ["search", "extract"]
+
+
# Mirror of _plugin_web_search_providers for cloud browser backends. After
# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under
# plugins/browser//; this helper is the sole source of provider rows
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index ba8c8ec7c8c..1d4abc7f896 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -14670,6 +14670,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
_is_provider_active,
_visible_providers,
provider_readiness_status,
+ web_provider_capabilities,
)
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
@@ -14683,6 +14684,8 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
+ active_search_backend = None
+ active_extract_backend = None
if cat:
# Fetch portal/entitlement state once for the whole matrix — the
# per-provider readiness computation below reuses it instead of
@@ -14706,7 +14709,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
- providers.append({
+ row = {
"name": prov["name"],
"badge": prov.get("badge", ""),
"tag": prov.get("tag", ""),
@@ -14721,17 +14724,45 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active
),
- })
- return {
+ }
+ if name == "web" and prov.get("web_backend"):
+ # The runtime split web into two capabilities long ago
+ # (web.search_backend / web.extract_backend); surface each
+ # row's backend key and which capabilities it can serve so
+ # the GUI can offer per-capability selection.
+ row["web_backend"] = prov["web_backend"]
+ row["capabilities"] = web_provider_capabilities(prov["web_backend"])
+ providers.append(row)
+ if name == "web":
+ # Resolve the per-capability active backends exactly the way the
+ # web_search / web_extract dispatchers do (per-capability key →
+ # shared web.backend → credential auto-detect), so the GUI badges
+ # reflect what a tool call would actually hit right now.
+ try:
+ from tools.web_tools import _get_extract_backend, _get_search_backend
+
+ active_search_backend = _get_search_backend()
+ active_extract_backend = _get_extract_backend()
+ except Exception:
+ active_search_backend = None
+ active_extract_backend = None
+ payload = {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
+ if name == "web":
+ payload["active_search_backend"] = active_search_backend
+ payload["active_extract_backend"] = active_extract_backend
+ return payload
class ToolsetProviderSelect(BaseModel):
provider: str
+ # Web-only capability scope: 'search' | 'extract'. Omitted → whole-provider
+ # selection through the legacy apply_provider_selection path (web.backend).
+ capability: Optional[str] = None
profile: Optional[str] = None
@@ -14917,6 +14948,15 @@ async def select_toolset_provider(
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
+ For the ``web`` toolset only, an optional ``capability`` ('search' |
+ 'extract') scopes the selection to ``web.search_backend`` /
+ ``web.extract_backend`` — the same per-capability overrides the runtime
+ dispatchers (``tools.web_tools._get_search_backend`` /
+ ``_get_extract_backend``) resolve first. The provider must actually
+ support the requested capability (a search-only backend can't be the
+ extract backend). Omitting ``capability`` keeps the legacy whole-provider
+ behavior (writes ``web.backend``).
+
Managed Nous rows (``managed_nous_feature``) additionally report the
Portal entitlement state: the CLI flow gates these selections on
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
@@ -14930,6 +14970,7 @@ async def select_toolset_provider(
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
apply_provider_selection,
+ web_provider_capabilities,
_get_effective_configurable_toolsets,
_visible_providers,
)
@@ -14942,15 +14983,58 @@ async def select_toolset_provider(
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
+ if body.capability is not None:
+ if name != "web":
+ raise HTTPException(
+ status_code=400,
+ detail="capability selection is only supported for the web toolset",
+ )
+ if body.capability not in ("search", "extract"):
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
+ )
+
with _profile_scope(body.profile or profile):
config = load_config()
- try:
- apply_provider_selection(name, body.provider, config)
- except KeyError as exc:
- raise HTTPException(status_code=400, detail=str(exc).strip('"'))
+ if body.capability is not None:
+ # Per-capability path: resolve the picker row to its backend key
+ # and write web._backend. Does NOT touch web.backend,
+ # so the other capability keeps resolving through the shared
+ # fallback chain.
+ cat = TOOL_CATEGORIES.get(name)
+ providers = _visible_providers(cat, config, force_fresh=True) if cat else []
+ prov = next((p for p in providers if p.get("name") == body.provider), None)
+ if prov is None:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
+ )
+ backend = prov.get("web_backend")
+ if not backend:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Provider {body.provider!r} has no web backend key",
+ )
+ if body.capability not in web_provider_capabilities(backend):
+ raise HTTPException(
+ status_code=400,
+ detail=f"{body.provider} does not support {body.capability}",
+ )
+ web_cfg = config.setdefault("web", {})
+ if not isinstance(web_cfg, dict):
+ web_cfg = {}
+ config["web"] = web_cfg
+ web_cfg[f"{body.capability}_backend"] = backend
+ else:
+ try:
+ apply_provider_selection(name, body.provider, config)
+ except KeyError as exc:
+ raise HTTPException(status_code=400, detail=str(exc).strip('"'))
save_config(config)
-
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
+ if body.capability is not None:
+ response["capability"] = body.capability
# Entitlement check for managed Nous rows — mirrors the gate the CLI
# applies via ensure_nous_portal_access at selection time.
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 204c1424d3b..5353ef0f4ba 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -5635,6 +5635,118 @@ class TestNewEndpoints:
)
assert resp.status_code == 400
+ # -- Web capability split (search vs extract backends) ------------------
+
+ def test_web_config_reports_per_capability_backends(self):
+ """GET web/config carries the resolved search/extract backends.
+
+ The runtime resolves web_search and web_extract independently
+ (web.search_backend / web.extract_backend → web.backend → auto-detect);
+ the config payload must surface both so the GUI can show which backend
+ each capability actually hits.
+ """
+ resp = self.client.get("/api/tools/toolsets/web/config")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "active_search_backend" in data
+ assert "active_extract_backend" in data
+ # Provider rows carry their backend key + supported capabilities so
+ # the GUI can hide "Use for Extract" on search-only rows.
+ rows_with_backend = [p for p in data["providers"] if p.get("web_backend")]
+ assert rows_with_backend, "expected at least one provider with a web backend key"
+ for prov in rows_with_backend:
+ assert isinstance(prov["capabilities"], list)
+ assert set(prov["capabilities"]) <= {"search", "extract"}
+ assert prov["capabilities"], "a web provider must support at least one capability"
+
+ def test_web_capability_fields_only_on_web_toolset(self):
+ resp = self.client.get("/api/tools/toolsets/tts/config")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert "active_search_backend" not in data
+ assert "active_extract_backend" not in data
+
+ def test_select_web_search_backend_matches_runtime_resolution(self, monkeypatch):
+ """PUT provider with capability=search writes web.search_backend and the
+ runtime search dispatcher resolves to it — while extract is untouched."""
+ # Make SearXNG available so both the endpoint gate and the runtime
+ # availability check agree it's usable.
+ monkeypatch.setenv("SEARXNG_URL", "http://localhost:8888")
+ # Give extract an explicit shared backend so the assertion isn't
+ # hostage to whatever creds exist on the machine running the tests.
+ monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
+ base = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "Firecrawl Self-Hosted"},
+ )
+ assert base.status_code == 200
+
+ resp = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "SearXNG", "capability": "search"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["ok"] is True
+ assert body["capability"] == "search"
+
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ assert cfg["web"]["search_backend"] == "searxng"
+ # The shared backend selected first must be preserved for extract.
+ assert cfg["web"]["backend"] == "firecrawl"
+
+ # The REAL runtime resolution — not a parallel reimplementation.
+ from tools.web_tools import _get_extract_backend, _get_search_backend
+ assert _get_search_backend() == "searxng"
+ assert _get_extract_backend() == "firecrawl"
+
+ # And the config endpoint reports the same split.
+ data = self.client.get("/api/tools/toolsets/web/config").json()
+ assert data["active_search_backend"] == "searxng"
+ assert data["active_extract_backend"] == "firecrawl"
+
+ def test_select_web_extract_backend_writes_extract_key(self, monkeypatch):
+ monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
+ resp = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "Firecrawl Self-Hosted", "capability": "extract"},
+ )
+ assert resp.status_code == 200
+
+ from hermes_cli.config import load_config
+ cfg = load_config()
+ assert cfg["web"]["extract_backend"] == "firecrawl"
+ # Whole-provider/search keys untouched by a capability-scoped write
+ # (the default config seeds them as empty strings).
+ assert not cfg["web"].get("search_backend")
+
+ from tools.web_tools import _get_extract_backend
+ assert _get_extract_backend() == "firecrawl"
+
+ def test_select_web_capability_rejects_unsupported_capability(self):
+ """A search-only provider (ddgs) can't be set as the extract backend."""
+ resp = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "DuckDuckGo (ddgs)", "capability": "extract"},
+ )
+ assert resp.status_code == 400
+ assert "does not support extract" in resp.json()["detail"]
+
+ def test_select_web_capability_rejects_bad_values(self):
+ resp = self.client.put(
+ "/api/tools/toolsets/web/provider",
+ json={"provider": "Firecrawl Self-Hosted", "capability": "browse"},
+ )
+ assert resp.status_code == 400
+
+ # capability is a web-only concept.
+ resp = self.client.put(
+ "/api/tools/toolsets/tts/provider",
+ json={"provider": "Microsoft Edge TTS", "capability": "search"},
+ )
+ assert resp.status_code == 400
+
# -- Terminal execution backend picker ---------------------------------
def test_get_terminal_backends_shape_and_local_ready(self, monkeypatch):