mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:01:58 +00:00
0d9f1d9339
- 实测 /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
489 lines
27 KiB
Python
489 lines
27 KiB
Python
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 "")
|