Skip to content

feat: add OpenRouter provider support#71

Merged
Marius1311 merged 11 commits into
quadbio:mainfrom
mossishahi:feat/openrouter-provider-support
May 8, 2026
Merged

feat: add OpenRouter provider support#71
Marius1311 merged 11 commits into
quadbio:mainfrom
mossishahi:feat/openrouter-provider-support

Conversation

@mossishahi
Copy link
Copy Markdown
Collaborator

Add OpenRouter as a first-class LLM provider alongside OpenAI, Gemini, and Anthropic. OpenRouter aggregates models from many upstream providers behind a single API key, enabling access to a wide range of models (e.g. openai/gpt-4o-mini, anthropic/claude-3.5-sonnet, deepseek/deepseek-v3.2).

Changes:

  • Register 'openrouter' in supported_providers and default_models.
  • Add OpenRouterProvider (extends OpenAIProvider with custom base_url).
  • Add OPENROUTER_API_KEY to APIKeyManager config.
  • Auto-detect OpenRouter from slash-style model IDs (e.g. provider/model).
  • Accept any listed OpenRouter model in test_query (catalog-based check).
  • Add structured-output JSON fallback for models that don't support OpenAI's .parse() endpoint (with text-repair recovery path).
  • Fix BaseAnnotator.query_llm signature to accept agent_description kwarg.
  • Update README with OpenRouter setup and usage documentation.
  • Add tutorial notebook: 110_openrouter_sample_annotation.ipynb.
  • Extend tests for providers, API keys, model detection, and integration.

@mossishahi mossishahi force-pushed the feat/openrouter-provider-support branch 2 times, most recently from 221ca47 to dcc6bf6 Compare March 23, 2026 19:16
Copy link
Copy Markdown
Member

@Marius1311 Marius1311 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, thanks for the contribution! Just a few changes.

Comment thread src/cell_annotator/model/_api_keys.py Outdated
Comment thread src/cell_annotator/model/_providers.py
Comment thread src/cell_annotator/model/_providers.py Outdated
assert provider_with_key._api_key == "test-key"


class TestOpenRouterProvider:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this to work, I guess we would have to store an OpenRouter API key as a secret in the repo? I think that's how I do it for the other providers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Marius1311 Yes, exactly — the real-API tests are guarded by @pytest.mark.skipif(not os.getenv("OPENROUTER_API_KEY"), ...) and will be skipped unless the secret is configured. The unit tests (initialization, repr, factory pattern, error handling) work without any key. If you'd like to enable the real-API tests in CI, an OPENROUTER_API_KEY secret would need to be added to the repo (same pattern as the existing OPENAI_API_KEY, GEMINI_API_KEY, and ANTHROPIC_API_KEY secrets). Happy to leave that to your discretion.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should set that up - I have it for the other providers, making sure that CI only queries there very cheap models, so that one scheduled CI run costs about 0.01 USD or so.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Marius1311 Sounds good! The default OpenRouter model is already set to openai/gpt-4o-mini (same as the OpenAI default), which is one of the cheapest options available. The real-API tests use that default with max_completion_tokens=50, so cost per CI run should be well under $0.01. Happy to help set up the secret once this PR is merged, or you can add OPENROUTER_API_KEY to the repo secrets at your convenience.

Add OpenRouter as a first-class LLM provider alongside OpenAI, Gemini,
and Anthropic. OpenRouter aggregates models from many upstream providers
behind a single API key, enabling access to a wide range of models
(e.g. openai/gpt-4o-mini, anthropic/claude-3.5-sonnet, deepseek/deepseek-v3.2).

Changes:
- Register 'openrouter' in supported_providers and default_models.
- Add OpenRouterProvider (extends OpenAIProvider with custom base_url).
- Add OPENROUTER_API_KEY to APIKeyManager config.
- Auto-detect OpenRouter from slash-style model IDs (e.g. provider/model).
- Accept any listed OpenRouter model in test_query (catalog-based check).
- Add structured-output JSON fallback for models that don't support
  OpenAI's .parse() endpoint (with text-repair recovery path).
- Fix BaseAnnotator.query_llm signature to accept agent_description kwarg.
- Update README with OpenRouter setup and usage documentation.
- Add tutorial notebook: 110_openrouter_sample_annotation.ipynb.
- Extend tests for providers, API keys, model detection, and integration.

