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
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
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/completionswith OpenAI request/response shape) but strategically a meta-provider — one API key unlocks Anthropic + OpenAI + Gemini + Llama + many others via prefixed model strings: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.mdrepresentationHow does an operator declare an OpenRouter-routed model in
model.md?A. Single field (recommended). OpenRouter's native model-string convention preserved verbatim.
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.
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 thanmodel.mdfields.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.Pros: accurate, deterministic, cost-guardrail discipline preserved.
Cons: high maintenance burden as OpenRouter's catalog grows; every upstream price change requires a
_costs.pyPR.B. Defer to OpenRouter's billing-side accounting.
Emit cost events with
cost_usd: Noneorcost_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_usdenforcement as native-provider agents.C. Cache OpenRouter's
/modelsendpoint at startup + per-session refresh.OpenRouter's
GET https://openrouter.ai/api/v1/modelsreturns 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.mdcost_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)OpenAICompatibleConfigmay need extensionAdd
pricing_resolver: Optional[PricingResolver] = Nonefield. If set, cost computation defers to the resolver; if None, falls back to_costs.pytable. Keep backward-compatible default._get_openrouter_key()resolverPricing cache module (
atomic_agents/llm/pricing_cache.py, ~80 LOC)Lazy-fetched OpenRouter pricing with TTL + offline fallback table.
Doctor integration
check_provider_keysextends to OpenRouter. Additionally, a new doctor checkcheck_openrouter_pricing_cachereports 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_targethelper, the pricing-cache strategy, and themodel.mdsingle-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.pyshipped withmake_openrouter_backendfactory +parse_openrouter_targethelper.atomic_agents/llm/pricing_cache.pyshipped with cache + TTL + offline fallback._get_openrouter_key()resolver in_llm.py.OpenAICompatibleConfigextended withpricing_resolverfield (backward-compatible).check_provider_keys+ newcheck_openrouter_pricing_cacheship.parse_openrouter_targetcovering prefix / no-prefix / multi-slash edge cases.Out of scope
anthropic/claude-opus-4-7toopenai/gpt-5if Anthropic is degraded). OpenRouter'smodelsparameter supports this natively; expose viamodel.mdfallback:field if requested.provider:request-level routing (force specific upstream). Possible v1.1.References
atomic_agents/llm/openai_compat.py(wire-level).