Skip to content

websearch: V2 keyless search is undocumented and "random" can stick to a failing provider #54311

Open

Description

@rodrigo103

Summary

In V1, websearch worked with no API key or search account whenever the OpenCode or OpenCode Go provider was in use (documented V1 behavior). In V2 the keyless capability still exists — Exa, Parallel, and Firecrawl run without any key/account — but it is undocumented: the V2 websearch docs present all five providers as requiring a /connect account or an environment key, or the paid Console hosted search ($0.01/search). The V1→V2 migration guide lists no websearch change, while stating that any other broken V1 behavior should be treated as a compatibility bug. On top of that, websearch.provider: "random" can stick to a provider that returns no results or fails (fallback is documented for HTTP 429 only), so search can silently degrade after upgrading.

Environment

  • opencode version: 2.0.20
  • OS: Darwin 25.2.0 (macOS arm64)
  • Terminal: xterm-256color ($TERM_PROGRAM unset)
  • Shell: /bin/zsh
  • Install/channel: latest (curl install, ~/.opencode/bin/opencode)
  • Active plugins: none in user config (reproduced with --standalone)

Reproduction

  1. With no search-provider account or key connected, set websearch.provider to exa and run a search. It succeeds via Exa's keyless MCP endpoint (this is undocumented behavior).
  2. With no search-provider account or key connected, set websearch.provider to random and run a search. Observed: the search executes via Firecrawl and returns No search results found, with no fallback to a working provider, and the session keeps the selected provider.
  3. (Contrast, V1) The V1 docs state the tool was available with no key whenever the OpenCode / OpenCode Go provider was used.

Commands used (v2.0.20, standalone server, no search accounts connected):

OPENCODE_CONFIG_CONTENT='{"websearch":{"provider":"exa"}}'    opencode run --standalone --auto --model <model> "call websearch ..."
OPENCODE_CONFIG_CONTENT='{"websearch":{"provider":"random"}}' opencode run --standalone --auto --model <model> "call websearch ..."

Expected Behavior

  • Either restore V1's automatic keyless behavior for the OpenCode / OpenCode Go provider, or document the new selection model — including that Exa, Parallel, and Firecrawl work without any key or account — and list the change in the migration guide.
  • random should recover when the chosen provider returns no results or fails, not only on HTTP 429.

Actual Behavior

  • V2 websearch docs (/v2/docs/websearch) say "Connect an account from the TUI with /connect, or set the provider's environment variable" for all five providers, and never mention keyless use. Console hosted search costs $0.01/search.
  • The V2 migration guide (/v2/docs/migrate-v1) lists only three intentional breaking changes (plugins, server API, terminal client config) and says: "If supported behavior described in this guide stops working in V2, treat it as a compatibility bug rather than an expected migration requirement."
  • With random, a provider that returns no results (or fails for anything other than HTTP 429) is not retried; sessions stick to their selected provider.

Additional Context

