Files
AgentLab/backend/app/providers/factory.py
T
ThearchyHelios 0d9f1d9339 用户看得到的列表补决胜键(复核 R-a9):按名称、时间等可能并列的排序后面补上同一个模型的 id,并列时两种库排出来一样
- 实测 /api/evals/calibers、/api/schedules/trigger-options 按 Workflow.name 排序遇同名并列时 SQLite、PostgreSQL 顺序不同。用 AST 逐个过了一遍 order_by:最后一个排序键是 created_at / updated_at / last_active_at / at / name / 各种 *_at 之类、不唯一的列,又不带 DISTINCT、GROUP BY 的,补「, 模型.id」(降序补 id.desc()),共 104 处,分布在 api、apps、auth、blueprints、chat、data、engine、eval、ledger、look、memory、process、providers、scheduler(runs.py 里的三处在上一个提交里)
- 按唯一列(seq、version、唯一的 name)排的不动;按聚合、coalesce 之类表达式排的说明见 db/postgres
2026-10-19 12:07:06 +08:00

489 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
import os
from typing import Any
from langchain_core.language_models.chat_models import BaseChatModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.crypto import decrypt
from app.db.models import Provider
from app.providers import catalog
from app.providers.mock_model import MockChatModel
logger = logging.getLogger(__name__)
class ModelSpec(BaseModel):
"""节点上对模型的请求。省略的字段会回退到 provider 默认值。"""
provider: str | None = None # provider 的 name 或 id
model: str | None = None
temperature: float | None = None
max_tokens: int | None = Field(default=None)
thinking: str | None = None # off | adaptive
effort: str | None = None # low | medium | high | xhigh | max
timeout: int | None = None
# SDK 自己遇到 429、5xx、连接错误、超时时重试几次;None 照 SDK 默认(openai、anthropic 都是 2 次)。
# 自己按失败原因重试的调用方(数据目录起草)设 0,免得两层重试叠在一起、实际次数和报错写的对不上
max_retries: int | None = None
extra: dict[str, Any] = Field(default_factory=dict)
class ProviderNotConfigured(RuntimeError):
"""模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句,
运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。
code 给调用方按类别分支:missing(点名的接入不存在)/ disabled / no_model / missing_key / missing_base_url。
name、model_owner 只有 named_provider 填:点名的名字(去掉两边空白)恰好是某个启用接入的模型时,model_owner 是
那个接入的名称。str(e) 末尾补上 model_note 那一句;画布、设置页、助手各自另拼,说清在哪一栏改(同 hint)。"""
def __init__(self, reason: str, hint: str = "", code: str = "", *, name: str = "", model_owner: str = "") -> None:
note = model_note(name, model_owner) if model_owner else ""
super().__init__("。".join(part for part in (reason, hint, note) if part))
self.reason = reason
self.hint = hint
self.code = code
self.name = name
self.model_owner = model_owner
async def list_providers(session: AsyncSession) -> list[Provider]:
rows = await session.execute(select(Provider).order_by(Provider.created_at, Provider.id))
return list(rows.scalars())
#: 点名的接入不存在、已停用时,在「设置 → 模型接入」里能做什么。NAMED_FIX 是整句的前半句;后半句(去哪里换一个
#: 接入)看场合:运行时报错、设置页写「或换一个接入」;画布校验、发布前检查按这一处在界面上能不能改另写
#:(engine/provider_refs.py);助手栏那一格下面先说「在上方重新选择」,再说去设置里做什么(assistant_unavailable)
NAMED_ACTION = {"missing": "核对名称", "disabled": "启用它"}
NAMED_FIX = {code: f"请在「设置 → 模型接入」中{act}" for code, act in NAMED_ACTION.items()}
def model_fact(name: str, owner: str) -> str:
"""点名的接入名恰好是某个启用接入的模型:先照实说这是哪个接入的模型,怎么用它各处另说(model_note……)。"""
return f"「{name}」是接入「{owner}」的模型"
def model_note(name: str, owner: str, model_field: str = "模型一栏", provider_field: str = "接入",
how: str = "选择它") -> str:
"""点名的接入名恰好是某个启用接入的模型时补的那一句:多半是把模型名填进了接入那一栏。开发数据里真有一条停用的
接入,名字就是一个模型 id,同名模型由另一条启用的接入提供——照「启用它」的提示去做,启用的是那条旧接入(它的
模型清单里根本没有这个模型),用户要的是这个模型。model_field、provider_field 是这一处模型、接入两栏的叫法,
how 是在模型那一栏里怎么做:设置页、核查节点的模型是输入框,写「填写」;运行时报错不分节点类型,多数节点的模型
是下拉框,写「模型一栏」「接入」「选择它」。画布、发布前检查按这一处在检查器里有哪几栏另写(provider_refs)。"""
return (f"{model_fact(name, owner)}:如需使用该模型,请在{model_field}中{how},"
f"{provider_field}留空或选择「{owner}」")
def _model_owner(providers: list[Provider], name: str) -> Provider | None:
"""提供这个模型的启用接入,认法同 provider_for_model(先认模型列表、再认默认模型)。只看启用的:停用接入的模型
补了这一句,照着做也一样调不了。"""
enabled = [p for p in providers if p.enabled]
return next((p for p in enabled if any((m or {}).get("id") == name for m in (p.models or []))), None) \
or next((p for p in enabled if p.default_model == name), None)
def named_provider(providers: list[Provider], ref: Any) -> Provider:
"""按名称或 id 找显式点名的接入(providers 是 list_providers 的结果)。找不到、找到的已停用都抛
ProviderNotConfigured:点名了就只用它,不换别家。运行时取模型、设置和画布校验都按这一处认,说法一致。
名称两边的空白不算;不是字符串的(接口写进来的数字一类)当作点了一个不存在的名字。点名的名字恰好是某个启用接入
的模型时,异常带上 model_owner,报错补一句这是哪个接入的模型、怎么用它(model_note)。"""
key = ref.strip() if isinstance(ref, str) else str(ref)
found = next((p for p in providers if key in (p.id, p.name)), None) \
or next((p for p in providers if (p.name or "").strip() == key), None)
if found is not None and found.enabled:
return found
owner = _model_owner(providers, key)
trap = {"name": key, "model_owner": owner.name} if owner is not None else {}
if found is None:
raise ProviderNotConfigured(f"模型接入「{key}」不存在", f"{NAMED_FIX['missing']},或换一个接入", code="missing",
**trap)
raise ProviderNotConfigured(f"模型接入「{found.name}」已停用", f"{NAMED_FIX['disabled']},或换一个接入",
code="disabled", **trap)
def serves(provider: Any, model: str) -> bool:
"""这个接入提供不提供这个模型:模型列表里有,或者是它的默认模型(按模型名找接入的同一个认法)。"""
return any((m or {}).get("id") == model for m in (provider.models or [])) or provider.default_model == model
def provider_for_model(providers: list[Provider], model: str) -> Provider | None:
"""没点名接入时按模型名找提供它的接入(providers 是 list_providers 的结果):先认模型列表、再认默认模型,只看启用的。
只有停用的接入提供它时抛 ProviderNotConfigured(disabled)——这时候不能默默换一家:拿着 A 家的模型名去调 B 家,
必然 404,而且报错完全指不到真正的原因。认不出来的模型名返回 None:用户可能手填了一个目录里还没有的新模型,由
调用方退回 fallback_provider。运行时取模型(resolve_provider)和画布校验、发布前检查(engine/provider_refs.py)
按这一处认,说法一致。"""
enabled = [p for p in providers if p.enabled]
found = next((p for p in enabled if any((m or {}).get("id") == model for m in (p.models or []))), None) \
or next((p for p in enabled if p.default_model == model), None)
if found is not None:
return found
off = next((p for p in providers if not p.enabled and serves(p, model)), None)
if off is not None:
raise ProviderNotConfigured(f"模型「{model}」所属的模型接入「{off.name}」已停用",
"请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled")
return None
def fallback_provider(providers: list[Provider]) -> Provider | None:
"""没点名接入、也没写模型(或模型名认不出来)时用哪个:第一个启用的真实接入,没有就第一个启用的演示接入;一个启用
的都没有返回 None(get_chat_model 换成内置演示模型)。"""
enabled = [p for p in providers if p.enabled]
real = [p for p in enabled if p.kind != "mock"]
return (real or enabled or [None])[0]
#: 助手的模型在界面上只有一处能改:「编排」页助手栏的「助手使用的模型」(设置页里没有这一项)。选「跟随默认」或一个
#: 模型都会清掉存着的接入。画布、问数据页、表格导入、数据目录起草报的是同一句,所以写明是哪一页
ASSISTANT_MODEL_FIELD = "助手使用的模型"
ASSISTANT_PICKER = f"「编排」页助手栏的「{ASSISTANT_MODEL_FIELD}」中"
ASSISTANT_REPICK = f"或在{ASSISTANT_PICKER}重新选择"
def assistant_unavailable(e: BaseException, *, here: bool = False) -> str:
"""助手(画布上改图、表格导入与数据目录的 AI 起草)用的模型取不到时给人看的那句话。
点名的接入不存在、已停用(按模型名找到的接入已停用也算)照实说是哪个接入、在设置里能做什么,或者到助手栏重新
选一个模型;以前这几种要么悄悄换成演示模型,要么各处自己判停用。点名的名字恰好是启用接入的模型时并成一句:先说
在设置里能做什么,再说这是哪个接入的模型、也可以在助手栏直接选它(不再另起一句把「助手使用的模型」说两遍)。
其余(缺 key、没有模型……)是接入本身没配完。
here:说给助手栏「助手使用的模型」那一格下面(GET /api/copilot/model 的 panel_error)。人就在这个下拉框旁边,
去向写「上方」,不再把人指回「编排」页助手栏。"""
from app.core.errors import not_configured
code = getattr(e, "code", "")
if code in NAMED_FIX:
said = f"助手使用的{getattr(e, 'reason', '') or e}。"
if owner := getattr(e, "model_owner", ""):
where = "上方" if here else ASSISTANT_PICKER
return f"{said}{NAMED_FIX[code]};{model_fact(getattr(e, 'name', ''), owner)},也可在{where}直接选择它"
if here:
return f"{said}请在上方重新选择,或在「设置 → 模型接入」中{NAMED_ACTION[code]}"
return f"{said}{NAMED_FIX[code]},{ASSISTANT_REPICK}"
return (f"助手使用的模型尚未配置完成:{not_configured(e, with_hint=False)}。请到「设置 → 模型接入」检查"
+ (",或在上方重新选择" if here else ""))
async def resolve_provider(
session: AsyncSession, ref: str | None, model: str | None = None
) -> Provider | None:
"""找出该用哪个 provider。
顺序很关键:显式指定的 ref 最优先;其次按模型名反查 —— 节点上通常只选了
模型(比如 mock-fast),没选 provider,这时必须找到真正提供该模型的那一个,
否则会拿着 A 家的模型名去调 B 家的接口。最后才回退到默认 provider。
显式给了 ref 却不存在或已停用,抛 ProviderNotConfigured(named_provider)。以前不存在时返回 None,
get_chat_model 把它当成「一个接入都没配」,悄悄换成演示模型:名字写错一个字、接入改了名或删了,
报告就由演示模型写成假文本,核查也由它来「核」,运行照样成功。停用的照常返回、照常调用,
又和下面「按模型名找到的接入已停用就报错」对不上。
返回 None 只剩一种情况:没给 ref(只有空白的也算没给),而一个启用的接入都没有。不是字符串的 ref(接口写进
设置里的数字一类)按点名了一个不存在的接入报,不当成没给。
"""
providers = await list_providers(session)
if ref is not None and (not isinstance(ref, str) or ref.strip()):
return named_provider(providers, ref)
# 模型认得出来、所属的接入却停用了:provider_for_model 照实报错,不默默换一家。认不出来的模型名放行
if model and (found := provider_for_model(providers, model)) is not None:
return found
return fallback_provider(providers)
def _resolve_key(provider: Provider) -> str | None:
"""明文 key。没配就退回环境变量 —— 本地实验时最省事。"""
key = decrypt(provider.api_key)
if key:
return key
env_names = {
"anthropic": ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"),
"openai": ("OPENAI_API_KEY",),
}.get(provider.kind, ())
for name in env_names:
if os.environ.get(name):
return os.environ[name]
return None
def build_chat_model(provider: Provider, spec: ModelSpec) -> BaseChatModel:
"""把一条 provider 记录 + 节点配置变成可调用的 LangChain 模型。
这里最要紧的是按模型能力裁剪参数:Claude 4.6 之后的模型收到 temperature
会直接 400,所以不能无脑透传节点上的滑块值。
"""
model = spec.model or provider.default_model or ""
if not model:
raise ProviderNotConfigured(f"模型接入「{provider.name}」没有可用的模型",
"请在「设置 → 模型接入」中添加可选模型,或设置默认模型", code="no_model")
kwargs: dict[str, Any] = {"model": model}
if spec.max_tokens:
kwargs["max_tokens"] = spec.max_tokens
# 以前只有节点显式配了才传,而没有任何地方给 spec.timeout 赋过值——所有调用
# 都走 SDK 默认的 10 分钟
kwargs["timeout"] = spec.timeout or settings.model_timeout_seconds
if spec.max_retries is not None:
kwargs["max_retries"] = spec.max_retries
# 采样参数:能力允许才传
if spec.temperature is not None and catalog.supports_sampling(model):
kwargs["temperature"] = spec.temperature
if provider.kind == "mock":
return MockChatModel(model_name=model)
api_key = _resolve_key(provider)
base_url = provider.base_url or None
extra = dict(provider.extra or {})
if provider.kind == "anthropic":
from langchain_anthropic import ChatAnthropic
base_url = base_url or os.environ.get("ANTHROPIC_BASE_URL") or None
if not api_key:
raise ProviderNotConfigured(f"模型接入「{provider.name}」缺少 API Key",
"请在「设置 → 模型接入」中填写", code="missing_key")
bearer = extra.get("auth_style") == "bearer"
kwargs["api_key"] = "placeholder-replaced-below" if bearer else api_key
if base_url:
kwargs["base_url"] = base_url
if extra.get("headers"):
kwargs["default_headers"] = dict(extra["headers"])
# thinking 这类模型会先花 token 思考再输出正文;给太小的 max_tokens
# 会出现"思考完就没额度写答案",返回一条空正文。
kwargs.setdefault("max_tokens", 8192)
# 交错思考 —— 工具结果回来之后模型还能再想一段,而不是一轮开头想完
# 就只剩埋头执行。串行跑工具时这一条最要紧:每一步之间本来就该重新
# 判断一次"看到这个结果之后下一步该干嘛"。
#
# 两代模型的开法完全不同,这也是为什么这里要分叉:
# 4.6 起 adaptive thinking **自带**交错,不需要 beta header。
# (Opus 4.6 上手动模式压根没有交错,只有 adaptive 有,
# 所以这一档绝不能退回手动。)
# 4 / 4.5 手动开 thinking + 挂 interleaved-thinking beta header。
# 目录里没列这一代,但模型名是放行的,用户可以手填。
model_kwargs: dict[str, Any] = {}
thinking_on = False
mode = spec.thinking or "summarized"
if catalog.supports_adaptive_thinking(model):
if mode in ("adaptive", "summarized"):
# summarized 会把思考摘要一起返回,画布上就能展示"它在想什么"
kwargs["thinking"] = {
"type": "adaptive",
"display": "summarized" if mode == "summarized" else "omitted",
}
thinking_on = True
elif mode == "off":
kwargs["thinking"] = {"type": "disabled"}
elif mode != "off" and catalog.needs_interleaved_beta(model):
# 光挂 header 是不够的:这一代默认根本没开 thinking,
# 没有思考可交错,header 就是一条什么都不做的空设置
kwargs["thinking"] = {
"type": "enabled",
"budget_tokens": max(1024, int(kwargs["max_tokens"]) // 2),
}
thinking_on = True
# betas 会把调用改道 client.beta.messages。自建网关可能不认它,
# 而那是请求时才炸的,所以留一个关得掉的口子
if extra.get("interleaved_thinking") is not False:
kwargs["betas"] = [catalog.INTERLEAVED_BETA]
# 开了 thinking 就不能再传 temperature(要求是 1)。4.6 起的模型压根
# 不收采样参数,上面 supports_sampling 已经挡住了;漏的是 4/4.5 这一代
# ——它们本来收 temperature,一旦开了 thinking 再传就是 400
if thinking_on:
kwargs.pop("temperature", None)
if spec.effort and catalog.supports_effort(model):
model_kwargs["output_config"] = {"effort": spec.effort}
if model_kwargs:
kwargs["model_kwargs"] = model_kwargs
chat = ChatAnthropic(**kwargs)
if bearer:
# 网关只认 Authorization: Bearer,且多带一个 x-api-key 就会 401。
# ChatAnthropic 的 default_headers 是 dict[str,str],塞不进 SDK 的 Omit 哨兵,
# 所以直接换掉底层 client —— anthropic SDK 原生支持 auth_token,
# 且 api_key=None 时不会发出 x-api-key。
import anthropic
client_args = {"base_url": base_url, "api_key": None, "auth_token": api_key,
"max_retries": chat.max_retries}
chat._client = anthropic.Anthropic(**client_args)
chat._async_client = anthropic.AsyncAnthropic(**client_args)
return chat
# openai 与 openai 兼容走同一个类,区别只在 base_url
if provider.kind == "openai_compatible" and not base_url:
raise ProviderNotConfigured(f"模型接入「{provider.name}」需要填写 Base URL",
"请在「设置 → 模型接入」中填写", code="missing_base_url")
# 很多本地服务(Ollama、vLLM)不校验 key,但 SDK 要求非空
kwargs["api_key"] = api_key or "not-needed"
if base_url:
kwargs["base_url"] = base_url
if extra.get("headers"):
kwargs["default_headers"] = extra["headers"]
# 流式调用要服务方在流尾补一块用量(stream_options.include_usage)。ChatOpenAI 只在没填 base_url 时默认开,
# 兼容接口都填了 base_url,于是一次运行上百次调用记成 0 token、0 美元——llm.end 读的 usage_metadata
# 就来自这一块。不认 stream_options 的服务由 ReasoningAwareChatOpenAI 去掉它重发,不让调用失败
kwargs["stream_usage"] = True
return _ReasoningAwareChatOpenAI(**kwargs)
def _reasoning_aware_openai() -> type:
"""ChatOpenAI 的子类,保留供应商的思考内容。
langchain 的 ChatOpenAI 只认官方 OpenAI 规范,第三方加的 reasoning_content
是**明确丢弃**的(它的文档原话:"Non-standard response fields added by
third-party providers are not extracted or preserved"),建议换用
ChatDeepSeek 之类的专用包。
但"用哪个供应商"在这个产品里是用户在设置页配的,可能是 DeepSeek、
Qianfan、vLLM、OpenRouter 里任何一个——为每种装一个专用包不现实。而这
段内容正是界面上"它大致在想什么"的唯一真实来源:实测 deepseek-v4-pro
一次调用花 160 个 token 在推理上,全被丢掉了,于是编排期那 50 秒里
界面上只有一行不动的"正在理解需求"。
所以在这里把它捞回 additional_kwargs,thinking_text 再统一读出来。
只加字段,不改 langchain 对正文的任何处理。
"""
from langchain_openai import ChatOpenAI
class ReasoningAwareChatOpenAI(ChatOpenAI): # type: ignore[misc]
def _convert_chunk_to_generation_chunk(
self, chunk: dict, default_chunk_class: type, base_generation_info: dict | None
):
gen = super()._convert_chunk_to_generation_chunk(
chunk, default_chunk_class, base_generation_info
)
if gen is None:
return gen
choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices") or []
delta = (choices[0] or {}).get("delta") or {} if choices else {}
# 不同网关的叫法不统一,见到哪个用哪个
text = delta.get("reasoning_content") or delta.get("reasoning")
if isinstance(text, str) and text:
gen.message.additional_kwargs["reasoning_content"] = text
return gen
async def _astream(self, messages: Any, stop: Any = None, run_manager: Any = None, **kwargs: Any):
"""流式调用;服务方不认 stream_options 时去掉它重发一次,之后这个接口不再带它。
只在还没收到任何一块时重发:拒绝都发生在请求刚发出去的时候,重发不会重复输出。"""
usage = not self._use_responses_api({**kwargs, **self.model_kwargs}) \
and self._should_stream_usage(kwargs.get("stream_usage"), **kwargs)
key = _endpoint_key(self)
first: Exception | None = None
if usage and key not in _NO_STREAM_USAGE:
started = False
try:
async for chunk in super()._astream(messages, stop, run_manager, **dict(kwargs)):
started = True
yield chunk
return
except Exception as e: # noqa: BLE001 - 认得出的拒绝才重发,其余原样抛出
if started or not _rejects_stream_options(e):
raise
first = e
if usage:
kwargs = {k: v for k, v in kwargs.items() if k != "stream_options"} | {"stream_usage": False}
try:
async for chunk in super()._astream(messages, stop, run_manager, **kwargs):
if first is not None and key not in _NO_STREAM_USAGE:
# 去掉它就成了:记下这个接口不认,之后不再每次先失败一次
_NO_STREAM_USAGE.add(key)
logger.warning("模型接入 %s 不支持流式用量回传(%s),已改为不请求用量", key, str(first)[:200])
yield chunk
except Exception as e:
if first is None:
raise
raise e from first
return ReasoningAwareChatOpenAI
#: 认定不支持 stream_options 的接口(base_url)。进程内有效:重启后再试一次,服务方可能已经升级
_NO_STREAM_USAGE: set[str] = set()
def _endpoint_key(model: Any) -> str:
return str(getattr(model, "openai_api_base", None) or "https://api.openai.com/v1")
def _rejects_stream_options(exc: Exception) -> bool:
"""这个报错是不是服务方不认 stream_options。
点名了 stream_options / include_usage 的一定是;只回 400 / 422、说不清哪个参数的(有的网关只回一句
Bad Request)也去掉它试一次——重发也失败就说明不是它的事,原样报重发的错。上下文超长同样是 400,
重发也没用,不算。"""
from langchain_core.exceptions import ContextOverflowError
if isinstance(exc, ContextOverflowError):
return False
text = str(exc).lower()
if "stream_options" in text or "include_usage" in text:
return True
return getattr(exc, "status_code", None) in (400, 422)
class _LazyReasoningChatOpenAI:
"""延迟导入 langchain_openai:它加载得慢,而 mock/anthropic 路径用不上。"""
def __call__(self, **kwargs: Any) -> Any:
return _reasoning_aware_openai()(**kwargs)
_ReasoningAwareChatOpenAI = _LazyReasoningChatOpenAI()
def bind_tools_safely(model: BaseChatModel, tools: list[Any], *, parallel: bool) -> Any:
"""绑定工具,并在需要时让模型一次只发一个工具调用。
`parallel_tool_calls=False` 两家都认,只是落点不同:ChatAnthropic 把它翻成
`tool_choice={"type": "auto", "disable_parallel_tool_use": True}`(type 是 auto
而不是强制某个工具,所以和 thinking 并存没问题),ChatOpenAI 原样透传。
但 provider 可以是任意 OpenAI 兼容网关(Ollama、vLLM、OpenRouter…),个别
网关可能在**请求时**才拒绝这个字段——那种情况这里的 try 拦不住。真撞上了,
把节点上的"并行执行工具"打开即可,绑定就退回原样。
无论如何这都只是省 token 的优化:模型仍然多发了几个调用时,编排层一轮只执行
第一个,串行语义不依赖这里能不能传成功。
"""
if not tools:
return model
if parallel:
return model.bind_tools(tools)
try:
return model.bind_tools(tools, parallel_tool_calls=False)
except (TypeError, ValueError, NotImplementedError):
return model.bind_tools(tools)
async def get_chat_model(session: AsyncSession, spec: ModelSpec) -> tuple[BaseChatModel, str]:
"""返回 (模型, 实际使用的模型 id)。
只有没点名接入、而一个启用的接入都没有时才回退到演示模型,保证一张图在没配 key 的机器上也能执行完。
点名的接入不存在或已停用、按模型名找到的接入已停用、接入缺 key 这些都抛 ProviderNotConfigured,
由调用方照实报给人(节点失败、核查记缺口……),不换成演示模型。"""
provider = await resolve_provider(session, spec.provider, spec.model)
if provider is None:
return MockChatModel(model_name="mock-fast"), "mock-fast"
try:
model = build_chat_model(provider, spec)
except ProviderNotConfigured:
raise
return model, (spec.model or provider.default_model or "")