Made-with: Cursor
@mossishahi mossishahi force-pushed the feat/openrouter-provider-support branch from 02b5095 to a6a219f Compare March 24, 2026 10:23
@Marius1311
Copy link
Copy Markdown
Member

@mossishahi , are tests passing for you locally? I think it's because the PR is from a fork, where the API keys aren't set up.

@mossishahi
Copy link
Copy Markdown
Collaborator Author

@mossishahi , are tests passing for you locally? I think it's because the PR is from a fork, where the API keys aren't set up.

@Marius1311 Yes, confirmed locally — the same 13 tests fail with ValueError: No API keys found both with and without my changes. All 13 are test_detect_provider_from_model parametrizations + test_provider_auto_detection, which require at least one API key to be set. The 2 new OpenRouter parametrizations fail identically to the 10 pre-existing ones. The remaining 38 tests pass, 7 are skipped (guarded by skipif). This is purely a missing-secrets issue in the fork PR context — once merged and run from the upstream CI with secrets, all tests should pass.

Copy link
Copy Markdown
Member

@Marius1311 Marius1311 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution, @mossishahi, and welcome to the project! The OpenRouter integration is well-scoped and modeling it as a subclass of OpenAIProvider is the right call since OpenRouter exposes an OpenAI-compatible API. Tests and README are updated appropriately, and I ran the full test suite locally on this branch — all 193 tests pass (2 OpenRouter real-LLM tests skip without OPENROUTER_API_KEY). Docs also build cleanly.

One blocking concern on the code side, one blocking concern on the tutorial, and a few smaller items — all detailed inline. The main code ask is around the new JSON-fallback and text-repair path in OpenAIProvider, which touches a critical invariant we track in AGENTS.md ("Never parse free-form LLM text in production paths").

A quick note on CI: the 4 failing hatch-test jobs are all failing at llm_interface.py:50 with No API keys found, because repository secrets are not exposed to PRs from forks — not your fault. A maintainer will need to re-run after merge-readiness. (Separately, this surfaces a pre-existing latent bug where LLMInterface(_skip_validation=True) still raises when both provider and model are None; out of scope here.)

Asks, in priority order

  1. [blocking] Make the JSON-fallback + text-repair path opt-in — default off for OpenAIProvider, default on for OpenRouterProvider. See inline on _providers.py.
  2. [blocking] Rework the new tutorial to match the style of the existing tutorials (env-var keys, real downloadable data). See inline on the notebook.
  3. Use response_format.model_validate_json(...) in the JSON-candidate parse step instead of response_format(**json.loads(...)).
  4. Narrow the bare except Exception in query so we don't silently route KeyboardInterrupt-adjacent or import errors into the fallback.
  5. Strengthen the data-privacy note in the README for OpenRouter.
  6. Document the weaker test_query guarantee for OpenRouter.

Happy to discuss structure before you dig in, especially on #1. Thanks again!

Comment thread src/cell_annotator/model/_providers.py Outdated
Comment thread src/cell_annotator/model/_providers.py Outdated
Comment thread src/cell_annotator/model/llm_interface.py Outdated
Comment thread README.md
Comment thread docs/notebooks/tutorials/110_openrouter_sample_annotation.ipynb Outdated
Comment thread src/cell_annotator/model/_api_keys.py Outdated
provider = "gemini"
elif any(claude_name in model_lower for claude_name in ["claude", "anthropic"]):
provider = "anthropic"
elif "/" in model and not model_lower.startswith("models/"):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit / follow-up: this "/" in model and not model_lower.startswith("models/") rule is duplicated in llm_interface.py:126. Worth pulling into a small shared helper in a follow-up PR — not blocking here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded into this PR as bf52712 — extracted a shared detect_provider_from_model free function in _api_keys.py backed by PROVIDER_CONFIG[*].model_keywords. Both validate_model_access and LLMInterface._detect_provider_from_model now delegate to it; the unified keyword set is the union of the two prior lists.

Marius1311 added 7 commits May 7, 2026 16:44
When both OPENROUTER_API_KEY and OPENAI_API_KEY are set in the environment,
``OpenRouterProvider()`` previously inherited the OpenAI key via the shared
SDK client, producing 401 'Missing Authentication header' errors against
``https://openrouter.ai/api/v1``. Resolve the OpenRouter key from env in
``__init__`` so the OpenAI SDK's default lookup never gets a chance to
pick up the wrong key. Manual ``api_key=`` overrides still win.

