Skip to content

feat: unified OpenRouter routing for all four AI providers (single key) - #3

Merged
alexpospekhov merged 4 commits into
alexpospekhov:mainfrom
kirillgreen:feat/openrouter-unified-key
May 4, 2026
Merged

feat: unified OpenRouter routing for all four AI providers (single key)#3
alexpospekhov merged 4 commits into
alexpospekhov:mainfrom
kirillgreen:feat/openrouter-unified-key

Conversation

@kirillgreen

Copy link
Copy Markdown
Contributor

TL;DR

Adds a [openrouter] config section that, when api_key is set, routes all four AI providers (openai / anthropic / perplexity / grok) through OpenRouter's OpenAI-compatible endpoint. Lets a user run searchstack ai with one key and unified billing instead of four separate native keys. Fully backward-compatible — if the openrouter key is empty, everything works exactly as before.

Base branch note

This PR is stacked on top of #2 (bug fixes) because both touch commands/ai.py and providers/perplexity.py. If you merge #2 first, the branch auto-bases cleanly. Happy to rebase/re-target if you prefer to review in a different order.

Why

I manage AI API keys through OpenRouter for unified billing and single-point management across multiple projects. Putting 4 separate native keys (OpenAI, Anthropic, Perplexity, xAI) into Searchstack's config duplicates infrastructure I already have, and every new project needs 4 key rotations instead of 1. I wanted to run searchstack ai with one key, not four.

I suspect I'm not the only AI-native founder in that position. OpenRouter has become a meaningful default for people who run multiple AI-touching projects, and I think a lot of Searchstack's target audience already has an account there.

Config

[openrouter]
# api_key loaded from $OPENROUTER_API_KEY
# base_url = "https://openrouter.ai/api/v1"
# chatgpt_model = "openai/gpt-4o-mini"
# claude_model = "anthropic/claude-haiku-4.5"
# perplexity_model = "perplexity/sonar"
# grok_model = "x-ai/grok-3-mini"

Overriding model IDs in .searchstack.toml is how users pick stronger models (openai/gpt-4o instead of gpt-4o-mini, perplexity/llama-3.1-sonar-large-128k-online for deeper research, etc.) without code changes.

Behavior

  • If openrouter.api_key is empty → native code paths, zero behavior change for existing users. Everything works as before.
  • If set → all four providers use https://openrouter.ai/api/v1/chat/completions with Bearer auth and the configured model IDs.
  • Anthropic provider special-cased: the native path uses Anthropic's Messages API (/v1/messages, x-api-key header, content-block response format), but OpenRouter exposes Claude via OpenAI Chat Completions format (different request shape, different response parsing). I added a _check_via_openrouter helper in anthropic_client.py that handles the conversion.
  • Gate checks in ai.py accept OPENROUTER_API_KEY as an alternative to per-provider keys — otherwise every provider would skip with "no NATIVE_API_KEY" even when OpenRouter is the user's only configured credential.

Smoke test

