Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ ketch search "query" -b firecrawl # use Firecrawl v2 search API (keyle
ketch search "query" -b keenable # use Keenable backend (keyless by default)
ketch search "query" -b tavily # use Tavily search API (keyed; basic depth)
ketch search "query" -b tinyfish # use TinyFish Search API (keyed; free tier)
ketch search "query" -b parallel # use Parallel Search MCP (keyless)
ketch search "query" -b parallel # use Parallel Search MCP (keyless by default)
ketch search "query" -b serpbase # use SerpBase Google Search API (keyed)
ketch search "query" -b degoog # use a self-hosted Degoog instance (degoog_url)
ketch search "query" -b serply # use Serply's Google Search API (keyed)
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ verbatim on the GitHub release. Versions follow

## [Unreleased]

**Parallel accepts an optional API key.** Parallel's Search MCP stays keyless by default; set `parallel_api_key` (or `KETCH_PARALLEL_API_KEY`, which takes a comma-separated list) to send it as a Bearer token and get Parallel's higher limits. `parallel_api_keys` rotates several keys like the other providers, a configured key promotes Parallel in `auto`, and `ketch doctor` reports a rejected key as misconfigured. Credential errors name the key by position (`key 1 of 2`), and a key the service echoes back into an error, a response body, or a redirect is replaced with `[redacted]` in errors and doctor output. Without a key, requests, errors, and doctor output are unchanged.

**Patched HTML parser and Go toolchain.** ketch parses arbitrary web pages with `golang.org/x/net/html`, and the version it shipped with had known denial-of-service and parsing flaws reachable from extraction; it now uses `golang.org/x/net` v0.61.0 and `golang.org/x/text` v0.43.0. Release binaries are built with Go 1.26.9, which fixes a set of `net/http`, `crypto/tls` and `net/textproto` advisories, so building from source now needs Go 1.26.9 or newer. CI runs `govulncheck` on every change (`make vulncheck` locally).

**`http_headers` values no longer reach instance errors or `ketch doctor`.** A SearXNG, Firecrawl, Degoog, or Sourcegraph instance that echoed a header value back could get it into the error ketch printed, or a doctor detail, through a response body, or through the URL or `Location` that Go's HTTP client quotes when it cannot follow a redirect. Every instance error and doctor detail now has every configured `http_headers` value replaced with `[redacted]`, including values for another origin the instance redirects to. Firecrawl redacts an error body before cutting it to 4096 bytes, so a value at the cut is no longer left half-visible. A header the request already sets is now recognised in any capitalisation and is no longer sent twice.
Expand Down
4 changes: 2 additions & 2 deletions MANUAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,8 +592,8 @@ rather than averaged away.
#### Keyless — Nothing to configure

`parallel`, `exa`, `keenable`, `youcom`, `firecrawl`, and `ddg` answer with no
setup. Keys for exa, keenable, youcom, and firecrawl are optional and lift the
hosted caps. ddg rate-limits readily under fan-out.
setup. Keys for parallel, exa, keenable, youcom, and firecrawl are optional and
lift the hosted caps. ddg rate-limits readily under fan-out.

#### Keyed — Free key, then promoted by auto

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ Every command supports `-h/--help` for its full flag list; `--json` is the only

| Surface | Default | Also available | Setup |
|---|---|---|---|
| `search` | `auto` | `brave`, `ddg`, `searxng`, `exa`, `firecrawl`, `keenable`, `tavily`, `tinyfish`, `parallel`, `serpbase`, `degoog`, `serply`, `youcom` | Nothing — `auto` falls back through the keyless providers (`parallel` → `exa` → `keenable` → `youcom` → `firecrawl` → `ddg`) and needs no key. Brave, Tavily, TinyFish, SerpBase, and Serply need a free key (`ketch config set brave_api_key <key>` / `tavily_api_key` / `tinyfish_api_key` / `serpbase_api_key` / `serply_api_key`) and `auto` prefers them once set; `firecrawl_api_key`, `exa_api_key`, `keenable_api_key`, and `youcom_api_key` are optional and lift the hosted caps. `degoog` needs a self-hosted instance (`ketch config set degoog_url <url>`), `searxng` an instance URL (`ketch config set searxng_url <url>`); both are preferred over hosted APIs once configured |
| `search` | `auto` | `brave`, `ddg`, `searxng`, `exa`, `firecrawl`, `keenable`, `tavily`, `tinyfish`, `parallel`, `serpbase`, `degoog`, `serply`, `youcom` | Nothing — `auto` falls back through the keyless providers (`parallel` → `exa` → `keenable` → `youcom` → `firecrawl` → `ddg`) and needs no key. Brave, Tavily, TinyFish, SerpBase, and Serply need a free key (`ketch config set brave_api_key <key>` / `tavily_api_key` / `tinyfish_api_key` / `serpbase_api_key` / `serply_api_key`) and `auto` prefers them once set; `firecrawl_api_key`, `exa_api_key`, `keenable_api_key`, `parallel_api_key`, and `youcom_api_key` are optional and lift the hosted caps. `degoog` needs a self-hosted instance (`ketch config set degoog_url <url>`), `searxng` an instance URL (`ketch config set searxng_url <url>`); both are preferred over hosted APIs once configured |
| `code` | `grepapp` | `sourcegraph`, `github` | Grep and Sourcegraph need nothing; GitHub uses `gh auth login`, `$GITHUB_TOKEN`, or `ketch config set github_token <tok>` |
| `docs` | `context7` | `local` (planned, not yet implemented) | Free key: `ketch config set context7_api_key <key>` |

Expand Down Expand Up @@ -288,7 +288,7 @@ Precedence is **CLI flag > `KETCH_*` env > config file > built-in default**. Not
- Invalid env values (e.g. `KETCH_LIMIT=abc`) fail loudly on commands that use config, naming the offending variable; `ketch version` and `ketch config set/path` still work.
- Secret `KETCH_*` vars are stripped from the environment of spawned subprocesses (headless browser, external PDF converter).

Other configurable keys include per-backend API keys (`brave_api_key`, `brave_api_keys` for multi-key rotation, `exa_api_key`, `firecrawl_api_key`, `keenable_api_key`, `tavily_api_key`, `tinyfish_api_key`, `serpbase_api_key`, `serply_api_key`, `youcom_api_key`, `context7_api_key`, `github_token`), `firecrawl_url` / `sourcegraph_url` / `degoog_url` (self-hosted overrides), `http_headers` (per-origin headers, such as a Cloudflare Access service token, for self-hosted SearXNG, Firecrawl, Degoog, and Sourcegraph instances), `cache_ttl`, `url_rewrites` (regex rewrite rules applied before fetch), `spa_markers` (extra JS-shell detection tokens), `cookie_file` (see below), `user_agent` (User-Agent override for HTTP and browser fetches; setting one scopes cached pages to it, so entries cached under the default stay valid), `extract_mode` (`clean` by default, or `complete`; see [extraction](#extraction) — a non-default mode scopes cached pages the same way), and the optional external PDF converter command/timeout. Multiple keys per provider are picked randomly per request to spread rate limits. See the [config reference](https://ketch.run/) for the full list.
Other configurable keys include per-backend API keys (`brave_api_key`, `brave_api_keys` for multi-key rotation, `exa_api_key`, `firecrawl_api_key`, `keenable_api_key`, `parallel_api_key`, `tavily_api_key`, `tinyfish_api_key`, `serpbase_api_key`, `serply_api_key`, `youcom_api_key`, `context7_api_key`, `github_token`), `firecrawl_url` / `sourcegraph_url` / `degoog_url` (self-hosted overrides), `http_headers` (per-origin headers, such as a Cloudflare Access service token, for self-hosted SearXNG, Firecrawl, Degoog, and Sourcegraph instances), `cache_ttl`, `url_rewrites` (regex rewrite rules applied before fetch), `spa_markers` (extra JS-shell detection tokens), `cookie_file` (see below), `user_agent` (User-Agent override for HTTP and browser fetches; setting one scopes cached pages to it, so entries cached under the default stay valid), `extract_mode` (`clean` by default, or `complete`; see [extraction](#extraction) — a non-default mode scopes cached pages the same way), and the optional external PDF converter command/timeout. Multiple keys per provider are picked randomly per request to spread rate limits. See the [config reference](https://ketch.run/) for the full list.

### Cookies (BYO cookies.txt)

Expand Down
2 changes: 2 additions & 0 deletions cmd/testdata/config-configured.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
],
"tinyfish_api_key_set": false,
"tinyfish_api_keys_count": 0,
"parallel_api_key_set": false,
"parallel_api_keys_count": 0,
"degoog_url": "",
"serply_api_key_set": false,
"serply_api_keys_count": 0
Expand Down
2 changes: 2 additions & 0 deletions cmd/testdata/config-defaults.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@
],
"tinyfish_api_key_set": false,
"tinyfish_api_keys_count": 0,
"parallel_api_key_set": false,
"parallel_api_keys_count": 0,
"degoog_url": "",
"serply_api_key_set": false,
"serply_api_keys_count": 0
Expand Down
26 changes: 17 additions & 9 deletions httpx/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,25 @@ func NewRedactor(headers map[string]map[string]string) *Redactor {
if err != nil {
return nil // nothing is sent for an invalid configuration
}
seen := make(map[string]bool)
var forms []string
var all []string
for _, h := range byOrigin {
for _, values := range h {
for _, value := range values {
for _, form := range valueForms(value) {
if form != "" && !seen[form] {
seen[form] = true
forms = append(forms, form)
}
}
all = append(all, values...)
}
}
return NewValueRedactor(all...)
}

// NewValueRedactor returns a Redactor for the given secret values, such as a
// provider's API keys, or nil when all of them are blank.
func NewValueRedactor(values ...string) *Redactor {
seen := make(map[string]bool)
var forms []string
for _, value := range values {
for _, form := range valueForms(value) {
if form != "" && !seen[form] {
seen[form] = true
forms = append(forms, form)
}
}
}
Expand Down
11 changes: 11 additions & 0 deletions httpx/redact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,14 @@ func TestRedactorExcerpt(t *testing.T) {
}
})
}

func TestNewValueRedactor(t *testing.T) {
if r := NewValueRedactor("", ""); r != nil {
t.Fatalf("blank values: got %v, want nil", r)
}
r := NewValueRedactor("sk/1+2")
got := r.String("Bearer sk/1+2 ?key=sk%2F1%2B2")
if want := "Bearer [redacted] ?key=[redacted]"; got != want {
t.Fatalf("String = %q, want %q", got, want)
}
}
18 changes: 18 additions & 0 deletions search/key_rotation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,24 @@ func TestBackendsRetryEveryCredentialStatus(t *testing.T) {
},
requestKey: func(r *http.Request) string { return r.Header.Get("X-API-Key") },
},
{
name: "parallel 401",
status: http.StatusUnauthorized,
successBody: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"results\":[]}"}]}}`,
newBackend: func(client *http.Client) Searcher {
return &Parallel{keys: deterministicPool("first", "second"), client: client, endpoint: parallelEndpoint}
},
requestKey: func(r *http.Request) string { return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") },
},
{
name: "parallel 429",
status: http.StatusTooManyRequests,
successBody: `{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"results\":[]}"}]}}`,
newBackend: func(client *http.Client) Searcher {
return &Parallel{keys: deterministicPool("first", "second"), client: client, endpoint: parallelEndpoint}
},
requestKey: func(r *http.Request) string { return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") },
},
{
name: "youcom 401",
status: http.StatusUnauthorized,
Expand Down
146 changes: 130 additions & 16 deletions search/parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,24 @@ const (
parallelDescriptionMaxRunes = 300
)

// Parallel searches through Parallel's hosted Search MCP. It is keyless by
// default (rate-limited); an optional API key, sent as a Bearer token, lifts
// the hosted limits.
type Parallel struct {
keys keyPool
redact *httpx.Redactor // API key values, kept out of errors
client *http.Client
endpoint string
}

// NewParallel creates a keyless Parallel Search backend.
func NewParallel() *Parallel {
return &Parallel{client: httpx.Default(), endpoint: parallelEndpoint}
return newParallelWithKeys(nil)
}

func newParallelWithKeys(keys []string) *Parallel {
pool := newKeyPool(keys)
return &Parallel{keys: pool, redact: httpx.NewValueRedactor(pool.keys...), client: httpx.Default(), endpoint: parallelEndpoint}
}

type parallelRPCResponse struct {
Expand Down Expand Up @@ -54,7 +64,20 @@ type parallelSearchPayload struct {
// Search calls Parallel's hosted Search MCP and maps its ranked results into
// Ketch's provider-neutral result shape. Parallel does not expose a result
// limit argument, so the requested limit is applied locally.
//
// Every error is passed through the key redactor: the service, or a hop it
// redirects to, can echo a credential into a status body, a JSON-RPC message,
// or a Location that net/http quotes. Keyless backends have no redactor and
// return errors unchanged.
func (p *Parallel) Search(ctx context.Context, query string, limit int) ([]Result, error) {
results, err := p.search(ctx, query, limit)
if err != nil {
return nil, p.redact.Error(err)
}
return results, nil
}

func (p *Parallel) search(ctx context.Context, query string, limit int) ([]Result, error) {
if limit <= 0 {
return []Result{}, nil
}
Expand All @@ -75,21 +98,14 @@ func (p *Parallel) Search(ctx context.Context, query string, limit int) ([]Resul
return nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(body))
resp, key, err := p.post(ctx, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")

resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("parallel request failed: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return nil, parallelStatusError(resp)
return nil, p.statusError(resp, key)
}

var rpc parallelRPCResponse
Expand Down Expand Up @@ -182,24 +198,122 @@ func nonEmptyStrings(values []string) []string {
return result
}

func parallelStatusError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if detail := strings.TrimSpace(string(body)); detail != "" {
// post sends one request with a key from the pool, retrying once with a
// different key when the first is rejected or rate limited. Keyless backends
// send a single unauthenticated request.
func (p *Parallel) post(ctx context.Context, body []byte) (*http.Response, string, error) {
key := p.keys.pick()
resp, err := p.request(ctx, body, key)
if err != nil {
return nil, key, err
}
if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusTooManyRequests) && p.keys.size() > 1 {
closeSearchResponse(resp)
key = p.keys.pickDifferent(key)
resp, err = p.request(ctx, body, key)
}
return resp, key, err
}

func (p *Parallel) request(ctx context.Context, body []byte, key string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("parallel request failed: %w", err)
}
return resp, nil
}

// statusError maps credential failures on a keyed request to actionable
// errors that name the key by position, never by value. Keyless requests keep
// the generic status error.
func (p *Parallel) statusError(resp *http.Response, key string) error {
if key != "" {
switch resp.StatusCode {
case http.StatusUnauthorized:
return fmt.Errorf("parallel: invalid API key (%s; set via: ketch config set parallel_api_key <key>)", p.keys.keyLabel(key))
case http.StatusPaymentRequired:
return fmt.Errorf("parallel: payment required (%s; check your Parallel plan)", p.keys.keyLabel(key))
case http.StatusTooManyRequests:
return fmt.Errorf("parallel: rate limited (%s)", p.keys.keyLabel(key))
}
}
return parallelStatusError(resp, p.redact)
}

// parallelStatusError quotes up to 4096 bytes of the response body. Key
// values are redacted before the body is cut, so a key at the cut is not left
// half-visible; a nil redactor quotes the body as it is.
func parallelStatusError(resp *http.Response, redact *httpx.Redactor) error {
if detail := strings.TrimSpace(redact.Excerpt(resp.Body, 4096)); detail != "" {
return fmt.Errorf("parallel returned status %d: %s", resp.StatusCode, detail)
}
return fmt.Errorf("parallel returned status %d", resp.StatusCode)
}

// ProbeParallel checks the provider using a caller-supplied client and
// endpoint. Without a key it is the generic MCP reachability check; with one
// it sends the key, reports a rejected key as misconfigured, and redacts the
// key from the detail.
func ProbeParallel(ctx context.Context, client *http.Client, endpoint, apiKey string) (health.Status, string) {
key := strings.TrimSpace(apiKey)
if key == "" {
return health.ProbeMCP(ctx, client, endpoint, "parallel")
}
status, detail := probeParallelKeyed(ctx, client, endpoint, key)
return status, httpx.NewValueRedactor(key).String(detail)
}

func probeParallelKeyed(ctx context.Context, client *http.Client, endpoint, key string) (health.Status, string) {
body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
if err != nil {
return health.StatusUnreachable, health.ErrorDetail(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return health.StatusUnreachable, health.ErrorDetail(err)
}
defer health.Drain(resp)

switch resp.StatusCode {
case http.StatusOK:
return health.StatusOK, ""
case http.StatusUnauthorized, http.StatusForbidden:
return health.StatusMisconfigured, "API key rejected (ketch config set parallel_api_key <key>)"
case http.StatusPaymentRequired:
return health.StatusMisconfigured, "payment required for this API key (check your Parallel plan)"
case http.StatusTooManyRequests:
return health.StatusOK, "reachable, key accepted (rate limited)"
default:
return health.StatusUnreachable, fmt.Sprintf("parallel returned status %d", resp.StatusCode)
}
}

func parallelProvider() Provider {
keys := config.KeyPool("parallel_api_key", "parallel_api_keys")
return Provider{
Settings: []config.Setting{},
Settings: []config.Setting{keys},
AutoRank: 70,
ID: "parallel",
Name: "Parallel",
Usable: func(*config.Config) bool { return true },
New: func(c *config.Config) (Searcher, error) { return NewParallel(), nil },
New: func(c *config.Config) (Searcher, error) { return newParallelWithKeys(keys.Keys(c)), nil },
Probe: func(ctx context.Context, client *http.Client, c *config.Config) (health.Status, string) {
return health.ProbeMCP(ctx, client, "https://search.parallel.ai/mcp", "parallel")
return health.ProbeKeyPool(keys.Keys(c), func(key string) (health.Status, string) {
return ProbeParallel(ctx, client, parallelEndpoint, key)
})
},
}
}
Loading