Skip to content

dsh-tavily

Verified

@0x427567/dsh-tavily Β· v0.3.0 Β· MIT Β· Web UI

Tavily-backed search provider for the DeepSeek Harness web seam (ctx.web): keyless-first credentials, a cooldown after the keyless tier refuses, and a user-visible notice when it falls back to the key.

Install

dsh plugin add @0x427567/dsh-tavily

Confirm the layer applied with dsh --profile default --dump-config β€” see the install guide.

Source

Tags

Creators

Readme

@0x427567/dsh-tavily

A Tavily-backed search provider for the DeepSeek Harness web capability seam (ctx.web), shipped as a DSH bundle so dsh plugin add wires it up without hand-editing a profile.

Tavily answers /search with the result list itself β€” title, URL, content and an optional published date β€” so one search is one HTTP request: no auxiliary model turn, and no scraping results out of provider prose.

What makes this one different

Several Tavily providers for DSH exist. This one is built around a single idea: which credential tier served your search should never be a silent decision.

  • Keyless first. Tavily serves /search with no account at all, so this provider works with no key stored. Your key is reached for only once the keyless tier refuses.
  • A refusal is visible. When the keyless tier refuses, that tier is cooled down and the switch to your key is reported in the search result itself β€” and logged β€” rather than happening behind your back. Both follow your harness language.
  • Explicit modes. keyless-first (default), key-first, keyless-only, key-only.
  • No build step. Plain ESM, published as written. Installing it runs no install script, so it needs no allowBuilds permission.

Install

Requires Node ^22.19 || >=24, and a harness that provides @deepseek-ai/dsh-web, @deepseek-ai/dsh-credentials, @deepseek-ai/dsh-web-search-deepseek and @deepseek-ai/dsh-timeout at ^0.1.5-rc.2 β€” all four are declared as peer dependencies. A peer mismatch is a warning, not a failure: pnpm 12 reports unmet peers and installs anyway, and what an older harness then gets is an ESM link error at load, because this plugin imports symbols those packages must already export. Keep the harness and the plugin on the same release line.

From npm (recommended)

dsh plugin --profile web add @0x427567/dsh-tavily

Prebuilt, so nothing is compiled on your machine. The package is scoped because dsh-tavily and dsh-web-tavily are already taken on npm by other Tavily providers.

From GitHub

dsh plugin --profile web add github:ShawnOY/dsh-tavily

Either form installs the same package. It declares dsh.bundle.patch, so dsh plugin add registers it in the profile's dsh.profile.bundles and its shipped patch mounts the provider, pins the web seam's search to it, and switches off the DeepSeek search provider the base bundle ships. web_search is therefore Tavily-backed, and Settings β†’ Plugins β†’ Plugin configuration shows this plugin's card alone. Its Search provider select switches to the shipped DeepSeek backend without a restart. Nothing else to configure.

Restart the harness afterwards: the base hmr row is disabled, so a newly added module is not hot-reloaded.

From a local checkout

Pack it, then install the tarball:

npm pack
dsh plugin --profile web add ./0x427567-dsh-tavily-<version>.tgz

Do not point dsh plugin add at the checkout directory. pnpm installs a directory dependency as a symlink, and Node resolves a symlinked module to its real path β€” so a checkout outside the harness home resolves this package's @deepseek-ai/* imports against the checkout's own node_modules, not the harness's, and boot fails with Cannot find package '@deepseek-ai/schemastery'. A tarball extracts the package inside the profile, where the peers resolve correctly. npm install in a checkout is only for running the test suite; it pulls its own copies of the host packages, which is fine for the stubbed tests and wrong for loading into a harness.

Re-packing to the same version is not enough to upgrade. dsh plugin add <tarball> records the tarball's integrity in the profile lockfile, so replacing the file at the same path and version leaves pnpm reusing the cached contents β€” the install reports success and nothing changes. Remove and re-add (dsh plugin --profile web remove <name> then add <tarball>), or bump the version so the spec differs. --force does not get past this.

Manually, by relative path