Activity

  1. opencode-agent commented on Oct 10, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm the random part, and it still happens on the latest v2 (4617210).

    With websearch.provider: "random", opencode only switches to another provider after an HTTP 429. When the provider it picked returns no results, the search ends with "No search results found", the other providers are never tried, and later searches in that session go to the same provider. A non-429 error (an HTTP 500, for example) fails the search the same way, with no fallback. The test below shows both cases with two fake providers. The retry code is the same in 2.0.20 and the current v2 branch.

    I couldn't check the live provider behavior here: in this sandbox every provider failed inside opencode, including Exa, even though Exa's endpoint answered a direct request. So I didn't confirm the keyless Exa success or Firecrawl's empty results. The Firecrawl empty results look like #50217. I also didn't test the documentation and migration-guide gaps, so those are for maintainers.

    Until this is addressed, pinning a provider that works for you (as you did with tavily) avoids the problem.

    Related:

    Reproduction script (websearch-random-repro.test.ts)
    // Repro for anomalyco/opencode#54311: with websearch provider "random", a provider that
    // returns no results or fails with a non-429 error is not replaced by another provider.
    // Run: copy to packages/core/test/ in a v2 checkout, `bun install`, then
    //   cd packages/core && bun test test/websearch-random-repro.test.ts
    // Both tests PASS on v2 today, i.e. they confirm the missing fallback.
    import { describe, expect } from "bun:test"
    import { Effect } from "effect"
    import { WebSearch } from "@opencode/core/websearch"
    import { testEffect } from "./lib/effect"
    import { TestWebSearch } from "./lib/websearch"
    
    const it = testEffect(TestWebSearch.layer)
    
    const register = (id: string, mode: { kind: "ok" | "empty" | "error500" }) =>
      Effect.gen(function* () {
        const websearch = yield* WebSearch.Service
        const providerID = WebSearch.ID.make(id)
        const calls: WebSearch.ProviderInput[] = []
        yield* websearch.transform((editor) => {
          editor.add({
            id: providerID,
            name: id,
            execute: (input) =>
              Effect.gen(function* () {
                calls.push(input)
                if (mode.kind === "error500") return yield* Effect.fail(TestWebSearch.httpError(500))
                if (mode.kind === "empty") return []
                return [{ url: `https://${id}.example.com`, title: input.query, content: id, time: {} }]
              }),
          })
        })
        return { providerID, calls, mode }
      })
    
    describe("websearch random (#54311)", () => {
      it.effect("empty results from the chosen provider are not retried on another provider", () =>
        Effect.gen(function* () {
          const a = yield* register("firecrawl", { kind: "ok" })
          const b = yield* register("exa", { kind: "ok" })
          const websearch = yield* WebSearch.Service
          yield* websearch.select("random")
          const first = yield* websearch.query({ query: "pick" }, { sessionID: "ses_x" as any })
          const chosen = first.providerID === a.providerID ? a : b
          const other = chosen === a ? b : a
          chosen.mode.kind = "empty"
          const res = yield* websearch.query({ query: "q" }, { sessionID: "ses_x" as any })
          console.log("empty case ->", res.providerID, "results:", res.results.length, "other calls:", other.calls.length)
          expect(res.providerID).toBe(chosen.providerID)
          expect(res.results).toEqual([]) // "No search results found"
          expect(other.calls).toHaveLength(0) // working provider never tried
          const again = yield* websearch.query({ query: "q2" }, { sessionID: "ses_x" as any })
          expect(again.providerID).toBe(chosen.providerID) // session stays stuck
        }),
      )
    
      it.effect("HTTP 500 from the chosen provider fails the search instead of falling back", () =>
        Effect.gen(function* () {
          const a = yield* register("firecrawl", { kind: "ok" })
          const b = yield* register("exa", { kind: "ok" })
          const websearch = yield* WebSearch.Service
          yield* websearch.select("random")
          const first = yield* websearch.query({ query: "pick" }, { sessionID: "ses_y" as any })
          const chosen = first.providerID === a.providerID ? a : b
          const other = chosen === a ? b : a
          chosen.mode.kind = "error500"
          const err = yield* websearch.query({ query: "q" }, { sessionID: "ses_y" as any }).pipe(Effect.flip)
          console.log("500 case ->", err._tag, "other calls:", other.calls.length)
          expect(err._tag).toBe("WebSearch.Request")
          expect(other.calls).toHaveLength(0)
        }),
      )
    })

    Run it: bun websearch-random-repro.test.ts

  2. carson2222 commented on Oct 12, 2026

    @carson2222

    Checked live on v2.0.27 with no search keys connected. The Firecrawl empty results come from Firecrawl's keyless quota, not from a response-shape bug.

    A direct firecrawl_search call to https://mcp.firecrawl.dev/v2/mcp returns HTTP 200 with this tool result:

    {
      "isError": true,
      "structuredContent": { "code": "KEYLESS_QUOTA_EXHAUSTED", "auth_mode": "keyless" },
      "content": [{ "type": "text", "text": "You've hit Firecrawl's free MCP rate limit. To continue using without limits, create a Firecrawl API key. ..." }]
    }

    WebSearchMcp.call (packages/core/src/plugin/websearch/mcp.ts) ignores isError. The Firecrawl adapter can't decode the text as search JSON, so it returns [] and the model sees "No search results found". The status is 200, so random never cools the provider down or tries another one.

    Exa's keyless endpoint behaves the same way once its limit is hit. During a benchmark run I got some HTTP 429s and some HTTP 200 responses whose tool text was "You've hit Exa's free MCP rate limit...". parseResults finds no URL: lines in that text and returns [].

    In my session history, 10 of 39 Firecrawl searches under random ended this way.

    Suggested fix: when a tool result has isError: true, fail with the provider's message. Treat quota codes like KEYLESS_QUOTA_EXHAUSTED as a rate limit so random moves on to the next provider.

  3. fihaaade commented on Oct 18, 2026

    @fihaaade
    Contributor

    I'll take the empty-results part. Following @carson2222's trace, the plan is to make WebSearchMcp.call fail when the MCP tool result has isError: true, with the provider's message, so the quota error shows up instead of "No search results found". This applies to all MCP-based providers. Whether random should also rotate on that error, and not only on HTTP 429, changes retry behavior, so I'll leave it out of the PR and mention it there.

  4. u-k-g commented on Oct 26, 2026

    @u-k-g

    +1
    Opencode v2.1.4 was hitting 'No search results found. Please try a different query.' on every websearch. Explicitly pinning websearch provider to Exa fixed it:

      "websearch": {
        "provider": "exa"
      }
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions