simbiot-harness/venv/lib/python3.12/site-packages/strands_harness/models.py
Simbiot Harness b60439152b Simbiot Harness v0.1: тактовая лестница на Strands + CQRS/ES-спина
- 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 первым)
2026-09-29 15:28:50 +00:00

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