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
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:
@@ -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__"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user