Skip to content

[backend] OpenRouter LLMBackend — meta-provider routing + model.md shape decision + pricing strategy #328

@dep0we

Description

@dep0we

Parent

Part of meta-issue #325 (expand LLM provider catalog). Architectural decision to land in core is documented there.

What's different about OpenRouter

OpenRouter is OpenAI-compatible at the wire level (POST https://openrouter.ai/api/v1/chat/completions with OpenAI request/response shape) but strategically a meta-provider — one API key unlocks Anthropic + OpenAI + Gemini + Llama + many others via prefixed model strings:

anthropic/claude-opus-4-7
openai/gpt-5
google/gemini-2.0-flash-exp
meta-llama/llama-3.3-70b-instruct
deepseek/deepseek-r1
mistralai/mistral-large

This arc is OpenAI-shape in implementation (~150 LOC) but carries two load-bearing design questions that need plan-eng-review resolution before any code lands.

Design question 1: model.md representation

How does an operator declare an OpenRouter-routed model in model.md?

A. Single field (recommended). OpenRouter's native model-string convention preserved verbatim.

## Default model
**`anthropic/claude-opus-4-7`**

## Provider
**`openrouter`**

Routing target is implicit in the model string prefix. Aligns with OpenRouter's docs and operator's mental model when reading their dashboard. Tooling that wants the target provider can parse the prefix.

B. Split fields. Routing intent explicit; tooling-friendly.

## Default model
**`claude-opus-4-7`**

## Provider
**`openrouter`**

## Target provider
**`anthropic`**

More explicit; allows the framework to enforce target-provider allowlists separately from the OpenRouter gateway. But duplicates information already in the model string and creates "what if they disagree" edge cases.

C. Both supported. Single is primary; split is opt-in. Maximum operator flexibility; minimum spec discipline. Probably the wrong call.

Recommendation: A. Simpler. Aligns with OpenRouter's native convention. Defer routing-intent introspection to a parser helper (parse_openrouter_target(model: str) -> str | None) rather than model.md fields.

Plan-eng-review should resolve before impl.

Design question 2: Pricing strategy

OpenRouter routes to many upstream providers with varying prices. Three options:

A. Hardcode every routed upstream model's price in _costs.py.

"openrouter/anthropic/claude-opus-4-7":   {"input": 15.00, "output": 75.00},
"openrouter/openai/gpt-5":                {"input": 10.00, "output": 30.00},
"openrouter/google/gemini-2.0-flash-exp": {"input": 0.10, "output": 0.40},
# ... ~100 entries

Pros: accurate, deterministic, cost-guardrail discipline preserved.
Cons: high maintenance burden as OpenRouter's catalog grows; every upstream price change requires a _costs.py PR.

B. Defer to OpenRouter's billing-side accounting.

Emit cost events with cost_usd: None or cost_usd: "unknown". Operators rely on OpenRouter's dashboard for cost tracking.

Pros: zero maintenance.
Cons: violates CLAUDE.md taste rule 4 ("cost is first-class, not bolted on"); breaks cost-guardrail discipline; OpenRouter agents can't be subject to the same daily_cap_usd / monthly_cap_usd enforcement as native-provider agents.

C. Cache OpenRouter's /models endpoint at startup + per-session refresh.

OpenRouter's GET https://openrouter.ai/api/v1/models returns the full catalog with current pricing. Cache once per process, refresh on stale (e.g., every 24h). Falls back to a small hardcoded table for resilience.

Pros: accurate, low maintenance, resilient to OpenRouter catalog churn.
Cons: adds a startup HTTP call (or lazy fetch on first cost-gate check); requires an internet round-trip for cost data; offline operation degrades.

Recommendation: C with manual override hook. Operators can pin a price for a specific model via model.md cost_override: field for testing / offline scenarios. Plan-eng-review should resolve the cache-refresh policy (eager vs lazy) and the offline fallback shape.

Implementation (assuming recommendations resolve as above)

atomic_agents/llm/openrouter.py (new file, ~150 LOC)

"""OpenRouterLLMBackend factory over OpenAICompatibleLLMBackend.

OpenRouter is a meta-provider: one API key unlocks many upstream models
via prefixed model strings (anthropic/claude-opus-4-7, openai/gpt-5, etc.).
Wire shape is OpenAI-compatible; routing target is parsed from the model
string prefix.
"""

from typing import Optional
from .openai_compat import OpenAICompatibleLLMBackend, OpenAICompatibleConfig
from .pricing_cache import OpenRouterPricingCache  # new helper

OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_PROVIDER_NAME = "openrouter"


def make_openrouter_backend(
    api_key: str,
    pricing_cache: Optional[OpenRouterPricingCache] = None,
) -> OpenAICompatibleLLMBackend:
    """Construct an OpenRouter backend with meta-provider routing.

    The model_catalog is intentionally NOT enumerated — OpenRouter's catalog
    is large and changes frequently. The catalog argument is set to None,
    which the openai_compat layer interprets as "accept any model string."
    Routing target is derived from the model string prefix at call time.
    """
    return OpenAICompatibleLLMBackend(
        config=OpenAICompatibleConfig(
            base_url=OPENROUTER_BASE_URL,
            api_key=api_key,
            provider_name=OPENROUTER_PROVIDER_NAME,
            model_catalog=None,  # accept any prefix/model
            pricing_resolver=pricing_cache,  # NEW: per-backend pricing strategy
        )
    )


def parse_openrouter_target(model: str) -> Optional[str]:
    """Extract the upstream provider from an OpenRouter model string.

    >>> parse_openrouter_target("anthropic/claude-opus-4-7")
    "anthropic"
    >>> parse_openrouter_target("gpt-5")  # not OpenRouter-prefixed
    None
    """
    if "/" in model:
        return model.split("/", 1)[0]
    return None

OpenAICompatibleConfig may need extension

Add pricing_resolver: Optional[PricingResolver] = None field. If set, cost computation defers to the resolver; if None, falls back to _costs.py table. Keep backward-compatible default.

_get_openrouter_key() resolver

def _get_openrouter_key() -> str:
    return _get_key(
        env_vars=["ATOMIC_AGENTS_OPENROUTER_KEY", "OPENROUTER_API_KEY"],
        keychain_name="atomic-agents-openrouter",
        config_key="openrouter",
    )

Pricing cache module (atomic_agents/llm/pricing_cache.py, ~80 LOC)

Lazy-fetched OpenRouter pricing with TTL + offline fallback table.

Doctor integration

check_provider_keys extends to OpenRouter. Additionally, a new doctor check check_openrouter_pricing_cache reports cache state (PASS: cache populated, age < TTL; WARN: stale; FAIL: cache empty + no fallback).

Spec/31 amendment

New §"Meta-provider backends" subsection documenting the OpenRouter routing pattern, the parse_openrouter_target helper, the pricing-cache strategy, and the model.md single-field convention. This is genuinely new architectural territory the original LLMBackend Protocol didn't anticipate.

Conformance suite parametrization

OpenRouter is tricky because its model catalog is open-ended. Parametrize against a fixed set of "well-known" OpenRouter models for the test surface; document that conformance is per-route, not per-prefix.

Acceptance criteria

  • atomic_agents/llm/openrouter.py shipped with make_openrouter_backend factory + parse_openrouter_target helper.
  • atomic_agents/llm/pricing_cache.py shipped with cache + TTL + offline fallback.
  • Registration skips silently when no OpenRouter key resolves.
  • _get_openrouter_key() resolver in _llm.py.
  • OpenAICompatibleConfig extended with pricing_resolver field (backward-compatible).
  • Doctor check_provider_keys + new check_openrouter_pricing_cache ship.
  • Conformance suite parametrizes against fixed well-known OpenRouter model set.
  • Smoke test for parse_openrouter_target covering prefix / no-prefix / multi-slash edge cases.
  • spec/31 §"Meta-provider backends" amended.
  • CHANGELOG entry covers the meta-provider pattern + pricing-cache design.

Out of scope

  • Multi-route load balancing within OpenRouter (e.g., automatic failover from anthropic/claude-opus-4-7 to openai/gpt-5 if Anthropic is degraded). OpenRouter's models parameter supports this natively; expose via model.md fallback: field if requested.
  • OpenRouter's provider: request-level routing (force specific upstream). Possible v1.1.
  • Custom OpenRouter routing rules (e.g., region pinning).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendProtocol-pattern backend abstractions (memory, logs, locks, etc.)enhancementNew feature or requestspecImplementation of an Atomic Agents spec doc

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions