Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
192 lines
7.5 KiB
Python
192 lines
7.5 KiB
Python
"""Resolution policy: turning a provider name into a usable backend.
|
|
|
|
This module owns the *rules* -- which credential wins, when to reroute through
|
|
OpenRouter, what to do when nothing is configured. The registry owns the data
|
|
those rules operate on.
|
|
|
|
The precedence chain is deliberately expressed as one readable sequence in
|
|
:func:`resolve_backend`, because the order of its steps is the entire
|
|
behaviour: swapping two of them silently changes which endpoint a chapter
|
|
talks to.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
from .models import Backend, Provider
|
|
from .openrouter import (
|
|
OPENROUTER_DEFAULT_MODEL,
|
|
ZERO_COST_HINT,
|
|
map_model_to_openrouter,
|
|
openrouter_base_url,
|
|
openrouter_key,
|
|
)
|
|
from .registry import lookup
|
|
|
|
__all__ = ["resolve_backend"]
|
|
|
|
# Local runtimes ignore the key, but the OpenAI client rejects an empty one.
|
|
# Deliberately not a provider name: this is a credential value, and reusing a
|
|
# provider name here would make the two indistinguishable to callers that log
|
|
# or redact based on either.
|
|
_PLACEHOLDER_KEY = "not-needed"
|
|
|
|
# The universal fallback is one specific provider, not a category. Other
|
|
# aggregators may share its model-id format (see Provider.namespaces_models)
|
|
# but not its endpoint or its credentials.
|
|
_OPENROUTER = "openrouter"
|
|
|
|
|
|
def build_openrouter_backend(
|
|
model: str,
|
|
api_key: str,
|
|
provider: str = "openrouter",
|
|
) -> Backend:
|
|
"""Build a backend that routes through OpenRouter.
|
|
|
|
Shared by :func:`resolve_backend` and the legacy shim in
|
|
:mod:`agentbook.providers.legacy` so the two cannot drift apart.
|
|
|
|
Args:
|
|
model: The requested model id; mapped to its OpenRouter equivalent.
|
|
api_key: The OpenRouter credential to use. Must already be resolved --
|
|
this function does not fall back to the environment. Empty values
|
|
become a placeholder, since the OpenAI client rejects an empty key.
|
|
provider: The provider that was originally requested. Recorded on the
|
|
backend so callers can report what the user asked for.
|
|
|
|
Returns:
|
|
A backend pointing at OpenRouter with ``using_openrouter`` set.
|
|
"""
|
|
return Backend(
|
|
api_key=api_key or _PLACEHOLDER_KEY,
|
|
base_url=openrouter_base_url(),
|
|
# The caller asked for this model and is being rerouted for credential
|
|
# reasons alone, so an unmapped id is sent as-is and rejected by name.
|
|
# Substituting here would answer as a different vendor's model without
|
|
# the reader ever learning theirs was unavailable.
|
|
model=map_model_to_openrouter(
|
|
(model or "").strip() or os.getenv("OPENROUTER_MODEL", "").strip() or OPENROUTER_DEFAULT_MODEL,
|
|
substitute_unknown=not (model or "").strip(),
|
|
),
|
|
provider=provider,
|
|
using_openrouter=True,
|
|
)
|
|
|
|
|
|
def _needs_openrouter_for_gpt5(spec: Provider, model: str) -> bool:
|
|
"""Report whether a gpt-5 request must be rerouted through OpenRouter.
|
|
|
|
The direct OpenAI API requires organisation verification for gpt-5.x, which
|
|
most readers will not have. Routing via OpenRouter avoids that -- except
|
|
when the reader explicitly selected the ``openai`` provider, in which case
|
|
honouring their choice matters more.
|
|
|
|
Args:
|
|
spec: The provider that was requested.
|
|
model: The resolved model id.
|
|
|
|
Returns:
|
|
``True`` if the request should be rerouted.
|
|
"""
|
|
return model.lower().startswith("gpt-5") and spec.name != "openai"
|
|
|
|
|
|
def _missing_key_error(spec: Provider) -> ValueError:
|
|
"""Build the error raised when no credential can be found.
|
|
|
|
Args:
|
|
spec: The provider that could not be configured.
|
|
|
|
Returns:
|
|
A ``ValueError`` naming the variables that would fix the problem and
|
|
pointing at the zero-cost options.
|
|
"""
|
|
wanted = " / ".join(spec.key_vars) or "(none)"
|
|
return ValueError(
|
|
f"No API key found for provider {spec.name!r}. Set {wanted}, "
|
|
"or OPENROUTER_API_KEY as a universal fallback. " + ZERO_COST_HINT
|
|
)
|
|
|
|
|
|
def resolve_backend(
|
|
provider: str,
|
|
model: str | None = None,
|
|
api_key: str | None = None,
|
|
) -> Backend:
|
|
"""Resolve a provider name into a usable backend.
|
|
|
|
Resolution order:
|
|
|
|
1. ``gpt-5*`` ids route through OpenRouter when a key is available, because
|
|
the direct OpenAI API requires org verification for them.
|
|
2. If the provider's own key is set (or the provider needs none, e.g.
|
|
Ollama), use the provider directly.
|
|
3. Otherwise fall back to OpenRouter, mapping the model id.
|
|
4. Otherwise raise, naming the variables that would fix it.
|
|
|
|
Args:
|
|
provider: Provider name or alias, e.g. ``"kimi"`` or ``"moonshot"``.
|
|
model: Model id overriding the provider's default.
|
|
api_key: Credential overriding the environment. For the ``openrouter``
|
|
provider this is treated as an OpenRouter key; for any other
|
|
provider it belongs to that provider and is never forwarded to
|
|
OpenRouter.
|
|
|
|
Returns:
|
|
A ready-to-use :class:`~agentbook.providers.models.Backend`.
|
|
|
|
Raises:
|
|
ValueError: If the provider is unknown, or if it requires a key and
|
|
neither its own variables nor ``OPENROUTER_API_KEY`` are set.
|
|
"""
|
|
spec = lookup(provider)
|
|
model_clean = (model or "").strip()
|
|
if model_clean:
|
|
resolved_model = model_clean
|
|
elif spec.name == _OPENROUTER:
|
|
# The OpenRouter default honours OPENROUTER_MODEL — the env var this
|
|
# package documents (see the module docstring / ZERO_COST_HINT) as the
|
|
# ':free' zero-cost selector. Without this, the documented free recipe
|
|
# silently resolves the paid OPENROUTER_DEFAULT_MODEL instead.
|
|
resolved_model = os.getenv("OPENROUTER_MODEL", "").strip() or spec.default_model
|
|
else:
|
|
resolved_model = spec.default_model
|
|
key = (api_key or "").strip() or spec.api_key()
|
|
|
|
# Only OpenRouter's own credential can authenticate against OpenRouter. An
|
|
# explicit key given for the openrouter provider is such a credential and
|
|
# wins over the environment; any other provider's key -- including another
|
|
# aggregator's -- belongs to that provider and is never forwarded here.
|
|
explicit_openrouter_key = key if spec.name == _OPENROUTER else ""
|
|
available_openrouter_key = explicit_openrouter_key or openrouter_key()
|
|
|
|
# 1. gpt-5.x needs OpenAI org verification on the direct API.
|
|
if available_openrouter_key and _needs_openrouter_for_gpt5(spec, resolved_model):
|
|
return build_openrouter_backend(resolved_model, available_openrouter_key, spec.name)
|
|
|
|
# 2. The provider's own credential, or a provider that needs none.
|
|
if key or not spec.requires_key:
|
|
return Backend(
|
|
api_key=key or _PLACEHOLDER_KEY,
|
|
base_url=spec.resolved_base_url(),
|
|
# An aggregator resells many vendors' models and so expects
|
|
# namespaced ids: a bare override like "gpt-4o" is mapped even when
|
|
# talking to the aggregator directly. An id with no mapping cannot
|
|
# be requested here at all, so a working default beats a certain
|
|
# failure -- unlike the reroute path above.
|
|
model=map_model_to_openrouter(resolved_model, substitute_unknown=True)
|
|
if spec.namespaces_models
|
|
else resolved_model,
|
|
provider=spec.name,
|
|
using_openrouter=spec.name == _OPENROUTER,
|
|
)
|
|
|
|
# 3. Universal fallback.
|
|
if available_openrouter_key:
|
|
return build_openrouter_backend(resolved_model, available_openrouter_key, spec.name)
|
|
|
|
# 4. Nothing is configured.
|
|
raise _missing_key_error(spec)
|