ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
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

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
"""Shared packaging and plumbing for the ai-agent-book companion experiments.
This package exists so the repo can declare its dependencies once (see the root
``pyproject.toml``) instead of repeating them across per-project
``requirements.txt`` files.
Install what a chapter needs::
pip install -e ".[ch1]" # chapter 1, no GPU stack
pip install -e ".[ch7]" # heavy fine-tuning deps, opt in explicitly
Scope: this package holds *plumbing only* -- provider resolution, environment
loading, trace printing. The teaching code stays inside each chapter's
experiment directory, where a reader can follow it top to bottom.
"""
__version__ = "0.1.0"
__all__ = ["__version__"]
+68
View File
@@ -0,0 +1,68 @@
"""Single source of truth for LLM provider resolution.
Every chapter experiment talks to an OpenAI-compatible endpoint. What differs
per provider is only the base URL, the default model id, and which environment
variable holds the key -- so all of that lives here instead of being repeated
in each experiment.
Typical use::
from agentbook.providers import resolve_backend
backend = resolve_backend("kimi")
client = OpenAI(api_key=backend.api_key, base_url=backend.base_url)
...
client.chat.completions.create(model=backend.model, ...)
Adding a provider is one entry in :data:`PROVIDERS`, in
:mod:`~agentbook.providers.registry`.
Free / zero-cost options:
* ``ollama`` -- runs models on your own machine, no API key, no cost.
* ``openrouter`` with a ``:free`` model id, e.g.::
OPENROUTER_API_KEY=your-openrouter-api-key
OPENROUTER_MODEL=google/gemma-4-31b-it:free
The model runs on OpenRouter's servers, so a modest laptop is fine.
Package layout, in dependency order -- each module imports only from those
above it:
* :mod:`~agentbook.providers.models` -- the ``Provider`` and ``Backend`` types
* :mod:`~agentbook.providers.openrouter` -- OpenRouter constants and model mapping
* :mod:`~agentbook.providers.registry` -- the provider table and name lookup
* :mod:`~agentbook.providers.resolution` -- the precedence rules
* :mod:`~agentbook.providers.legacy` -- the pre-registry compatibility shim
This module re-exports the full public surface, so importing from
``agentbook.providers`` directly is the supported way to use the package.
"""
from __future__ import annotations
from .legacy import resolve_llm_backend
from .models import Backend, Provider
from .openrouter import (
OPENROUTER_BASE_URL,
OPENROUTER_DEFAULT_MODEL,
is_openrouter_key,
map_model_to_openrouter,
)
from .registry import PROVIDERS, SUPPORTED_PROVIDERS, canonical_provider
from .resolution import resolve_backend
__all__ = [
"OPENROUTER_BASE_URL",
"OPENROUTER_DEFAULT_MODEL",
"PROVIDERS",
"SUPPORTED_PROVIDERS",
"Backend",
"Provider",
"canonical_provider",
"is_openrouter_key",
"map_model_to_openrouter",
"resolve_backend",
"resolve_llm_backend",
]
+75
View File
@@ -0,0 +1,75 @@
"""Backwards-compatible shim for the pre-registry chapter helper.
Before the shared registry existed, three chapter experiments each carried
their own copy of ``resolve_llm_backend``. It is still imported by three
chapter modules and called by two of them, so it stays until all of them are
migrated:
* ``chapter1/web-search-agent/agent.py`` -- calls it
* ``chapter1/learning-from-experience/llm_agent.py`` -- calls it
* ``chapter1/context/config.py`` -- re-exports it for its own importers
Deleting this function therefore breaks ``chapter1/context`` at import time
even though that module never calls it.
It cannot simply delegate to :func:`~agentbook.providers.resolution.resolve_backend`:
callers pass a bare ``base_url`` with no provider name, which the registry has
no way to express. What it *can* share is the OpenRouter construction, so the
two code paths cannot drift apart on the part that matters.
"""
from __future__ import annotations
from .openrouter import ZERO_COST_HINT, openrouter_key
from .resolution import build_openrouter_backend
__all__ = ["resolve_llm_backend"]
_NO_KEY_MESSAGE = (
"No API key found. Set a provider key (DASHSCOPE_API_KEY / SILICONFLOW_API_KEY / ARK_API_KEY / "
"MOONSHOT_API_KEY / DEEPSEEK_API_KEY / ZHIPU_API_KEY / OPENAI_API_KEY / "
"GEMINI_API_KEY) or OPENROUTER_API_KEY (universal fallback). " + ZERO_COST_HINT
)
def resolve_llm_backend(
primary_key: str | None,
primary_base_url: str,
model: str,
) -> tuple[str, str, str, bool]:
"""Resolve a backend from a loose key/URL pair, as the old helper did.
Prefer :func:`~agentbook.providers.resolution.resolve_backend`, which knows
the provider registry and therefore reports far better errors. This exists
for call sites that only have a base URL and no provider name.
Args:
primary_key: The caller's own API key. Falsy values trigger the
OpenRouter fallback.
primary_base_url: Endpoint matching ``primary_key``.
model: Requested model id. Mapped to an OpenRouter id when the request
is rerouted, and passed through untouched otherwise.
Returns:
A plain ``(api_key, base_url, model, using_openrouter)`` tuple. Callers
compare this against tuple literals, so it deliberately stays a tuple
rather than becoming a :class:`~agentbook.providers.models.Backend`.
Raises:
ValueError: If neither ``primary_key`` nor ``OPENROUTER_API_KEY`` is
set.
"""
fallback_key = openrouter_key()
# gpt-5.x needs OpenAI org verification on the direct API; prefer OpenRouter
# even when the caller supplied their own key.
if fallback_key and str(model or "").lower().startswith("gpt-5"):
return tuple(build_openrouter_backend(model, fallback_key))
if primary_key:
return primary_key, primary_base_url, model, False
if fallback_key:
return tuple(build_openrouter_backend(model, fallback_key))
raise ValueError(_NO_KEY_MESSAGE)
+104
View File
@@ -0,0 +1,104 @@
"""Dataclasses describing providers and resolved backends.
This module is the leaf of the package's dependency graph: it defines the two
value types the rest of the package builds on, and imports nothing from its
siblings.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
__all__ = ["Backend", "Provider"]
@dataclass(frozen=True)
class Provider:
"""Static description of an OpenAI-compatible backend.
Attributes:
name: Canonical provider name, e.g. ``"kimi"``.
base_url: Default API endpoint, used when no override is set.
default_model: Model id used when the caller does not pick one.
key_vars: Environment variables holding the API key, tried in order.
The first non-empty one wins; later entries exist for backwards
compatibility.
base_url_var: Environment variable overriding ``base_url``, for
self-hosted or regional deployments. ``None`` if not overridable.
requires_key: Whether a missing key is an error. Local runtimes such as
Ollama accept any placeholder, so they set this to ``False``.
namespaces_models: Whether this backend expects vendor-namespaced model
ids such as ``openai/gpt-4o`` rather than bare ones. True for
aggregators that resell many vendors' models; a bare id given to
one of these is mapped before the request goes out.
This describes *model-id formatting only*. It says nothing about
which endpoint to call or whose credentials are valid -- an
aggregator sharing OpenRouter's id format still has its own
``base_url`` and its own key, and is never routed through
OpenRouter on that basis.
"""
name: str
base_url: str
default_model: str
key_vars: tuple[str, ...] = ()
base_url_var: str | None = None
requires_key: bool = True
namespaces_models: bool = False
def api_key(self) -> str:
"""Read this provider's API key from the environment.
Returns:
The first non-empty value among ``key_vars``, stripped of
surrounding whitespace, or ``""`` when none is set.
"""
for var in self.key_vars:
value = os.getenv(var, "").strip()
if value:
return value
return ""
def resolved_base_url(self) -> str:
"""Return the endpoint to call, honouring any environment override.
Returns:
The value of ``base_url_var`` if that variable is set and non-empty,
otherwise the built-in ``base_url``.
"""
if self.base_url_var:
return os.getenv(self.base_url_var, "").strip() or self.base_url
return self.base_url
@dataclass(frozen=True)
class Backend:
"""A resolved, ready-to-use OpenAI-compatible endpoint.
Attributes:
api_key: Credential for ``base_url``. Never empty -- local runtimes get
a placeholder, because the OpenAI client rejects an empty key.
base_url: The endpoint to send requests to.
model: Model id valid at ``base_url``. Note this may differ from the
requested id when the request was rerouted through OpenRouter.
provider: The provider that was requested, after alias resolution.
using_openrouter: Whether the request is going through OpenRouter
rather than the provider's own API.
"""
api_key: str
base_url: str
model: str
provider: str
using_openrouter: bool
def __iter__(self):
"""Unpack as the 4-tuple the pre-registry chapter helpers returned.
Returns:
An iterator over ``api_key``, ``base_url``, ``model`` and
``using_openrouter``, in that order.
"""
return iter((self.api_key, self.base_url, self.model, self.using_openrouter))
+132
View File
@@ -0,0 +1,132 @@
"""OpenRouter endpoint constants and model-id mapping.
OpenRouter is the universal fallback: it speaks the OpenAI protocol and hosts
models from many vendors, so any chapter can run against it with a single key.
The catch is that it namespaces model ids (``openai/gpt-4o`` rather than
``gpt-4o``), which is what :func:`map_model_to_openrouter` translates.
Everything OpenRouter-specific lives here, so a change to its ids or endpoint
touches exactly one module.
"""
from __future__ import annotations
import os
__all__ = [
"OPENROUTER_BASE_URL",
"OPENROUTER_DEFAULT_MODEL",
"ZERO_COST_HINT",
"is_openrouter_key",
"map_model_to_openrouter",
"openrouter_base_url",
"openrouter_key",
]
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_DEFAULT_MODEL = "openai/gpt-5.6-luna"
# Appended to every "no key configured" error so the way out of the problem is
# stated once rather than copied into each message.
ZERO_COST_HINT = (
"For a zero-cost setup use provider 'ollama' (local, no key) or "
"OPENROUTER_MODEL with a ':free' model id."
)
def openrouter_key() -> str:
"""Read the OpenRouter API key from the environment.
Returns:
The value of ``OPENROUTER_API_KEY``, stripped, or ``""`` when unset.
"""
return os.getenv("OPENROUTER_API_KEY", "").strip()
def is_openrouter_key(api_key: str) -> bool:
"""Report whether a credential looks like an OpenRouter key.
OpenRouter issues keys under the ``sk-or-`` prefix, so a key the reader
pasted can usually be attributed without asking them which service it came
from. This is a naming convention rather than a guarantee, which bounds
where the answer may be used.
Intended for callers that accept a key of unknown origin -- a CLI taking
``--api-key``, say -- and must pick which provider to resolve. It is
deliberately *not* used by :func:`~agentbook.providers.resolve_backend`,
whose ``api_key`` argument means "this provider's credential"; inferring
routing from the value there would silently override the caller and send a
provider's key to the wrong host when a prefix collides.
Args:
api_key: A credential of unknown origin. ``None`` and ``""`` are
tolerated and report ``False``.
Returns:
``True`` if the key carries OpenRouter's prefix.
"""
return (api_key or "").strip().startswith("sk-or-")
def openrouter_base_url() -> str:
"""Return the OpenRouter endpoint, honouring an environment override.
Returns:
The value of ``OPENROUTER_BASE_URL`` if set and non-empty, otherwise
the default public endpoint.
"""
return os.getenv("OPENROUTER_BASE_URL", "").strip() or OPENROUTER_BASE_URL
def map_model_to_openrouter(model: str, *, substitute_unknown: bool = False) -> str:
"""Map a bare model id to the equivalent OpenRouter model id.
Mapping rules, applied in order:
* ids already containing ``/`` are returned unchanged (already OpenRouter form)
* ``gpt-*`` / ``o1-*`` / ``o3-*`` / ``o4-*`` become ``openai/<id>``
* ``claude-*`` becomes the matching Anthropic id
* ``kimi-*`` becomes ``moonshotai/kimi-k2.6`` (kimi-k3 is not hosted)
* ``deepseek-*`` becomes ``deepseek/<id>``
* ``qwen-*`` / ``qwen2*`` / ``qwen3*`` becomes ``qwen/<id>``
What to do with an unmapped id -- a native one such as ``doubao-*`` or
``glm-*``, which OpenRouter does not reliably host -- depends on why the
caller is mapping, so it is the caller's decision rather than a fixed rule
here. Talking to an aggregator that *requires* a namespaced id, a working
default beats a request that cannot succeed. Rerouting a request the reader
already aimed at a named model, silently answering as a different vendor's
model is worse than failing.
Args:
model: A bare or already-namespaced model id. ``None`` and ``""`` are
tolerated.
substitute_unknown: When ``True``, an unmapped id becomes
``OPENROUTER_MODEL`` or the package default. When ``False`` it is
returned unchanged, to be rejected by OpenRouter under the name the
reader actually asked for.
Returns:
An OpenRouter model id, or the unchanged input for an unmapped id when
``substitute_unknown`` is ``False``.
"""
m = (model or "").strip()
if "/" in m:
return m
ml = m.lower()
if ml.startswith(("gpt-", "o1-", "o3-", "o4-")):
return "openai/" + m
if ml.startswith("claude-"):
if "sonnet" in ml:
return "anthropic/claude-sonnet-4.6"
if "haiku" in ml:
return "anthropic/claude-haiku-4.5"
return "anthropic/claude-opus-4.8"
if ml.startswith("kimi"):
return "moonshotai/kimi-k2.6"
if ml.startswith("deepseek"):
return "deepseek/" + m
if ml.startswith("qwen"):
return "qwen/" + m
if substitute_unknown:
return os.getenv("OPENROUTER_MODEL", "").strip() or OPENROUTER_DEFAULT_MODEL
return m
+174
View File
@@ -0,0 +1,174 @@
"""The provider registry: which backends exist and what they are called.
This module is pure data plus lookup. Adding a provider means adding one entry
to :data:`PROVIDERS` and nothing else -- chapter CLIs build their
``--provider`` choices from :data:`SUPPORTED_PROVIDERS`, so a new entry becomes
selectable without touching any argparse code.
Resolution *policy* -- which provider wins, when to fall back -- lives in
:mod:`agentbook.providers.resolution`, not here.
"""
from __future__ import annotations
from .models import Provider
from .openrouter import OPENROUTER_BASE_URL, OPENROUTER_DEFAULT_MODEL
__all__ = [
"PROVIDERS",
"SUPPORTED_PROVIDERS",
"canonical_provider",
"lookup",
"supported_providers",
]
PROVIDERS: dict[str, Provider] = {
"dashscope": Provider(
name="dashscope",
# Alibaba Cloud Model Studio (Bailian) keys are region-bound. Default
# to the mainland endpoint for this Chinese-first project; readers
# using an international-region key can set DASHSCOPE_BASE_URL to the
# Singapore endpoint documented in the experiment README.
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
default_model="qwen3.7-plus",
key_vars=("DASHSCOPE_API_KEY",),
base_url_var="DASHSCOPE_BASE_URL",
),
"siliconflow": Provider(
name="siliconflow",
base_url="https://api.siliconflow.cn/v1",
default_model="Qwen/Qwen3.5-397B-A17B",
key_vars=("SILICONFLOW_API_KEY",),
),
"doubao": Provider(
name="doubao",
base_url="https://ark.cn-beijing.volces.com/api/v3",
default_model="doubao-seed-1-6-thinking-250715",
key_vars=("ARK_API_KEY",),
),
"kimi": Provider(
name="kimi",
base_url="https://api.moonshot.cn/v1",
default_model="kimi-k3",
# KIMI_API_KEY kept for backwards compatibility.
key_vars=("MOONSHOT_API_KEY", "KIMI_API_KEY"),
base_url_var="KIMI_BASE_URL",
),
"deepseek": Provider(
name="deepseek",
base_url="https://api.deepseek.com",
# V4 Flash is OpenAI-compatible with tool calling + thinking mode.
# Legacy deepseek-chat / deepseek-reasoner aliases deprecated 2026-07-24.
default_model="deepseek-v4-flash",
key_vars=("DEEPSEEK_API_KEY",),
base_url_var="DEEPSEEK_BASE_URL",
),
"zhipu": Provider(
name="zhipu",
base_url="https://open.bigmodel.cn/api/paas/v4",
default_model="glm-5.2",
key_vars=("ZHIPU_API_KEY",),
),
"openrouter": Provider(
name="openrouter",
base_url=OPENROUTER_BASE_URL,
default_model=OPENROUTER_DEFAULT_MODEL,
key_vars=("OPENROUTER_API_KEY",),
base_url_var="OPENROUTER_BASE_URL",
# Resells many vendors' models, so ids must be namespaced.
namespaces_models=True,
),
"openai": Provider(
name="openai",
base_url="https://api.openai.com/v1",
default_model="gpt-4o",
key_vars=("OPENAI_API_KEY",),
base_url_var="OPENAI_BASE_URL",
),
"gemini": Provider(
name="gemini",
# Google exposes an OpenAI-compatible endpoint; the free tier is
# generous enough for most chapter experiments.
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
default_model="gemini-2.5-flash",
key_vars=("GEMINI_API_KEY", "GOOGLE_API_KEY"),
),
"ollama": Provider(
name="ollama",
base_url="http://localhost:11434/v1",
default_model="qwen3:8b",
# Ollama ignores the key but the OpenAI client requires a non-empty one.
key_vars=("OLLAMA_API_KEY",),
base_url_var="OLLAMA_BASE_URL",
requires_key=False,
),
}
# Provider names used interchangeably in the chapters, mapped to canonical ones.
_ALIASES = {
"moonshot": "kimi",
"ark": "doubao",
"google": "gemini",
# "Qwen" is the model family and "Bailian" is the product name; both
# select Alibaba's DashScope-compatible endpoint rather than SiliconFlow.
"qwen": "dashscope",
"bailian": "dashscope",
}
# Every accepted name, canonical plus aliases. Chapter CLIs use this for their
# --provider choices so a new registry entry is immediately selectable instead
# of being rejected by argparse.
#
# Computed once at import: PROVIDERS is a module-level table edited in source,
# not registered at runtime. Anything mutating PROVIDERS after import (tests
# do, to exercise hypothetical providers) must read supported_providers()
# instead, which recomputes.
SUPPORTED_PROVIDERS: tuple[str, ...] = tuple(sorted(set(PROVIDERS) | set(_ALIASES)))
def supported_providers() -> tuple[str, ...]:
"""Return every accepted provider name, canonical plus aliases.
Prefer the :data:`SUPPORTED_PROVIDERS` constant unless
:data:`PROVIDERS` may have been modified since import.
Returns:
Sorted provider names and aliases, recomputed from the live table.
"""
return tuple(sorted(set(PROVIDERS) | set(_ALIASES)))
def canonical_provider(provider: str) -> str:
"""Normalise a provider name, resolving aliases.
Args:
provider: A provider name or alias, e.g. ``"moonshot"`` or ``"Kimi"``.
Case and surrounding whitespace are ignored. ``None`` is tolerated.
Returns:
The canonical name, e.g. ``"kimi"``. Names that are not known aliases
are returned lowercased but otherwise unchanged, so callers can still
look them up and get a sensible error for genuinely unknown providers.
"""
key = (provider or "").strip().lower()
return _ALIASES.get(key, key)
def lookup(provider: str) -> Provider:
"""Find the :class:`~agentbook.providers.models.Provider` for a name.
Args:
provider: A provider name or alias.
Returns:
The registered provider specification.
Raises:
ValueError: If the name matches no registry entry or alias. The message
lists the supported names.
"""
key = canonical_provider(provider)
if key not in PROVIDERS:
supported = ", ".join(sorted(PROVIDERS))
raise ValueError(f"Unsupported provider: {provider!r}. Supported: {supported}")
return PROVIDERS[key]
+191
View File
@@ -0,0 +1,191 @@
"""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)