Ran the full pipeline end-to-end against two production domains — searchstack ai completed all four providers × 7 queries with valid responses from each, and the defensive Perplexity citation extraction (from #2) correctly pulled url_citation annotations out of OpenRouter's response shape.

Cost for a full 7-query baseline across all four providers: ~$0.08 per run. Cheap enough to run daily.

File-by-file summary

  • config.py — new OpenrouterConfig dataclass with api_key, base_url, per-provider model defaults. Wired into Config, _build_config, and _ENV_MAP (OPENROUTER_API_KEY env var).
  • providers/openai_client.py — checks config.openrouter.api_key; if set, swaps api_url, model, api_key to OpenRouter equivalents. Same Chat Completions payload, simple three-variable swap.
  • providers/perplexity.py — same swap. The four-location citation extraction from Fix 3 bugs in searchstack ai found during real-world usage #2 already handles OpenRouter's annotation format.
  • providers/grok.py — same swap.
  • providers/anthropic_client.py_check_via_openrouter helper that converts Messages API format → Chat Completions format (payload + response parsing).
  • commands/ai.py — provider gate checks now accept OPENROUTER_API_KEY as an alternative to per-provider keys.

Out of scope

  • Native code paths — unchanged
  • Ollama provider — already supports arbitrary OpenAI-compatible backends via its own base_url
  • Sitemap commands (schema, meta, onpage, etc.) — unaffected
  • SEO / keyword / DataForSEO logic — unaffected

Totally understand if you'd rather not take this

There's a real argument that the point of Searchstack is measuring each provider's actual behavior, not abstracting them into a single call. If you'd rather keep the tool provider-native, no problem — the three bug fixes in #2 are independent of this and land cleanly standalone. Feel free to merge #2 and pass on this one.

Provider check_citation functions are signed as
(config, query_text, domain), but _check_provider was calling them as
(query, config.domain, config). The first positional argument — a plain
string — was passed as the `config` parameter, so the first attribute
access inside the provider (e.g. `config.openai.api_key`) raised
`AttributeError: 'str' object has no attribute 'openai'`.

The result is that `searchstack ai` cannot complete a single provider
call on v0.1.0 in any configuration. The command errors on the very
first query and every subsequent query.

This is a one-line swap to the correct positional order. The other
commands (schema, meta, onpage, llms, geo) are not affected because
they call the provider code directly with correct arguments.
Providers return {cited, text, citations, model, error} but the
ai_citations snapshot only stored {query, cited, url, raw}, where `url`
and `raw` are never populated by any provider — so every snapshot had
empty fields for the model's actual answer and the citations list.

On a zero-citation baseline this was nearly all of the run's value:
knowing that your own domain isn't cited matters much less than knowing
which domains the model DID cite in its place. The competitive
landscape data was thrown away before it ever touched disk.

This change extends each result entry to persist `text`, `citations`,
`model`, and `error` alongside the existing fields. Backward-compatible:
the pre-existing `url` and `raw` fields are kept untouched so any
downstream consumer of the snapshot format keeps working.

Also adds an inline UI hint: when a query returns "not cited" but has
citations, the console prints the top 3 competitor hostnames so users
see the competitive landscape at a glance without having to parse JSON:

    "best yacht broker for HNWI clients"  ❌ not cited   [cites: yachtmann.com, spearswms.com, ikonicyachts.com]
Perplexity citation data can be returned in four different places
depending on which backend answered the request:

  1. top-level `citations` (array of URL strings) — native Perplexity
     API on older Sonar models
  2. `choices[0].citations` — older passthrough wrappers
  3. `choices[0].message.citations` — some wrappers
  4. `choices[0].message.annotations[]` with `type == "url_citation"`
     and a nested `url_citation: {url, title, ...}` object — the
     current Perplexity annotation format (2026-04+), also used by
     OpenAI-compatible passthroughs such as OpenRouter

The original code only probed (1), so responses from backends that
returned their citations in (4) looked like they had zero citations
when they actually had 10+. This caused real false-negatives on
OpenRouter-routed Perplexity requests and on any newer Sonar model
that has migrated to the annotation format.

This change probes all four locations and concatenates whatever it
finds. Parse is defensive: each nested access checks for dict type
before attribute lookup, so a backend that returns a partial/novel
response shape degrades gracefully rather than crashing.

Also made the `text` extraction resilient to responses that don't have
`choices[0].message.content` — it now falls back to empty string
instead of raising KeyError on novel response shapes.
Adds an [openrouter] config section that, when api_key is set, routes
openai / anthropic / perplexity / grok requests through OpenRouter's
OpenAI-compatible endpoint using configurable model IDs, instead of
hitting each vendor's native API. This lets a user run `searchstack ai`
with a single key and unified billing — which matches how many
AI-native founders already manage their API access in 2026.

Fully backward-compatible. If `openrouter.api_key` is empty, the native
code paths are unchanged and existing users see no difference.

## Config

    [openrouter]
    # api_key loaded from $OPENROUTER_API_KEY
    # base_url = "https://openrouter.ai/api/v1"
    # chatgpt_model = "openai/gpt-4o-mini"
    # claude_model = "anthropic/claude-haiku-4.5"
    # perplexity_model = "perplexity/sonar"
    # grok_model = "x-ai/grok-3-mini"

## Changes per file

- `config.py` — new `OpenrouterConfig` dataclass with api_key,
  base_url, and per-provider model overrides. Wired into `Config`,
  `_build_config`, and `_ENV_MAP` (OPENROUTER_API_KEY env var).

- `providers/openai_client.py` — checks `config.openrouter.api_key` at
  the top of `check_citation`. If set, swaps `api_url`, `model`, and
  `api_key` to the OpenRouter equivalents. Same OpenAI Chat Completions
  payload shape, so this is a simple three-variable swap.

- `providers/perplexity.py` — same swap. The defensive four-location
  citation extraction added in the previous fix commit already handles
  OpenRouter's `choices[0].message.annotations[]` response format, so
  no additional parsing changes are needed here.

- `providers/grok.py` — same swap. Also cleans up the early-return
  error message to mention OpenRouter as an alternative.

- `providers/anthropic_client.py` — special-cased because the native
  path uses Anthropic's Messages API (/v1/messages, x-api-key header,
  content-block response format) while OpenRouter exposes Claude via
  standard OpenAI Chat Completions format (different payload, different
  response parsing). Added a `_check_via_openrouter` helper that
  handles the full conversion.

- `commands/ai.py` — provider gate checks now accept
  `OPENROUTER_API_KEY` as an alternative to per-provider keys.
  Otherwise every provider would skip with "no NATIVE_API_KEY" even
  when OpenRouter is the user's only configured credential.

## Cost

A full 7-query baseline across all 4 providers via OpenRouter runs at
roughly $0.08, which is cheap enough to run daily per project.

## Out of scope

Not touched: native-key code paths, Ollama provider (already supports
arbitrary OpenAI-compatible backends via `base_url`), sitemap-based
commands (schema, meta, etc.), any SEO / keyword / DataForSEO logic.
@alexpospekhov
alexpospekhov merged commit e9d53b3 into alexpospekhov:main May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants