DeepSeek Harness Ships web_search Without a Free Backend

Quick answer: DeepSeek Harness (dsh) makes web_search a built-in tool, but it defaults to searchProvider: deepseek-official — DeepSeek's paid API, which returns 402 Insufficient Balance on an account with no money in it. There is no fallback, and the agent exits 0 anyway. Of the three backends DeepSeek publishes, only Exa has a free tier ($10 in credits a month, no card), and it is not the default. Below: the runs that show the failure, and a 43-line plugin that routes web_search at Tavily's 1,000 free searches a month instead.

DeepSeek Harness is at 228,000 GitHub stars and takes any OpenAI-compatible endpoint, which makes it the obvious harness to drive with a free model. It also ships web_search and web_fetch as built-in tools — so an agent on a free key should be able to look things up.

It can't, out of the box. Fetching a known URL is anonymous HTTP and free; searching needs a provider, and the one dsh picks by default is the one with no free tier. Everything below was run on 2026-09-18 against dsh 0.1.1-rc.2 on Node 24.20.0 (WSL2 Ubuntu), with GLM-4-Flash as the agent model and outbound requests through a local HTTP proxy.

What the Default Actually Is

You don't have to guess — dsh will print its own composed config:

dsh --profile headless --dump-config | grep searchProvider
#     searchProvider: deepseek-official

Now give that default agent a task it cannot do without searching:

dsh --profile headless "Use the web_search tool to find out how many credits \
per month Tavily's free tier includes. Report the number and the source URL."

After 9 seconds, on a DeepSeek key with no balance:

I apologize, but I am currently unable to access the necessary API key
for web_search. Therefore, I cannot retrieve the information...

$ echo $?
0

That exit code is the part worth sitting with. A script that checks $? sees success. A CI job sees success. The only evidence anything went wrong is prose inside the answer — the same failure mode we hit repeatedly when we ran a coding agent on free API tiers. The underlying cause is unambiguous: a direct call to DeepSeek's API with that key returns

HTTP 402 {"error":{"message":"Insufficient Balance", ...}}

The Three Shipped Backends, and What They Cost

dsh publishes exactly three search backends on npm. We checked each registry entry and each vendor's own pricing page on 2026-09-18:

Backendnpm packageFree tierFree searches/month
DeepSeek (the default)@deepseek-ai/dsh-web-search-deepseekNone — zero-balance key returns 4020
Exa@deepseek-ai/dsh-web-search-exa$20 on sign-up + $10/month, no payment method≈1,400 (at $7/1k requests)
Perplexity@deepseek-ai/dsh-web-search-perplexityNone documented — Search API is $5/1k requests0
Tavilyno package exists (404)1,000 credits/month, no card1,000 basic searches

Two things follow. First, Exa is a genuinely free option and the fastest fix if you just want search working: its Starter tier grants $10 in credits every month with no payment method, and at $7 per 1,000 search requests that is roughly 1,400 searches. We read that from Exa's pricing page rather than testing it — we have no Exa key — so treat the number as documented, not benchmarked. Second, the default is the only one of the three with no free tier at all, and dsh does not fall back to a working provider when the configured one fails.

Tavily has no dsh package, which is why we wrote one. Its ceiling is denominated in searches instead of dollars, so you can reason about it without a rate card — see our comparison of Tavily, Brave and Exa for how the three free tiers differ in practice.

A Free Search Backend in 43 Lines

A search backend is just a class with available() and search(), registered into the web seam. This is the whole file we ran — save it as ~/.dsh/plugins/tavily-min/src/index.ts:

import type { Context } from '@deepseek-ai/cordis'
import type { WebSearchProvider, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
import { WebError } from '@deepseek-ai/dsh-web'

export const name = 'web-search-tavily'
export const inject = ['web']

class TavilyProvider implements WebSearchProvider {
  readonly id = 'tavily'
  // A #private field, not `constructor(private key: string)` — see below.
  readonly #key: string
  constructor(key: string) { this.#key = key }

  available(): boolean { return this.#key.length > 0 }

  async search(request: WebSearchRequest): Promise<WebSearchResult> {
    const res = await fetch('https://api.tavily.com/search', {
      method: 'POST',
      headers: { authorization: `Bearer ${this.#key}`, 'content-type': 'application/json' },
      body: JSON.stringify({
        query: request.query,
        search_depth: 'basic',
        include_answer: true,
        max_results: request.maxResults ?? 5,
      }),
    })
    if (!res.ok) {
      throw new WebError(res.status === 432
        ? 'Tavily free-tier credits exhausted'
        : `Tavily search failed with HTTP ${res.status}`, 'WEB_PROVIDER_ERROR')
    }
    const body = await res.json() as { results?: { url: string, title?: string, content?: string }[], answer?: string }
    const sources = (body.results ?? [])
      .filter(r => (r.content ?? '').trim().length > 0)
      .map(r => ({ url: r.url, title: r.title, snippet: r.content!.trim() }))
    return { sources, truncated: false, ...body.answer ? { content: body.answer } : {} }
  }
}

export function apply(ctx: Context): void {
  ctx.web.registerSearchProvider(new TavilyProvider(process.env.TAVILY_API_KEY ?? ''))
}

Add a package.json with "main": "./src/index.ts" and "type": "module", then install and wire it:

dsh plugin --profile headless add link:$HOME/.dsh/plugins/tavily-min

dsh answers with warning: ... declares no dsh.bundle — installed as a plain dependency, not a profile layer. That is expected, and it is also the trap: installing a provider does not select it. Both entries below go in the profile's cordis.patch.yml, and the first one is the one that matters:

- id: web
  config:
    searchProvider: tavily

- insert:
    - id: web-search-tavily
      name: '@toolfreebie/dsh-web-search-tavily-min'

Export TAVILY_API_KEY (free, no card) and re-run the same task. Ours answered in 14.0 seconds end to end — "Tavily offers 1,000 free API credits per month… docs.tavily.com" — with the search call itself taking about 2 seconds of that. Five raw basic searches timed back to back ran 1.58–2.11 s (median 1.71 s), proxy included.

Two Things That Will Break the Install

1. TypeScript parameter properties are a hard boot error. dsh loads plugin .ts files through Node's strip-only type stripping, which refuses the one TS construct that cannot be stripped without emitting code:

constructor(private readonly key: string) {}
// SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]:
// TypeScript parameter property is not supported in strip-only mode

Use a #private field and assign it in the constructor body, as above. We reproduced both forms in isolation on Node 24.20.0: the parameter property throws, the private field loads and runs.

2. A link: install does not give the plugin its peer dependencies. Node resolves imports from the plugin's real directory, not from the profile that linked it, so boot dies with:

Error: dsh: plugin tree failed to load: ... Cannot find package
'@deepseek-ai/dsh-launch-environment' imported from
/home/you/.dsh/plugins/tavily-min/src/index.ts

Point the plugin directory at the node_modules that already has dsh in it — one symlink, and the error disappears:

ln -s ~/dsh-lab/node_modules ~/.dsh/plugins/tavily-min/node_modules

What 1,000 Free Searches Actually Buys

Tavily bills per search, not per token: basic costs 1 credit, advanced costs 2. So the free tier is 1,000 basic searches a month. To find out what that means per task, we logged every provider call:

Agent taskSearches firedUseful answer?
One fact ("Tavily's free monthly credit allowance")1Yes, with source URL
Three facts in one prompt (Tavily, Exa, Perplexity pricing)3No — model gave up after searching

Roughly one credit per question asked, so 1,000 searches is a few hundred real tasks. But note the second row: all three searches returned HTTP 200, and GLM-4-Flash still reported that it "was unable to retrieve the information." Credits are spent on the request, not on success — a weak free model burns quota on tasks it then fails, the same pattern we measured when we gave free models real agent jobs.

You can read your own balance at GET https://api.tavily.com/usage, which returns plan_limit and plan_usage. One caveat from our run: the counter did not move at all across roughly ten live searches over 25 minutes, so treat it as a periodic meter rather than a live one.

Frequently Asked Questions

Does dsh's web_search work without any configuration?

Only if you have a funded DeepSeek API account. The default searchProvider is deepseek-official, and a zero-balance key returns HTTP 402. web_fetch is unaffected — fetching a URL you already know is anonymous HTTP and stays free.

What is the quickest free fix?

Install @deepseek-ai/dsh-web-search-exa and set searchProvider: exa. Exa's free tier grants $10 of credits a month with no payment method — about 1,400 searches at its listed $7 per 1,000 requests. Use the Tavily plugin above if you would rather have a quota counted in searches than in dollars.

Why does the agent exit 0 when search is broken?

The tool error is handed back to the model as a tool result, and the model then writes a normal reply about not being able to search. From the harness's perspective the run completed, so nothing sets a non-zero exit code. Grep the output for the apology, or assert on the content, if you run dsh in CI.

Why test with GLM-4-Flash instead of Groq?

Groq's free tier caps tokens per minute at 8,000, counting prompt plus max_tokens, and the headless agent's system prompt plus tool definitions requested 5,956 tokens on its own — enough to trip a 429 before the task started. That ceiling is the subject of its own write-up on why free API keys still fail inside agents.

The Verdict

The gap here is not that dsh charges for search — it is that the default is a paid provider and the failure is silent. If you run the harness on free models, change searchProvider before you change anything else: Exa if you want search working in one command, the Tavily plugin above if you want a ceiling you can count. Either way, run one search-dependent task and read the answer, not the exit code.

Related Reads