mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 20:21:58 +00:00
375 lines
17 KiB
Python
375 lines
17 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
|
||
extra: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class ProviderNotConfigured(RuntimeError):
|
||
"""模型接入没配好。reason 说缺了什么,hint 说去哪里补;str(e) 是两句连起来的整句,
|
||
运行失败原因直接用它。设置页的测试弹窗本来就在接入的编辑框里,只取 reason、另给自己的 hint。
|
||
code 给调用方按类别分支:disabled / no_model / missing_key / missing_base_url。"""
|
||
|
||
def __init__(self, reason: str, hint: str = "", code: str = "") -> None:
|
||
super().__init__(f"{reason}。{hint}" if hint else reason)
|
||
self.reason = reason
|
||
self.hint = hint
|
||
self.code = code
|
||
|
||
|
||
async def list_providers(session: AsyncSession) -> list[Provider]:
|
||
rows = await session.execute(select(Provider).order_by(Provider.created_at))
|
||
return list(rows.scalars())
|
||
|
||
|
||
async def resolve_provider(
|
||
session: AsyncSession, ref: str | None, model: str | None = None
|
||
) -> Provider | None:
|
||
"""找出该用哪个 provider。
|
||
|
||
顺序很关键:显式指定的 ref 最优先;其次按模型名反查 —— 节点上通常只选了
|
||
模型(比如 mock-fast),没选 provider,这时必须找到真正提供该模型的那一个,
|
||
否则会拿着 A 家的模型名去调 B 家的接口。最后才回退到默认 provider。
|
||
"""
|
||
providers = await list_providers(session)
|
||
enabled = [p for p in providers if p.enabled]
|
||
|
||
if ref:
|
||
for p in providers:
|
||
if p.name == ref or p.id == ref:
|
||
return p
|
||
return None
|
||
|
||
if model:
|
||
for p in enabled:
|
||
if any((m or {}).get("id") == model for m in (p.models or [])):
|
||
return p
|
||
for p in enabled:
|
||
if p.default_model == model:
|
||
return p
|
||
# 模型认得出来,但它所属的 provider 被停用了。这时候不能默默换一家 ——
|
||
# 拿着 A 家的模型名去调 B 家,必然 404,而且报错完全指不到真正的原因。
|
||
for p in providers:
|
||
if not p.enabled and (
|
||
any((m or {}).get("id") == model for m in (p.models or []))
|
||
or p.default_model == model
|
||
):
|
||
raise ProviderNotConfigured(
|
||
f"模型「{model}」所属的模型接入「{p.name}」已停用",
|
||
"请在「设置 → 模型接入」中启用它,或为节点换一个模型", code="disabled",
|
||
)
|
||
# 认不出来的模型名就放行:用户可能手填了一个目录里还没有的新模型
|
||
|
||
real = [p for p in enabled if p.kind != "mock"]
|
||
return (real or enabled or [None])[0]
|
||
|
||
|
||
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.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}
|
||
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)。找不到任何 provider 时回退到 mock,
|
||
保证一张图在没配 key 的机器上也能跑完。"""
|
||
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 "")
|