The two ``_api_key is None`` assertions in the init tests are dropped --
they encoded the previous (broken) behaviour.
``LLMInterface(_skip_validation=True)`` previously still raised
``ValueError("No API keys found")`` when both ``provider`` and ``model``
were ``None`` and the environment had no provider keys. This blocked the
fork-PR CI runs noted in the PR review and any caller relying on the
validation skip.

Skip the env-keys probe when ``_skip_validation`` is set or a manual
``api_key`` is supplied; default to ``"openai"`` and its default model.
The slash-detection rule (``"/" in model and not model_lower.startswith("models/")``)
was duplicated in ``APIKeyManager.validate_model_access`` and
``LLMInterface._detect_provider_from_model``, with subtly diverging keyword
lists. Pull both into a single ``detect_provider_from_model`` free function
in ``_api_keys.py`` backed by ``PROVIDER_CONFIG[*].model_keywords``. The
unified keyword set is the union of the two prior lists -- strictly more
permissive than either, so no callers regress.

Addresses follow-up nit on _api_keys.py:194.
``LLMInterface.test_query`` had a special branch for OpenRouter that
bypassed ``query_llm`` and only checked catalog membership, on the
assumption that structured outputs were too unreliable on OpenRouter to
test live. With the upcoming tiered fallback chain in OpenAIProvider
(``extra_body`` json_schema → plain ``json_object`` → optional text-repair),
the same structured-output round-trip succeeds for OpenRouter slugs too.

Drop the special branch -- ``test_query`` now exercises the real path
end-to-end for every provider. This also removes the asymmetry that was
forcing skip-guards in the parametrized ``test_query_*`` unit tests.

Addresses review thread on llm_interface.py:200 (weaker test_query
guarantee for OpenRouter): the right fix is to make the guarantee
uniform, not to document the gap.
The PR's ``OpenAIProvider`` JSON fallback applied a free-form-text recovery
pipeline (``_query_with_json_fallback`` + ``_repair_text_to_json``) to *all*
callers via a bare ``except Exception``. This conflicts with the project
invariant in AGENTS.md ("Never parse free-form LLM text in production paths"):
text-repair hides schema drift, doubles token spend on silent failure, and
the bare ``except`` rerouted unrelated errors (KeyboardInterrupt-adjacent,
import errors, etc.) into the recovery path.

Three changes, all driven by the review:

1. Gate text-repair behind ``enable_text_repair: bool = False`` on
   ``OpenAIProvider``; ``OpenRouterProvider`` opts in (``True``) where
   upstream-model variability legitimately needs a last-resort recovery.

