feat: unified OpenRouter routing for all four AI providers (single key) - #3
Merged
alexpospekhov merged 4 commits intoMay 4, 2026
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR
Adds a
[openrouter]config section that, whenapi_keyis set, routes all four AI providers (openai / anthropic / perplexity / grok) through OpenRouter's OpenAI-compatible endpoint. Lets a user runsearchstack aiwith 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.pyandproviders/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 aiwith 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
Overriding model IDs in
.searchstack.tomlis how users pick stronger models (openai/gpt-4oinstead ofgpt-4o-mini,perplexity/llama-3.1-sonar-large-128k-onlinefor deeper research, etc.) without code changes.Behavior
openrouter.api_keyis empty → native code paths, zero behavior change for existing users. Everything works as before.https://openrouter.ai/api/v1/chat/completionswith Bearer auth and the configured model IDs./v1/messages,x-api-keyheader, content-block response format), but OpenRouter exposes Claude via OpenAI Chat Completions format (different request shape, different response parsing). I added a_check_via_openrouterhelper inanthropic_client.pythat handles the conversion.ai.pyacceptOPENROUTER_API_KEYas 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 aicompleted all four providers × 7 queries with valid responses from each, and the defensive Perplexity citation extraction (from #2) correctly pulledurl_citationannotations 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— newOpenrouterConfigdataclass withapi_key,base_url, per-provider model defaults. Wired intoConfig,_build_config, and_ENV_MAP(OPENROUTER_API_KEYenv var).providers/openai_client.py— checksconfig.openrouter.api_key; if set, swapsapi_url,model,api_keyto 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_openrouterhelper that converts Messages API format → Chat Completions format (payload + response parsing).commands/ai.py— provider gate checks now acceptOPENROUTER_API_KEYas an alternative to per-provider keys.Out of scope
base_urlTotally 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.