- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
565 lines
26 KiB
Python
565 lines
26 KiB
Python
"""Model resolution from ``provider/name`` strings, with per-provider reasoning config.
|
|
|
|
Consumers pass a ready ``Model`` instance, a ``"provider/name"`` string, a bare Bedrock model
|
|
id, or ``None`` for the harness default. Every provider uses its real model ids directly. The
|
|
``effort`` level is mapped to each provider's own request fields here, so the caller sets one
|
|
value regardless of provider.
|
|
|
|
Prompt caching is requested via ``caching`` and reaches the provider one of two ways: the harness
|
|
configures Bedrock and Anthropic direct (cache points plus cached tool definitions), while OpenAI,
|
|
Gemini, bedrock-mantle, and litellm cache automatically server-side (Gemini on models 2.5 and
|
|
newer; litellm through its OpenAI-compatible backend). Only a pre-built ``Model`` or ``ModelRouter``
|
|
instance can't be honored (its provider is unknown), so a warning is logged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from collections.abc import Callable
|
|
from typing import Any, NamedTuple
|
|
|
|
from strands.models import Model, ModelRouter
|
|
|
|
from strands_harness import defaults
|
|
from strands_harness.types.agent import Effort
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ANTHROPIC_MAX_TOKENS = 32_000
|
|
# Claude calls the search directly (not from code execution), so results come back as citations
|
|
# and the tool works on every Claude model, not only those with programmatic tool calling.
|
|
_ANTHROPIC_WEB_SEARCH = {"type": "web_search_20260318", "name": "web_search", "allowed_callers": ["direct"]}
|
|
|
|
# Claude's real max_tokens ceiling by tier, verified live against Bedrock Converse. Applied on
|
|
# Bedrock and Anthropic-direct only — other Bedrock-hosted model families aren't known to need
|
|
# this. Matched as a substring so any Bedrock region/vendor prefix in front of it doesn't matter.
|
|
_CLAUDE_MAX_TOKENS = {
|
|
"claude-opus-": 128_000,
|
|
"claude-sonnet-": 128_000,
|
|
"claude-haiku-": 64_000,
|
|
"claude-fable-": 128_000,
|
|
}
|
|
_CLAUDE_MAX_TOKENS_BY_VERSION = (
|
|
("claude-opus-4-5", 64_000),
|
|
("claude-opus-4.5", 64_000),
|
|
("claude-sonnet-4-5", 64_000),
|
|
("claude-sonnet-4.5", 64_000),
|
|
)
|
|
|
|
|
|
def _claude_max_tokens(model_id: str) -> int | None:
|
|
pinned = next((tokens for needle, tokens in _CLAUDE_MAX_TOKENS_BY_VERSION if needle in model_id), None)
|
|
if pinned is not None:
|
|
return pinned
|
|
return next((tokens for needle, tokens in _CLAUDE_MAX_TOKENS.items() if needle in model_id), None)
|
|
|
|
|
|
# Small, fast model per provider for the web_fetch summarizer. Keyed by the main agent's provider
|
|
# so the summarizer shares its credentials. Kept byte-identical with ``_WEB_FETCH_MODELS`` in the
|
|
# TypeScript ``models.ts``.
|
|
_WEB_FETCH_MODELS = {
|
|
"bedrock": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
|
"bedrock-mantle": "openai.gpt-5.6-luna",
|
|
"anthropic": "claude-haiku-4-5-20251001",
|
|
"openai": "gpt-5.6-luna",
|
|
"google": "gemini-3.5-flash",
|
|
}
|
|
|
|
# Cross-region inference profile prefixes stripped from a Bedrock model id before matching its
|
|
# provider family. Kept byte-identical with ``models.ts``.
|
|
# Providers whose endpoint can be repointed by an env var. A non-default endpoint publishes its
|
|
# own model list, so the vended small summarizer is not guaranteed to exist on it.
|
|
_CUSTOM_ENDPOINT_VARS = {"anthropic": "ANTHROPIC_BASE_URL", "openai": "OPENAI_BASE_URL"}
|
|
|
|
_BEDROCK_REGION_PREFIXES = ("global.", "apac.", "us.", "eu.", "au.", "jp.")
|
|
|
|
|
|
# Reasoning levels each provider's API accepts. The harness validates against the resolved
|
|
# provider's set so an unsupported level fails here rather than as a request error.
|
|
_ANTHROPIC_LEVELS = ("low", "medium", "high", "xhigh", "max")
|
|
_OPENAI_LEVELS = ("minimal", "low", "medium", "high", "xhigh", "none")
|
|
_BEDROCK_GPT_LEVELS = ("none", "low", "medium", "high", "xhigh", "max")
|
|
_BEDROCK_GPT_OSS_LEVELS = ("low", "medium", "high")
|
|
_BEDROCK_QWEN_LEVELS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
_BEDROCK_XAI_LEVELS = ("low", "medium", "high", "xhigh")
|
|
_GOOGLE_LEVELS = ("minimal", "low", "medium", "high")
|
|
|
|
_ADAPTIVE_THINKING_SINCE = {"opus": (4, 6), "sonnet": (4, 6)}
|
|
_EXTENDED_THINKING_SINCE = {"opus": (4, 5), "sonnet": (4, 5), "haiku": (4, 5)}
|
|
_CLAUDE_ID = re.compile(
|
|
r"claude-(?:(\d{1,2})(?:[-.](\d{1,2}))?-)?(opus|sonnet|haiku|fable|mythos)"
|
|
r"(?:[-.](\d{1,2}))?(?:[-.](\d{1,2}))?(?!\d)"
|
|
)
|
|
_EXTENDED_THINKING_BUDGETS = {"low": 2_048, "medium": 8_192, "high": 16_384, "xhigh": 32_768, "max": 49_152}
|
|
|
|
|
|
def _claude_thinking_mode(model_id: str) -> str | None:
|
|
match = _CLAUDE_ID.search(model_id)
|
|
if match is None:
|
|
return "adaptive"
|
|
lead_major, lead_minor, family, major, minor = match.groups()
|
|
if major is None and lead_major is None:
|
|
takes_adaptive = family not in _EXTENDED_THINKING_SINCE or family in _ADAPTIVE_THINKING_SINCE
|
|
return "adaptive" if takes_adaptive else "extended"
|
|
version = (int(major), int(minor or 0)) if major is not None else (int(lead_major), int(lead_minor or 0))
|
|
if family not in _EXTENDED_THINKING_SINCE:
|
|
return "adaptive"
|
|
if version >= _ADAPTIVE_THINKING_SINCE.get(family, (99, 99)):
|
|
return "adaptive"
|
|
return "extended" if version >= _EXTENDED_THINKING_SINCE[family] else None
|
|
|
|
|
|
def _claude_thinking(effort: str) -> dict:
|
|
return {
|
|
"thinking": {"type": "adaptive", "display": "summarized"},
|
|
"output_config": {"effort": effort},
|
|
}
|
|
|
|
|
|
def _claude_extended_thinking(effort: str, max_tokens: int) -> dict:
|
|
return {"thinking": {"type": "enabled", "budget_tokens": min(_EXTENDED_THINKING_BUDGETS[effort], max_tokens - 1)}}
|
|
|
|
|
|
def _claude_thinking_block(model_id: str, effort: str, max_tokens: int) -> dict:
|
|
if _claude_thinking_mode(model_id) == "extended":
|
|
return _claude_extended_thinking(effort, max_tokens)
|
|
return _claude_thinking(effort)
|
|
|
|
|
|
def _bedrock_family(model_id: str) -> str:
|
|
prefix = next((prefix for prefix in _BEDROCK_REGION_PREFIXES if model_id.startswith(prefix)), "")
|
|
return model_id[len(prefix) :]
|
|
|
|
|
|
def _bedrock_levels(model_id: str) -> tuple[str, ...]:
|
|
family = _bedrock_family(model_id)
|
|
if family.startswith("anthropic."):
|
|
return _ANTHROPIC_LEVELS if _claude_thinking_mode(family) is not None else ()
|
|
if family.startswith("openai.gpt-5.6-") or family == "openai.gpt-6-astra":
|
|
return _BEDROCK_GPT_LEVELS
|
|
if family.startswith("openai.gpt-oss-"):
|
|
return _BEDROCK_GPT_OSS_LEVELS
|
|
if family.startswith("qwen."):
|
|
return _BEDROCK_QWEN_LEVELS
|
|
if family.startswith("xai."):
|
|
return _BEDROCK_XAI_LEVELS
|
|
return ()
|
|
|
|
|
|
def _bedrock_effort(model_id: str, effort: Effort) -> str | None:
|
|
levels = _bedrock_levels(model_id)
|
|
if levels:
|
|
return _effort(effort, "high", levels, f"Bedrock model {model_id}")
|
|
_check_effort(effort)
|
|
if effort in ("auto", "off"):
|
|
return None
|
|
raise ValueError(f"Effort {effort!r} is not supported by Bedrock model {model_id}. Pass 'auto' or 'off'.")
|
|
|
|
|
|
def _bedrock_thinking(model_id: str, effort: str | None) -> dict | None:
|
|
if effort is None:
|
|
return None
|
|
family = _bedrock_family(model_id)
|
|
if family.startswith("anthropic."):
|
|
return _claude_thinking_block(family, effort, _claude_max_tokens(model_id) or _ANTHROPIC_MAX_TOKENS)
|
|
if family.startswith("openai.gpt-5.6-") or family == "openai.gpt-6-astra":
|
|
return {"reasoning": {"effort": effort}}
|
|
if family.startswith("openai.gpt-oss-"):
|
|
return {"reasoning_effort": effort}
|
|
if family.startswith("qwen."):
|
|
return {"reasoning_effort": effort}
|
|
if family.startswith("xai."):
|
|
return {"reasoning_effort": effort}
|
|
return None
|
|
|
|
|
|
def _bedrock(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
from strands.models import BedrockModel, CacheConfig
|
|
|
|
thinking = _bedrock_thinking(model_id, effort)
|
|
extra: dict[str, Any] = {} if thinking is None else {"additional_request_fields": thinking}
|
|
max_tokens = _claude_max_tokens(model_id)
|
|
if max_tokens is not None:
|
|
extra["max_tokens"] = max_tokens
|
|
if caching:
|
|
# The SDK injects cache points for Anthropic model ids on Bedrock (a no-op for others).
|
|
# Tools are stable across turns, so cache them too.
|
|
extra["cache_config"] = CacheConfig(strategy="auto", tools_ttl=True)
|
|
return BedrockModel(model_id=model_id, **extra)
|
|
|
|
|
|
def _anthropic(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
from strands.models import CacheConfig
|
|
from strands.models.anthropic import AnthropicModel
|
|
|
|
max_tokens = _claude_max_tokens(model_id) or _ANTHROPIC_MAX_TOKENS
|
|
extra: dict[str, Any] = {} if effort is None else {"params": _claude_thinking_block(model_id, effort, max_tokens)}
|
|
if web_search:
|
|
extra["anthropic_tools"] = [_ANTHROPIC_WEB_SEARCH]
|
|
if caching:
|
|
# Caches the tool definitions plus a cache point on the last user message; the system
|
|
# prompt is cached automatically. Mirrors the Bedrock builder.
|
|
extra["cache_config"] = CacheConfig(strategy="auto", tools_ttl=True)
|
|
return AnthropicModel(model_id=model_id, max_tokens=max_tokens, **extra)
|
|
|
|
|
|
def _openai(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
# ``caching`` is unused: OpenAI caches server-side with no opt-in.
|
|
from strands.models.openai_responses import OpenAIResponsesModel
|
|
|
|
params: dict[str, Any] = {}
|
|
if effort is not None:
|
|
params["reasoning"] = {"effort": effort}
|
|
# The Responses API merges built-in tools carried in ``params`` with the agent's function tools.
|
|
if web_search:
|
|
params["tools"] = [{"type": "web_search"}]
|
|
return OpenAIResponsesModel(model_id=model_id, **({"params": params} if params else {}))
|
|
|
|
|
|
def _bedrock_mantle(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
# ``caching`` is unused: Mantle caches server-side automatically.
|
|
from strands.models.openai_responses import BedrockMantleConfig, OpenAIResponsesModel
|
|
|
|
params: dict[str, Any] = {} if effort is None else {"reasoning": {"effort": effort}}
|
|
if web_search:
|
|
params["tools"] = [{"type": "web_search", "external_web_access": True}]
|
|
return OpenAIResponsesModel(
|
|
model_id=model_id, bedrock_mantle_config=BedrockMantleConfig(), **({"params": params} if params else {})
|
|
)
|
|
|
|
|
|
def _gemini(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
# ``caching`` is unused: Gemini caches implicitly server-side (models 2.5 and newer).
|
|
from google.genai import types as genai_types
|
|
from strands.models.gemini import GeminiModel
|
|
|
|
extra: dict[str, Any] = {}
|
|
if effort is not None:
|
|
extra["params"] = {"thinking_config": {"thinking_level": effort}}
|
|
# ``gemini_tools`` is appended alongside function declarations, so search coexists with tools.
|
|
if web_search:
|
|
extra["gemini_tools"] = [genai_types.Tool(google_search=genai_types.GoogleSearch())]
|
|
return GeminiModel(model_id=model_id, **extra)
|
|
|
|
|
|
def _ollama(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
from strands.models.ollama import OllamaModel
|
|
|
|
# OLLAMA_API_KEY mirrors the TS builder, which sends it as a bearer token (for proxied hosts).
|
|
api_key = os.environ.get("OLLAMA_API_KEY")
|
|
client_args = {"headers": {"Authorization": f"Bearer {api_key}"}} if api_key else None
|
|
return OllamaModel(
|
|
host=os.environ.get("OLLAMA_HOST", "http://127.0.0.1:11434"),
|
|
ollama_client_args=client_args,
|
|
model_id=model_id,
|
|
)
|
|
|
|
|
|
def _litellm(model_id: str, effort: str | None, web_search: bool, caching: bool) -> Model:
|
|
# ``caching`` is unused: LiteLLM caches server-side via its OpenAI-compatible backend.
|
|
from strands.models.litellm import LiteLLMModel
|
|
|
|
api_key = os.environ.get("LITELLM_API_KEY")
|
|
api_base = os.environ.get("LITELLM_BASE_URL")
|
|
client_args = {
|
|
**({"api_key": api_key} if api_key else {}),
|
|
**({"api_base": api_base} if api_base else {}),
|
|
}
|
|
return LiteLLMModel(client_args=client_args, model_id=model_id)
|
|
|
|
|
|
class Provider(NamedTuple):
|
|
"""How one model provider is built and what reasoning/search/caching it supports.
|
|
|
|
``web_search`` is enabled through model config on the providers whose SDK exposes a
|
|
non-clobbering seam for it (OpenAI Responses ``params.tools`` for OpenAI and bedrock-mantle,
|
|
Gemini ``gemini_tools``); ``_has_web_search`` narrows bedrock-mantle to its GPT-5/GPT-6 models.
|
|
Bedrock Converse has no mechanism, and the Anthropic-direct model spreads ``params`` last so a
|
|
``tools`` key would overwrite the function tools; both stay ``False`` until the SDK grows a
|
|
safe seam.
|
|
|
|
``caching`` marks providers where prompt caching is in effect when requested, whether or not
|
|
the harness configures anything: Bedrock and Anthropic direct (the harness sets cache points and tool caching)
|
|
plus OpenAI, Gemini, bedrock-mantle, and litellm (automatic server-side). Only a pre-built
|
|
``Model`` instance can't be honored (its provider is unknown), so the factory warns there.
|
|
"""
|
|
|
|
build: Callable[[str, str | None, bool, bool], Model]
|
|
recommended_thinking: str | None
|
|
thinking_levels: tuple[str, ...]
|
|
web_search: bool
|
|
caching: bool
|
|
|
|
|
|
_PROVIDERS = {
|
|
"bedrock": Provider(_bedrock, "high", _ANTHROPIC_LEVELS, web_search=False, caching=True),
|
|
"bedrock-mantle": Provider(_bedrock_mantle, "high", _OPENAI_LEVELS, web_search=True, caching=True),
|
|
"anthropic": Provider(_anthropic, "high", _ANTHROPIC_LEVELS, web_search=True, caching=True),
|
|
"openai": Provider(_openai, "high", _OPENAI_LEVELS, web_search=True, caching=True),
|
|
"google": Provider(_gemini, "high", _GOOGLE_LEVELS, web_search=True, caching=True),
|
|
"ollama": Provider(_ollama, None, (), web_search=False, caching=False),
|
|
"litellm": Provider(_litellm, None, (), web_search=False, caching=True),
|
|
}
|
|
|
|
|
|
EFFORT_LEVELS: tuple[Effort, ...] = ("auto", "off", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
|
|
|
|
def _check_effort(effort: object) -> None:
|
|
if effort in EFFORT_LEVELS:
|
|
return
|
|
raise ValueError(f"Unknown effort {effort!r}. Choose one of: {', '.join(EFFORT_LEVELS)}.")
|
|
|
|
|
|
def _effort(
|
|
effort: Effort,
|
|
recommended: str | None,
|
|
levels: tuple[str, ...],
|
|
subject: str = "this provider",
|
|
) -> str | None:
|
|
"""Map ``effort`` onto the provider's ``levels``; ``"off"`` is ``None`` where the provider has no ``none``."""
|
|
_check_effort(effort)
|
|
if effort == "off":
|
|
return "none" if "none" in levels else None
|
|
if effort == "auto":
|
|
return recommended
|
|
if effort in levels:
|
|
return effort
|
|
supported = [level for level in levels if level != "none"]
|
|
detail = (
|
|
f"Supported levels: {', '.join(supported)} (or 'auto', 'off')."
|
|
if supported
|
|
else "It supports no reasoning levels; pass 'auto' or 'off'."
|
|
)
|
|
raise ValueError(f"Effort {effort!r} is not supported by {subject}. {detail}")
|
|
|
|
|
|
def _split_provider(model: str) -> tuple[str, str]:
|
|
"""Split a ``"provider/name"`` string (or a bare Bedrock id) into ``(provider, name)``."""
|
|
provider_name, sep, name = model.partition("/")
|
|
if not sep:
|
|
return "bedrock", model
|
|
return provider_name, name
|
|
|
|
|
|
def _require_or_warn(explicit: bool, error: str, warning: str) -> None:
|
|
"""Raise ``error`` when a feature was requested explicitly but can't be honored; otherwise
|
|
(the feature is on only by default) log ``warning`` and let the caller build without it."""
|
|
if explicit:
|
|
raise ValueError(error)
|
|
logger.warning(warning)
|
|
|
|
|
|
def _has_web_search(provider_name: str, name: str) -> bool:
|
|
provider = _PROVIDERS.get(provider_name)
|
|
if provider is None or not provider.web_search:
|
|
return False
|
|
# Bedrock Web Search is only served for Mantle's GPT-5/GPT-6 models; other families reject the tool (HTTP 400).
|
|
return provider_name != "bedrock-mantle" or name.startswith(("openai.gpt-5.", "openai.gpt-6-"))
|
|
|
|
|
|
def supports_web_search(model: Model | ModelRouter | str | None) -> bool:
|
|
"""Whether native web search can be enabled for ``model``.
|
|
|
|
A ``Model`` or ``ModelRouter`` instance has an unknown provider, so this is ``False``; the
|
|
caller configures search on the instance directly. ``None`` resolves to the default model.
|
|
"""
|
|
if isinstance(model, (Model, ModelRouter)):
|
|
return False
|
|
return _has_web_search(*_split_provider(model or defaults.DEFAULT_MODEL))
|
|
|
|
|
|
def supports_thinking(model: Model | str | None) -> bool:
|
|
if isinstance(model, Model):
|
|
return True
|
|
provider_name, name = _split_provider(model or defaults.DEFAULT_MODEL)
|
|
if provider_name == "bedrock":
|
|
return bool(_bedrock_levels(name))
|
|
if provider_name == "anthropic":
|
|
return _claude_thinking_mode(name) is not None
|
|
provider = _PROVIDERS.get(provider_name)
|
|
return provider is not None and bool(provider.thinking_levels)
|
|
|
|
|
|
def _supports_media(model: Model | ModelRouter | str | None) -> bool:
|
|
"""Whether ``model`` accepts image and document blocks in a tool result.
|
|
|
|
OpenAI-family models on Bedrock Converse reject both outright ("This model doesn't support the
|
|
image field for user messages"), which fails the whole turn rather than degrading, so ``read``
|
|
describes those files in text instead.
|
|
|
|
A ``ModelRouter`` may switch models mid-conversation, so every candidate has to accept media for
|
|
the tool to keep emitting it: one rejecting candidate is enough to fail a turn. A ``BedrockModel``
|
|
instance is resolved through its configured ``model_id``; any other instance has an unknown
|
|
provider and is assumed capable. Pass ``builtin_tools={"read": {"media": False}}`` to override.
|
|
"""
|
|
from strands.models import BedrockModel
|
|
|
|
if isinstance(model, ModelRouter):
|
|
return all(_supports_media(candidate.model) for candidate in model.candidates)
|
|
if isinstance(model, BedrockModel):
|
|
model_id = (model.get_config() or {}).get("model_id")
|
|
return _supports_media(model_id) if isinstance(model_id, str) else True
|
|
if isinstance(model, Model):
|
|
return True
|
|
provider_name, name = _split_provider(model or defaults.DEFAULT_MODEL)
|
|
if provider_name == "bedrock":
|
|
return not _bedrock_family(name).startswith("openai.")
|
|
return True
|
|
|
|
|
|
def resolve_model(
|
|
model: Model | ModelRouter | str | None,
|
|
default: str,
|
|
effort: Effort = "auto",
|
|
web_search: bool = False,
|
|
caching: bool = False,
|
|
caching_explicit: bool = False,
|
|
) -> Model | ModelRouter:
|
|
"""Resolve the ``model`` argument into a model or router instance.
|
|
|
|
A passed-in ``Model`` or ``ModelRouter`` instance is used verbatim: its provider is unknown, so
|
|
``effort`` (when not ``"auto"``) and ``caching`` are ignored with a logged warning and never
|
|
raise. For a ``provider/name`` string, ``effort`` is validated against the provider (and Bedrock
|
|
model family); ``caching`` on a provider without it raises when explicit (``caching_explicit``)
|
|
and otherwise warns and builds without it. ``web_search`` turns on the model's native search and
|
|
is only passed for models that have one (``supports_web_search``).
|
|
"""
|
|
_check_effort(effort)
|
|
if isinstance(model, (Model, ModelRouter)):
|
|
instance = "a pre-built Model instance or ModelRouter"
|
|
if effort != "auto":
|
|
logger.warning("effort=%r not applied to %s; configure reasoning on the instance", effort, instance)
|
|
if caching:
|
|
logger.warning("prompt caching not applied to %s; configure it on the instance", instance)
|
|
return model
|
|
if model is None:
|
|
model = default
|
|
|
|
provider_name, name = _split_provider(model)
|
|
|
|
provider = _PROVIDERS.get(provider_name)
|
|
if provider is None:
|
|
supported = ", ".join(sorted(_PROVIDERS))
|
|
raise ValueError(
|
|
f"Unknown model provider {provider_name!r} in {model!r}. Supported providers: {supported}. "
|
|
"Pass a strands.models.Model instance for anything else."
|
|
)
|
|
|
|
if caching and not provider.caching:
|
|
supported = ", ".join(p for p, cfg in _PROVIDERS.items() if cfg.caching)
|
|
_require_or_warn(
|
|
caching_explicit,
|
|
f"Provider {provider_name!r} does not support prompt caching. "
|
|
f"Providers with caching: {supported}. Pass caching=False, "
|
|
"or switch to a supported provider.",
|
|
f"provider=<{provider_name}> | prompt caching not supported by this provider; continuing without it",
|
|
)
|
|
unsupported_caching = provider_name == "bedrock" and _bedrock_family(name).startswith("anthropic.claude-3-haiku-")
|
|
if caching and unsupported_caching:
|
|
_require_or_warn(
|
|
caching_explicit,
|
|
f"Model {provider_name}/{name} does not support prompt caching. Pass caching=False.",
|
|
f"model=<{provider_name}/{name}> | prompt caching not supported by this model; continuing without it",
|
|
)
|
|
levels = provider.thinking_levels
|
|
recommended = provider.recommended_thinking
|
|
subject = "this provider"
|
|
if provider_name == "anthropic" and _claude_thinking_mode(name) is None:
|
|
levels, recommended, subject = (), None, f"model {name}"
|
|
level = (
|
|
_bedrock_effort(name, effort) if provider_name == "bedrock" else _effort(effort, recommended, levels, subject)
|
|
)
|
|
native_search = web_search and _has_web_search(provider_name, name)
|
|
return provider.build(name, level, native_search, caching and provider.caching and not unsupported_caching)
|
|
|
|
|
|
def _bedrock_web_fetch_model(name: str) -> str | None:
|
|
"""Small Bedrock summarizer id for a Bedrock main model ``name``, or ``None`` if the family is
|
|
unidentifiable.
|
|
|
|
Bedrock hosts models from several providers, so the summarizer follows the main model's family.
|
|
An Anthropic-on-Bedrock model (an ``anthropic.`` prefix, after any cross-region prefix like
|
|
``us.``) gets Anthropic Haiku; an OpenAI-on-Bedrock model (an ``openai.`` prefix) gets the OpenAI
|
|
small model hosted on Bedrock (``openai.`` + the OpenAI-provider summarizer), carrying the main
|
|
model's cross-region prefix because those ids are only served through an inference profile.
|
|
Either way the summarizer shares the main model's provider. Any other family is unidentifiable
|
|
and returns ``None`` so the caller can reuse the main model rather than guess.
|
|
"""
|
|
family = _bedrock_family(name)
|
|
if family.startswith("anthropic."):
|
|
return _WEB_FETCH_MODELS["bedrock"]
|
|
if family.startswith("openai."):
|
|
prefix = name[: len(name) - len(family)]
|
|
return f"{prefix}openai.{_WEB_FETCH_MODELS['openai']}"
|
|
return None
|
|
|
|
|
|
def resolve_web_fetch_model(
|
|
main_model: Model | ModelRouter | str | None,
|
|
web_fetch_model: Model | ModelRouter | str | None,
|
|
) -> Model:
|
|
"""Resolve the model the web_fetch summarizer runs on.
|
|
|
|
An explicit ``web_fetch_model`` (a ``Model`` or ``ModelRouter`` instance, or ``"provider/name"``
|
|
string) wins. With none set, pick the small fast model for the main agent's provider so the
|
|
summarizer shares its credentials; when the main model is a ``Model`` instance (provider unknown),
|
|
reuse it as the summarizer. A router uses its concrete default model because auxiliary calls are
|
|
outside the primary agent invocation and cannot share its routing decision. On Bedrock the
|
|
summarizer follows the main model's family (Anthropic-on-Bedrock gets Haiku, OpenAI-on-Bedrock gets
|
|
the OpenAI small model); a Bedrock family we can't identify reuses the main model and logs a warning
|
|
rather than guessing. Thinking is never applied: summarizing a page is a fast task.
|
|
|
|
``caching`` is deliberately not forwarded: the single message carries the per-call prompt before
|
|
the page body, so every fetch would write a cache entry no later call can read.
|
|
"""
|
|
if isinstance(web_fetch_model, ModelRouter):
|
|
return web_fetch_model.default_model
|
|
if isinstance(web_fetch_model, Model):
|
|
return web_fetch_model
|
|
if web_fetch_model is not None:
|
|
return _concrete_model(resolve_model(web_fetch_model, web_fetch_model, effort="off"))
|
|
|
|
if isinstance(main_model, ModelRouter):
|
|
return main_model.default_model
|
|
if isinstance(main_model, Model):
|
|
return main_model
|
|
main = main_model if main_model is not None else defaults.DEFAULT_MODEL
|
|
provider_name, name = _split_provider(main)
|
|
if provider_name == "bedrock":
|
|
small = _bedrock_web_fetch_model(name)
|
|
if small is None:
|
|
logger.warning(
|
|
f"model=<{main}> | could not identify the Bedrock model family for the web_fetch "
|
|
"summarizer; reusing the main model. Set builtin_tools={'web_fetch': {'model': ...}} to choose a "
|
|
"smaller one."
|
|
)
|
|
return _concrete_model(resolve_model(main, main, effort="off"))
|
|
elif (base_url_var := _CUSTOM_ENDPOINT_VARS.get(provider_name)) and os.environ.get(base_url_var):
|
|
logger.warning(
|
|
f"model=<{main}> | {base_url_var} points provider <{provider_name}> at a non-default "
|
|
"endpoint, which serves its own model list, so the vended summarizer may not exist "
|
|
"there; reusing the main model. Pass web_fetch_model to choose a smaller one."
|
|
)
|
|
return _concrete_model(resolve_model(main, main, effort="off"))
|
|
else:
|
|
small = _WEB_FETCH_MODELS.get(provider_name)
|
|
if small is None:
|
|
if provider_name in ("ollama", "litellm"):
|
|
logger.warning(
|
|
f"model=<{main}> | no separate web_fetch summarizer is configured for provider "
|
|
f"<{provider_name}>; reusing the main model. Set builtin_tools={{'web_fetch': {{'model': ...}}}} "
|
|
"to choose another model."
|
|
)
|
|
return _concrete_model(resolve_model(main, main, effort="off"))
|
|
raise ValueError(
|
|
f"No default web_fetch model for provider {provider_name!r}. "
|
|
"Set builtin_tools={'web_fetch': {'model': ...}} explicitly, or disable web_fetch via builtin_tools."
|
|
)
|
|
return _concrete_model(resolve_model(f"{provider_name}/{small}", small, effort="off"))
|
|
|
|
|
|
def _concrete_model(model: Model | ModelRouter) -> Model:
|
|
return model.default_model if isinstance(model, ModelRouter) else model
|