2. Reshape ``_query_with_json_fallback`` into a tiered chain:

     Tier 1: ``extra_body={"response_format": {"type": "json_schema", ...}}``
             -- the canonical OpenRouter structured-output path (per
             OpenRouter docs and LiteLLM #11652). Strongest signal short
             of ``.parse()``; many upstream models honour it even when
             ``.parse()`` doesn't work end-to-end.
     Tier 2: plain ``json_object`` mode with the schema interpolated into
             the prompt (existing behaviour, retained as a backstop).
             ``model_validate_json`` is now used consistently across both
             the strict and ``_extract_json_candidate`` paths.
     Tier 3: ``_repair_text_to_json`` -- gated on the new flag.

   Adding tier 1 also means OpenRouter rarely reaches text-repair in
   practice -- weak slugs go through the schema-aware path first.

3. Narrow the bare ``except Exception`` in ``query()`` to
   ``except (ValueError, TypeError)``; re-raise unconditionally when
   ``enable_text_repair`` is off. The ``openai.OpenAIError`` branch
   stays as the legitimate fallback for native OpenAI users.

Bumps two recovery-path log statements from ``debug`` to ``warning`` so the
fallback is visible when it engages, and adds a TODO next to
``_extract_json_candidate`` flagging its ``find('{')/rfind('}')`` brittleness.

Adds:
- 5 unit tests in ``TestJSONFallback`` covering flag wiring, chain
  ordering, the gate, and the local-error re-raise.
- 1 real-LLM test (``anthropic/claude-haiku-4-5`` via OpenRouter) that
  exercises the new ``extra_body`` tier end-to-end.

Addresses review threads on _providers.py:202 (gate text-repair),
_providers.py:276 (model_validate_json consistency + _extract_json_candidate
TODO), and the bare-except concern.
OpenRouter forwards requests to a chosen upstream provider under
OpenRouter's own ToS, so the effective attack surface is wider than for
the three native providers. Spell out the forwarding behaviour, point at
both privacy policies, and flag that some tiers may log prompts by
default unless the user configures otherwise via account settings.

Addresses review thread on README.md:125.
The tutorial added by the PR required users to paste an API key inline
and supply their own ``ADATA_PATH``, so it could not run end-to-end out
of the box. Reworking it to match the heart-atlas style (env-var key +
figshare-backed dataset) is better folded into an existing tutorial
demonstrating ``provider="openrouter"``, which is a follow-up.

The ``:glob:`` directive in ``docs/notebooks/tutorials/index.rst``
removes the listing automatically -- no other doc references.

Addresses review thread on the tutorial notebook.
@Marius1311
Copy link
Copy Markdown
Member

Pushed 7 commits addressing the review (replies inline above). Two of them are out-of-band fixes worth flagging:

  • 8b1cc2f fix(openrouter): resolve OPENROUTER_API_KEY explicitly in provider init — pre-existing bug where OpenRouterProvider() would inherit OPENAI_API_KEY via the shared SDK client when both keys were set, producing 401s. Surfaced when verifying the new fallback chain locally.
  • 5f23ead fix(llm-interface): respect _skip_validation in auto-select branch — the latent bug noted as out of scope in the review summary, folded in at maintainer's request. Should also unblock the failing hatch-test CI jobs that were hitting No API keys found on fork PRs.

Verified locally:

  • pre-commit run --all-files: clean.
  • pytest -m 'not real_llm_query': 154 passed.
  • pytest -m real_llm_query against OpenAI / Gemini / OpenRouter (Anthropic key not set locally): all pass, including the new extra_body-tier test against anthropic/claude-haiku-4-5.

…in env

``test_provider_auto_detection`` constructs ``BaseAnnotator()`` without
``_skip_validation``, so it really does need at least one provider key in
env to exercise the auto-select branch. On fork-PR CI runs (where repo
secrets are not exposed) it crashes with "No API keys found".

Mark it ``skipif`` when none of the four provider keys are available,
matching the pattern used for ``real_llm_query`` tests. Locally with keys
configured, the test still runs and asserts auto-detection picked a
provider.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 7, 2026

Codecov Report

❌ Patch coverage is 66.08187% with 58 lines in your changes missing coverage. Please review.
✅ Project coverage is 46.61%. Comparing base (3986ed3) to head (f3e15a2).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
src/cell_annotator/model/_providers.py 65.35% 53 Missing ⚠️
src/cell_annotator/model/_api_keys.py 81.81% 2 Missing ⚠️
src/cell_annotator/model/base_annotator.py 0.00% 2 Missing ⚠️
src/cell_annotator/model/cell_annotator.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #71       +/-   ##
===========================================
- Coverage   79.95%   46.61%   -33.34%     
===========================================
  Files          15       15               
  Lines        1237     1375      +138     
===========================================
- Hits          989      641      -348     
- Misses        248      734      +486     
Files with missing lines Coverage Δ
src/cell_annotator/_constants.py 92.85% <100.00%> (ø)
src/cell_annotator/model/llm_interface.py 40.69% <100.00%> (-48.44%) ⬇️
src/cell_annotator/model/sample_annotator.py 18.33% <ø> (-56.67%) ⬇️
src/cell_annotator/model/cell_annotator.py 18.04% <0.00%> (-72.19%) ⬇️
src/cell_annotator/model/_api_keys.py 77.06% <81.81%> (+7.33%) ⬆️
src/cell_annotator/model/base_annotator.py 28.57% <0.00%> (-64.12%) ⬇️
src/cell_annotator/model/_providers.py 58.72% <65.35%> (-15.41%) ⬇️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Marius1311 Marius1311 merged commit 212a86c into quadbio:main May 8, 2026
9 checks passed
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