If you would rather not install a package, copy this directory to <profile>/plugins/tavily-search/ and add to <profile>/cordis.patch.yml:

- insert:
    - id: web-search-tavily
      name: ./plugins/tavily-search/index.js
      config:
        apiKeyEnv: TAVILY_API_KEY

# The shipped DeepSeek provider starts off, so the default selection is this
# one; the card switches between them inside this plugin. Pinning the seam is
# what keeps a third search provider from making it ambiguous.
- id: web-search-deepseek
  disabled: true

- id: web
  config:
    searchProvider: tavily
    fetchProvider: http

Credentials

Keyless needs none. To use the keyed tier, provide a key in any of these ways β€” first match wins:

  1. A literal apiKey in this plugin's config (avoid; it lands in a config file).

  2. TAVILY_API_KEY (or your apiKeyEnv) in the harness's credential store, ~/.dsh/.credentials.yaml β€” written either by the card's API key field or by hand:

    version: 1
    refs:
      TAVILY_API_KEY: tvly-...
    
  3. The same variable in the environment that launched the harness.

Keyless is free but rate-limited, and Tavily does not publish the keyless limit. Keys have documented limits (100 RPM development, 1,000 RPM production). When the keyless tier refuses, this provider falls back to your key and tells you.

Configuration

Key Default Meaning
apiKey β€” Literal key. Prefer the credential store.
apiKeyEnv TAVILY_API_KEY Credential ref resolved per search. Must be a shell-style name ([A-Za-z_][A-Za-z0-9_]*); anything else is rejected when the config loads.
baseURL https://api.tavily.com API base; /search is appended.
searchDepth basic Tavily search_depth. advanced costs more.
maxResults 5 Result bound when the caller sets none.
snippetChars 1200 Bound on one source's snippet; a longer one is cut and marked with …. 0 keeps Tavily's text as-is.
allowCustomBaseURL false Whether baseURL may point somewhere other than the official endpoint. See The official endpoint is locked.
searchTimeoutMs 12000 How long one search may take before this plugin gives up and throws WEB_SEARCH_TIMEOUT. The seam has no deadline of its own, so without this a slow upstream can hold a turn open. Armed through @deepseek-ai/dsh-timeout, the same deadline primitive the shipped fetch provider uses, so a timeout is classified from its abort reason rather than inferred. The probe spends the same budget.
includeAnswer false Ask Tavily for an LLM-written answer as well.
provider tavily Which backend answers web_search: tavily or deepseek-official β€” see Switching providers.
mode keyless-first Credential strategy β€” see below.
keylessCooldownMinutes 10 How long a refused keyless tier is skipped. 0 disables the cooldown.
language auto Copy language for this plugin alone. auto follows the harness preference β€” see Language.

provider, mode, keylessCooldownMinutes, language, searchDepth and maxResults are also editable at runtime from Settings β†’ Plugins β†’ Plugin configuration, which also carries a Test connection button β€” see Testing the connection. This package ships a browser half that contributes the card for its dsh-tavily namespace β€” registering a namespace on the Host is not enough on its own, because that page renders only the namespaces a card claims. A change there takes effect on the next search, and the profile patch remains the base value a reset returns to. The other keys are file-only.

Modes

mode Behaviour
keyless-first (default) Try keyless; on refusal, cool that tier down and retry with the key.
key-first Use the key whenever one resolves; keyless only when none does.
keyless-only Never send the key.
key-only Require a key; never use the keyless tier.

A refusal means HTTP 401, 403 or 429, or a 200 whose body carries prose instead of a results array β€” Tavily answers a capped keyless call with instructions rather than a machine-readable error. A 400 (a bad query) is not a refusal: retrying with a key would not help, so the error surfaces.

What you see when it falls back

The switch is announced in the search result, which the harness renders as the tool output:

⚠️ Tavily 免密ι’₯ι’εΊ¦ε·²η”¨ε°½οΌŒθΏ™ζ¬‘ζ”Ήη”¨ε―†ι’₯οΌ›ζŽ₯δΈ‹ζ₯ηΊ¦ 10 εˆ†ι’Ÿε†…η›΄ζŽ₯使用密ι’₯。

and, while the cooldown is active:

⚠️ Tavily 免密ι’₯ζœεŠ‘ε†·ε΄δΈ­οΌˆηΊ¦ε‰© 7 εˆ†ι’ŸοΌ‰οΌŒθΏ™ζ¬‘δ½Ώη”¨ε―†ι’₯。

Both also emit a warn to the harness log. A happy path is silent. The wording above is the default (Simplified Chinese); see Language for the other two.

Language

The notice, the log warning, and this provider's own error messages follow the harness language preference β€” Settings β†’ General β†’ Language β€” so they match the language the settings card and the rest of the UI are already in. A language change applies to the next search.

The card's own language field overrides that for this plugin alone. Leave it on auto to follow the harness; pick a language to keep the rest of the UI where it is and have only this card and this plugin's notices speak it. Both halves read the same field, so the card and the transcript cannot disagree about which language they are in.

Three languages are served:

Preference Language Where it comes from
zh (default) Simplified Chinese The harness's own zh, so it matches the shell
en English The harness's own en
zh-Hant Traditional Chinese A language pack this package adds

zh and en are built into the harness; zh-Hant arrives with this plugin's browser half, which calls ctx.locale.addLanguage for it. It falls back to zh, so a key missing from the pack resolves instead of rendering as a raw identifier.

The pack is registered under the script tag and deliberately not a region tag such as zh-TW. That keeps the default where it belongs: a browser reporting zh-TW or zh-HK matches the zh primary subtag and opens in Simplified, and a Traditional reader opts in by picking 繁體中文 once. Registering zh-TW instead would exact-match those browsers and pull them off the default.

Both halves resolve a given preference the same way β€” an exact id wins, then the primary subtag β€” so an unshipped zh-TW or zh-HK lands on Simplified zh in the card and in the notices rather than the two disagreeing.

The one case where they can part ways is a browser that has never picked a language and reports zh-Hant itself. The client matches that exactly and opens in Traditional; the Host sees no stored preference at all, so its notices use the default, Simplified. Choosing a language in Settings β†’ General β†’ Language persists it and closes the gap. Nothing else can: the browser's language list never leaves the client, and the client deliberately does not write a browser-derived guess into the durable settings document.

Error classification

A failed search throws a WebError whose code tells you what to do, instead of one generic WEB_PROVIDER_ERROR:

Code When What to do
WEB_SEARCH_QUOTA_EXCEEDED HTTP 432/433 on the key tier Top up the key or check its plan
WEB_SEARCH_RATE_LIMITED HTTP 429 on either tier Wait a bit and retry
WEB_SEARCH_INVALID_KEY HTTP 401/403 on the key tier Check that the key is correct and valid
WEB_SEARCH_KEYLESS_REFUSED the keyless tier refuses (401/403/432/433) Set an API key to keep searching
WEB_SEARCH_UPSTREAM_ERROR HTTP 5xx Tavily is down for a moment; retry later
WEB_SEARCH_UNREACHABLE the request never reached Tavily Check the network or endpoint settings
WEB_SEARCH_TIMEOUT no answer within searchTimeoutMs Lower maxResults, or raise the budget
WEB_SEARCH_CREDENTIAL_MISSING the mode needs a key and none resolved Provide the credential (see Credentials)
WEB_PROVIDER_ERROR anything else (e.g. a 400, or an unparseable body) Read the message; it carries Tavily's detail

The message itself ends with the matching action, in the interface language, so the operator does not have to map codes by hand.

Switching providers

Settings β†’ Plugins β†’ Plugin configuration β†’ Tavily web search carries a Search provider select:

Value Backend Cards on the page
tavily (default) This plugin, keyless-first This one
deepseek-official The DeepSeek search provider dsh-base ships, run inside this plugin This one

The choice applies to the next web_search β€” no restart β€” and it is stored in the settings document, so it survives one. The profile patch remains the value a reset returns to.

Both selections answer through this plugin, so the page keeps one search card either way. The seam is pinned to this provider and never re-resolves, which also means a third search provider installed alongside cannot make it ambiguous.

The one thing the built-in selection does not carry over is its settings card. The shipped provider's endpoint and model stay on their defaults, with DEEPSEEK_SEARCH_BASE_URL as the override, and its key is read from DEEPSEEK_API_KEY in the credential store or the environment.

Testing the connection

Settings β†’ Plugins β†’ Plugin configuration β†’ Tavily web search β†’ Test connection calls the credential tiers and reports each one separately, so "the keyless tier still works" and "my key is valid" stop being the same unanswered question:

Row What a passing row means
Keyless tier Tavily answered without any key
Your API key Tavily answered with the key the credential ref resolved

The test uses the values currently on screen, saved or not, so a mode can be tried before it is saved. Editing Search provider or Credential mode clears a result, because it no longer describes what is selected; changing the language or the cooldown, or collapsing the card, does not.

  • A tier the mode never uses reads Not tested and is not called: keyless-only does not spend a keyed request, and key-only does not touch the keyless tier.
  • The probe is a real call, not a dry run. Each row asks Tavily for a single result. When the keyless tier refuses, that refusal starts the cooldown exactly as a real search would, and the result says so β€” you can see the fallback working before you rely on it. A tier already cooling down is reported as such rather than hit again.
  • Selecting the built-in backend tests that backend instead: one row, and it costs a whole model turn, because DeepSeek exposes no dedicated search endpoint. A missing DEEPSEEK_API_KEY is reported without making that call at all.
  • The button exists only where the deployment mounts the web /api bridge. A profile without one β€” a headless composition β€” shows "This deployment has no test channel." and still searches normally.

The key never travels from the browser: the card posts provider, mode and the cooldown, and the Host reads the credential itself. The route sits behind the harness's own Host/Origin fence and browser authentication, because it is registered on the /api bridge rather than beside it.

Tier status at a glance

The card's header carries one line about the credential tiers, readable without expanding it:

What it says When
Keyless is cooling down (~N min left) A search or a test was refused by the keyless tier, so that tier is being skipped
Keyless is ready; your key is configured / …no key configured The ordinary state, with or without a stored key
This mode never uses the keyless tier mode: key-only, where a cooldown would not affect the next search
Built-in backend: key configured / …no key configured The built-in backend is selected

Reading it costs nothing: it is a local read of this plugin's own state with no upstream request, so opening the settings page spends no keyless call and no credit. It is re-read when the card first renders, when you expand it, after a save, and after a test.

It is not a usage readout. Tavily publishes no keyless limit and offers no endpoint for one, so there is no "remaining quota" number to show β€” only whether the tier is cooling down. Per-key usage and balance belong to the key-pool plugins, which need a key and a call to Tavily's /usage to show them. The cooldown itself lives in memory: restart the harness and the line reads ready, which is what the next search will do too.

How the switch is wired

web_search is not a search engine of its own: it is a model-facing tool that calls ctx.web.search(), and the seam resolves one registered provider. Three rows decide which:

  • the web row pins searchProvider: tavily, so the seam always resolves this package;
  • this package's provider is registered and available whenever its base URL parses β€” the keyless tier needs no credential, so a stored key is not a precondition β€” and its search() routes on the provider setting;
  • the web-search-deepseek row stays disabled, which is what keeps the Plugins page to one search card.

Picking deepseek-official therefore does not move the pin or touch the loader. This package imports DeepSeekSearchProvider from @deepseek-ai/dsh-web-search-deepseek β€” a declared peer dependency β€” and delegates to it, so that backend's behaviour, errors and endpoint handling are the shipped ones. What it costs is that the shipped provider's own configuration card is gone, and with it the ability to edit that endpoint and model from the UI.

The patch also restates fetchProvider: http, because a patch replaces the targeted row's whole config and dropping it would leave the web fetch provider unset.

Your own cordis.patch.yml is applied after every bundle layer, so you can still override any of these rows β€” but you must restate the whole config to do so.

The official endpoint is locked by default

baseURL decides where your key is sent, so a stray value there is not a routing preference β€” it is a credential leaving the machine. Unless allowCustomBaseURL: true is set, any baseURL other than the official endpoint (compared ignoring case and a trailing slash) is refused by search, by the test button and by the status read, before any request is built. The error names the switch to set, and the card shows it when the test fails.

The lock is a guard against misconfiguration, not a sandbox: it does not resolve DNS, follow redirects or vet certificates, and allowCustomBaseURL: true is a deliberate decision that the endpoint is yours.

Caveats

  • The shipped DeepSeek search provider starts switched off, and its settings card stays off. The default selection is Tavily, so Settings β†’ Plugins shows this plugin's card alone; picking deepseek-official runs that backend without bringing its card back. Its endpoint and model are not editable from the UI.
  • Keyless has no account and no contract. You accept no terms and hold the least leverage over what happens to your data. It is the right default for trying a tool and the wrong channel for anything sensitive β€” see Tavily on keyless. Set mode: key-first if you would rather always use your own account.
  • The card covers provider, language, mode and the cooldown. The remaining keys β€” apiKey, apiKeyEnv, baseURL, searchDepth, maxResults, searchTimeoutMs and includeAnswer β€” are set in the profile patch or the settings document. The card's API key field writes the credential store through the harness's own credentials facade β€” the same one its Models settings page uses β€” and the store never hands the value back, so the field is write-only by construction.
  • A failure now says what went wrong, not only which status came back. A 429 reads as rate limited, a keyed 401/403 as the key was rejected, and a keyless one as the keyless tier refused it β€” the same words the card's test button uses, in the harness's language. The error code stays WEB_PROVIDER_ERROR; only a timeout takes its own, WEB_SEARCH_TIMEOUT, so a caller can tell a slow upstream from a turn it cancelled itself.
  • Traditional Chinese is a language pack, not a build of zh. The harness reads bare zh as Simplified, so 繁體 lives at zh-Hant. It shows up in the language list as an extra entry rather than as a variant of δΈ­ζ–‡, and a Traditional reader has to pick it once. No zh-TW/zh-HK entry is offered, so those browsers stay on the Simplified default unless someone chooses otherwise.

Development

npm test                                             # both offline suites
node test/provider.test.mjs                          # the Host half only
node test/client.test.mjs                            # the browser half only
TAVILY_LIVE_TEST=1 node test/provider.test.mjs       # + a live keyless call
TAVILY_LIVE_TEST=1 TAVILY_API_KEY=tvly-... node test/provider.test.mjs
npm run test:bridge                                  # register on a harness's real /api bridge
TAVILY_LIVE_TEST=1 node test/bridge.test.mjs         # + a live call dispatched through it

test/bridge.test.mjs is the one suite that does not stub the Host. It loads the real @deepseek-ai/dsh-client-connection and cordis, lets apply() register the probe route the way a running profile does, and dispatches through the bridge's own handler table β€” so the route grammar, the duplicate-path contract, the inherited Host/Origin fence and the fall-through to 404 are checked against the implementation that will actually run them. Those packages are not dependencies here, so it resolves them from an install (DSH_BRIDGE_ROOT, a profile's node_modules, or the global dsh) and exits 0 with a notice when it cannot β€” a skip is not a pass. It is deliberately outside npm test for that reason.

The offline assertions stub fetch; the live ones are opt-in. The plugin imports its @deepseek-ai/* peers as bare specifiers, so run the tests where those resolve β€” for example inside the profile that has the plugin installed.

test/client.test.mjs stands up the __ModuleLoader__ contract with a stub require and a stub context, so the browser half's wiring β€” the dictionaries it registers and the language pack it adds β€” is checked without a browser. It does not render the card.

client.js is the browser half. It is a prebuilt bundle in the shape the client module system serves β€” a window.__ModuleLoader__.load factory receiving the host's require β€” so it is served as written and needs no build step. Its dsh.client.platform is web, and it is registered in the browser module graph as @0x427567/dsh-tavily/client.js.

License

MIT β€” see LICENSE.