mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
632 lines
25 KiB
PHP
632 lines
25 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
declare(strict_types=1);
|
||
|
||
namespace app\services\ai;
|
||
|
||
use app\dao\ai\AiModelDao;
|
||
use crmeb\basic\BaseService;
|
||
use crmeb\exceptions\AdminException;
|
||
use crmeb\services\ai\AiInterface;
|
||
use crmeb\services\ai\AiProtocol;
|
||
use crmeb\services\ai\EmbeddingAiInterface;
|
||
use crmeb\utils\Http;
|
||
use crmeb\utils\SsrfGuard;
|
||
/**
|
||
* AI 模型管理服务(系统内置 AI 能力)
|
||
*
|
||
* 职责:模型登记的增删改查 + 把模型记录解析为协议适配器可用的配置,
|
||
* 协议实现本身在 crmeb\services\ai\protocol 下,不感知数据表。
|
||
*/
|
||
class AiModelServices extends BaseService
|
||
{
|
||
/**
|
||
* 密钥占位符:更新时回传该值表示保持原密钥不变
|
||
*/
|
||
const SECRET_KEEP = '__KEEP__';
|
||
|
||
/**
|
||
* 系统内置默认协议:一号通 AI
|
||
*
|
||
* 凭证来自「一号通登录/设置」写入的全局系统配置,无需在 eb_ai_model 登记模型记录,
|
||
* 因此未显式指定模型的对话接口统一以此协议兜底。
|
||
*/
|
||
const DEFAULT_PROTOCOL = 'yihaotong';
|
||
|
||
/**
|
||
* @var AiModelDao
|
||
*/
|
||
protected $dao;
|
||
|
||
public function __construct(AiModelDao $dao)
|
||
{
|
||
$this->dao = $dao;
|
||
}
|
||
|
||
/**
|
||
* 模型列表(分页)
|
||
* @param array $where 查询条件(status/protocol/keyword)
|
||
* @param int $page 页码
|
||
* @param int $limit 每页条数
|
||
* @return array ['list' => array, 'count' => int]
|
||
*/
|
||
public function getList(array $where = [], int $page = 1, int $limit = 20): array
|
||
{
|
||
$count = $this->dao->count($where);
|
||
$list = $this->dao->selectList($where, '*', $page, $limit, 'sort desc,id asc', [], true)->toArray();
|
||
foreach ($list as &$item) {
|
||
$item = $this->formatModel($item);
|
||
}
|
||
unset($item);
|
||
// 内置一号通 AI 的配置状态随列表下发,供页面提示「未配置,请一键配置」
|
||
return ['list' => $list, 'count' => $count, 'yihaotong_configured' => $this->isYihaotongConfigured() ? 1 : 0];
|
||
}
|
||
|
||
/**
|
||
* 模型详情
|
||
* @param int $id 模型ID
|
||
* @return array
|
||
*/
|
||
public function getDetail(int $id): array
|
||
{
|
||
return $this->formatModel($this->dao->findById($id), true);
|
||
}
|
||
|
||
/**
|
||
* 新增模型
|
||
* @param array $data 表单数据
|
||
* @return int 模型ID
|
||
*/
|
||
public function save(array $data): int
|
||
{
|
||
$payload = $this->buildData($data);
|
||
$payload['is_default'] = (int) ($payload['is_default'] ?? 0);
|
||
if ($payload['is_default'] === 1 && (int) ($payload['status'] ?? 1) === 1) {
|
||
$payload['is_default'] = 0;
|
||
$id = (int) $this->dao->save($payload)->id;
|
||
$this->setDefault($id);
|
||
return $id;
|
||
}
|
||
return (int) $this->dao->save($payload)->id;
|
||
}
|
||
|
||
/**
|
||
* 修改模型
|
||
* @param int $id 模型ID
|
||
* @param array $data 表单数据
|
||
* @return bool
|
||
*/
|
||
public function update(int $id, array $data): bool
|
||
{
|
||
$old = $this->dao->findById($id);
|
||
if (!$old) {
|
||
throw new AdminException('AI 模型不存在');
|
||
}
|
||
|
||
$payload = $this->buildData($data, $id);
|
||
// 密钥留空或回传占位符时保持原值,避免编辑表单必须重填密钥
|
||
if (($payload['api_key'] ?? '') === '' || $payload['api_key'] === self::SECRET_KEEP) {
|
||
$payload['api_key'] = (string) ($old['api_key'] ?? '');
|
||
}
|
||
|
||
$this->dao->update($id, $payload);
|
||
if ((int) ($payload['is_default'] ?? 0) === 1) {
|
||
$this->setDefault($id);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 删除模型
|
||
* @param int $id 模型ID
|
||
* @return bool
|
||
*/
|
||
public function delete(int $id): bool
|
||
{
|
||
if (!$this->dao->findById($id)) {
|
||
throw new AdminException('AI 模型不存在');
|
||
}
|
||
$this->dao->delete($id);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 修改模型状态
|
||
* @param int $id 模型ID
|
||
* @param int $status 状态(0=停用 1=启用)
|
||
* @return bool
|
||
*/
|
||
public function setStatus(int $id, int $status): bool
|
||
{
|
||
if (!$this->dao->findById($id)) {
|
||
throw new AdminException('AI 模型不存在');
|
||
}
|
||
$this->dao->update($id, $status === 0 ? ['status' => 0, 'is_default' => 0] : ['status' => 1]);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 设为系统默认模型
|
||
* @param int $id 模型ID
|
||
* @return bool
|
||
*/
|
||
public function setDefault(int $id): bool
|
||
{
|
||
// id = 0 表示内置一号通模型:清空全部登记默认,一号通即成为系统默认
|
||
if ($id === 0) {
|
||
$this->dao->clearDefaultExcept(0);
|
||
return true;
|
||
}
|
||
$model = $this->dao->findById($id);
|
||
if (!$model) {
|
||
throw new AdminException('AI 模型不存在');
|
||
}
|
||
if ((int) $model['status'] !== 1) {
|
||
throw new AdminException('请先启用该模型再设为默认');
|
||
}
|
||
$this->dao->clearDefaultExcept($id);
|
||
$this->dao->update($id, ['is_default' => 1]);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 批量排序
|
||
* @param array $sortData [['id' => 1, 'sort' => 10], ...]
|
||
* @return bool
|
||
*/
|
||
public function setSort(array $sortData): bool
|
||
{
|
||
foreach ($sortData as $item) {
|
||
if (isset($item['id']) && isset($item['sort'])) {
|
||
$this->dao->update((int) $item['id'], ['sort' => (int) $item['sort']]);
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 模型连通性测试:按表单配置构造协议适配器发起一次真实请求(不落库)
|
||
*
|
||
* 按模型能力分流:仅勾选「支持文本向量化」的纯向量模型没有对话端点,
|
||
* 改用 /embeddings 端点真实测试,其余模型走对话接口。
|
||
* @param array $data 表单数据(与保存表单一致,编辑时需携带 id 以继承原密钥)
|
||
* @return array [ok=>1|0, cost_ms=>int, message=>string]
|
||
*/
|
||
public function testModel(array $data): array
|
||
{
|
||
$id = (int) ($data['id'] ?? 0);
|
||
$old = $id > 0 ? $this->dao->findById($id) : null;
|
||
if ($old) {
|
||
$data = array_merge($old, $data);
|
||
// 编辑场景密钥回传占位符或留空时,使用原密钥真实测试
|
||
if (in_array(trim((string) ($data['api_key'] ?? '')), ['', self::SECRET_KEEP], true)) {
|
||
$data['api_key'] = (string) $old['api_key'];
|
||
}
|
||
}
|
||
// 名称仅用于展示,测试场景不参与查重
|
||
$data['name'] = trim((string) ($data['name'] ?? '')) ?: ((string) ($data['model'] ?? ''));
|
||
$payload = $this->buildData($data, $id);
|
||
|
||
$driver = AiProtocol::make($payload['protocol'], $this->buildConfig($payload));
|
||
// 纯向量模型:仅勾选向量化、未勾选工具调用/视觉(向量模型调 /chat/completions 会被厂商直接拒绝)
|
||
$embeddingOnly = (int) ($payload['supports_embedding'] ?? 0) === 1
|
||
&& (int) ($payload['supports_tools'] ?? 0) !== 1
|
||
&& (int) ($payload['supports_vision'] ?? 0) !== 1;
|
||
$start = microtime(true);
|
||
try {
|
||
if ($embeddingOnly) {
|
||
if (!$driver instanceof EmbeddingAiInterface) {
|
||
throw new \RuntimeException('该模型协议不支持文本向量化,请检查模型能力配置');
|
||
}
|
||
$driver->embeddings(['连通性测试']);
|
||
} else {
|
||
$result = $driver->chat('连通性测试,请直接回复:ok', ['stream' => 0]);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$message = $e->getMessage();
|
||
// 向量模型因未勾选向量化能力走了对话端点被厂商拒绝时,给出可操作的勾选建议
|
||
if (!$embeddingOnly
|
||
&& (int) ($payload['supports_embedding'] ?? 0) !== 1
|
||
&& str_contains(strtolower((string) ($payload['model'] ?? '')), 'embedding')) {
|
||
$message .= ';检测到模型 ID 含有「embedding」,请先在高级配置勾选「支持文本向量化」再测试';
|
||
}
|
||
return [
|
||
'ok' => 0,
|
||
'cost_ms' => (int) round((microtime(true) - $start) * 1000),
|
||
'message' => '连通性测试失败:' . $message,
|
||
];
|
||
}
|
||
$costMs = (int) round((microtime(true) - $start) * 1000);
|
||
if ($embeddingOnly) {
|
||
return [
|
||
'ok' => 1,
|
||
'cost_ms' => $costMs,
|
||
'message' => '连通性测试成功,向量接口正常响应(耗时 ' . $costMs . 'ms)',
|
||
];
|
||
}
|
||
$content = trim((string) ($result['content'] ?? ''));
|
||
return [
|
||
'ok' => 1,
|
||
'cost_ms' => $costMs,
|
||
'message' => $content !== ''
|
||
? '连通性测试成功,模型已正常响应(耗时 ' . $costMs . 'ms)'
|
||
: '连通性测试通过,但模型未返回内容,请检查模型配置',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 拉取厂商可用模型列表(OpenAI 兼容 /models 端点,不落库)
|
||
*
|
||
* 供新增/编辑弹窗「获取模型」按钮调用:按表单填写的接口地址与密钥请求 /models,
|
||
* 返回模型标识列表,前端拿到后把「模型 ID」切换为下拉选择。
|
||
* @param array $data 表单数据(base_url/api_key/full_url,编辑时需携带 id 以继承原密钥)
|
||
* @return array 模型标识一维数组(去重升序)
|
||
*/
|
||
public function fetchRemoteModels(array $data): array
|
||
{
|
||
$id = (int) ($data['id'] ?? 0);
|
||
$old = $id > 0 ? $this->dao->findById($id) : null;
|
||
if ($old) {
|
||
$data = array_merge($old, $data);
|
||
// 编辑场景密钥回传占位符或留空时,使用原密钥真实拉取
|
||
if (in_array(trim((string) ($data['api_key'] ?? '')), ['', self::SECRET_KEEP], true)) {
|
||
$data['api_key'] = (string) $old['api_key'];
|
||
}
|
||
}
|
||
|
||
$baseUrl = trim((string) ($data['base_url'] ?? ''));
|
||
if (!preg_match('#^https?://#i', $baseUrl)) {
|
||
throw new AdminException('请先填写以 http:// 或 https:// 开头的接口地址');
|
||
}
|
||
// 完整 URL 模式没有「网关根地址」概念,无法推导 /models 端点
|
||
if ((int) ($data['full_url'] ?? 0) === 1) {
|
||
throw new AdminException('完整 URL 模式无法自动获取模型列表,请手动输入模型 ID');
|
||
}
|
||
$apiKey = trim((string) ($data['api_key'] ?? ''));
|
||
if ($apiKey === '') {
|
||
throw new AdminException('请先填写 API 密钥');
|
||
}
|
||
// 出站 SSRF 防护:与模型保存同口径(「允许内网 AI 服务」开关打开时放行内网自建模型)
|
||
try {
|
||
SsrfGuard::assertSafeUrl($baseUrl, $this->privateEndpointAllowed() ? SsrfGuard::MODE_SELFCALL : SsrfGuard::MODE_STRICT);
|
||
} catch (\InvalidArgumentException $e) {
|
||
throw new AdminException('接口地址不安全:' . $e->getMessage());
|
||
}
|
||
|
||
// 地址可能被填成对话端点(…/chat/completions),先剥掉再拼 /models
|
||
if (str_ends_with($baseUrl, '/chat/completions')) {
|
||
$baseUrl = substr($baseUrl, 0, -strlen('/chat/completions'));
|
||
}
|
||
$url = rtrim($baseUrl, '/') . '/models';
|
||
|
||
// 原生 curl 而非 Http::getRequest:4xx(密钥错误等)响应体里有厂商的明确错误信息,需要取出来提示
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Accept: application/json'],
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CONNECTTIMEOUT => 10,
|
||
CURLOPT_TIMEOUT => 15,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
]);
|
||
$body = curl_exec($ch);
|
||
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||
$curlError = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
if ($body === false) {
|
||
throw new AdminException('获取模型列表失败:' . ($curlError ?: '网络异常,请稍后重试'));
|
||
}
|
||
$result = json_decode((string) $body, true);
|
||
if (!is_array($result)) {
|
||
throw new AdminException($httpCode > 0 ? '获取模型列表失败:HTTP ' . $httpCode : '获取模型列表失败:响应解析失败');
|
||
}
|
||
if ($httpCode >= 400 || isset($result['error'])) {
|
||
throw new AdminException('获取模型列表失败:' . $this->remoteModelsErrorMessage($result, $httpCode));
|
||
}
|
||
$models = [];
|
||
foreach ((array) ($result['data'] ?? []) as $item) {
|
||
$modelId = trim((string) ($item['id'] ?? ''));
|
||
if ($modelId !== '') {
|
||
$models[] = $modelId;
|
||
}
|
||
}
|
||
$models = array_values(array_unique($models));
|
||
sort($models);
|
||
return $models;
|
||
}
|
||
|
||
/**
|
||
* 从拉取模型列表的错误响应中提取可读消息
|
||
* @param array $result 已解析的 JSON 响应
|
||
* @param int $httpCode HTTP 状态码
|
||
* @return string
|
||
*/
|
||
protected function remoteModelsErrorMessage(array $result, int $httpCode): string
|
||
{
|
||
$error = $result['error'] ?? null;
|
||
$message = is_array($error) ? (string) ($error['message'] ?? '') : (string) $error;
|
||
if ($message === '') {
|
||
$message = (string) ($result['message'] ?? ($result['msg'] ?? ''));
|
||
}
|
||
if ($message === '') {
|
||
$message = 'HTTP ' . ($httpCode > 0 ? $httpCode : '请求异常');
|
||
}
|
||
return mb_substr(trim($message), 0, 300);
|
||
}
|
||
|
||
/**
|
||
* 可用模型清单(下拉选择用,不含密钥)
|
||
* @param bool $onlyEnabled 仅返回启用中的模型
|
||
* @return array
|
||
*/
|
||
public function getAvailableModels(bool $onlyEnabled = true): array
|
||
{
|
||
$list = $onlyEnabled ? $this->dao->getAllEnabled() : $this->dao->selectList([], '*', 0, 0, 'sort desc,id asc')->toArray();
|
||
$result = [];
|
||
foreach ($list as $item) {
|
||
$result[] = [
|
||
'id' => (int) $item['id'],
|
||
'name' => (string) $item['name'],
|
||
'protocol' => (string) $item['protocol'],
|
||
'protocol_name' => AiProtocol::name((string) $item['protocol']),
|
||
'provider' => (string) $item['provider'],
|
||
'model' => (string) $item['model'],
|
||
'supports_tools' => (int) $item['supports_tools'],
|
||
'supports_vision' => (int) $item['supports_vision'],
|
||
'supports_embedding' => (int) ($item['supports_embedding'] ?? 0),
|
||
'is_default' => (int) $item['is_default'],
|
||
'status' => (int) $item['status'],
|
||
];
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* 内置协议族清单(后台表单渲染用)
|
||
* @return array
|
||
*/
|
||
public function getProtocols(): array
|
||
{
|
||
return AiProtocol::options();
|
||
}
|
||
|
||
/**
|
||
* 按模型 ID 创建协议适配器实例
|
||
* @param int $modelId 模型ID,传 0 使用系统默认模型
|
||
* @return AiInterface
|
||
*/
|
||
public function getHandler(int $modelId = 0): AiInterface
|
||
{
|
||
$model = $modelId > 0 ? $this->dao->findById($modelId) : $this->dao->getDefaultModel();
|
||
if (!$model) {
|
||
throw new AdminException($modelId > 0
|
||
? 'AI 模型不存在,请在「AI Agent → 模型管理」中检查配置'
|
||
: '未配置默认 AI 模型,请先在「AI Agent → 模型管理」中新增并启用一个模型');
|
||
}
|
||
if ((int) $model['status'] !== 1) {
|
||
throw new AdminException('AI 模型[' . $model['name'] . ']已停用,请在「AI Agent → 模型管理」中启用');
|
||
}
|
||
return AiProtocol::make((string) $model['protocol'], $this->buildConfig($model));
|
||
}
|
||
|
||
/**
|
||
* 对话接口用适配器解析:显式指定模型走登记模型,缺省走内置一号通 AI
|
||
*
|
||
* 与 getHandler 的差异:本方法在 model_id 为 0 时不依赖「系统默认模型」,
|
||
* 直接使用一号通全局凭证,保证后台内置 AI 能力(如编辑器 AI 写作)开箱可用。
|
||
* @param int $modelId 模型ID,传 0 使用内置一号通 AI
|
||
* @return AiInterface
|
||
*/
|
||
public function getChatHandler(int $modelId = 0): AiInterface
|
||
{
|
||
return $modelId > 0 ? $this->getHandler($modelId) : $this->getYihaotongHandler();
|
||
}
|
||
|
||
/**
|
||
* 向量模型适配器解析:显式指定模型走登记模型(须勾选向量化能力),缺省走一号通全局凭证
|
||
*
|
||
* 与 getChatHandler 同口径:model_id 为 0 时不依赖登记记录,
|
||
* 直接使用一号通全局凭证 + 平台默认向量模型,保证向量化能力开箱可用。
|
||
* @param int $modelId 向量模型ID,传 0 使用内置一号通向量模型
|
||
* @return EmbeddingAiInterface
|
||
*/
|
||
public function getEmbeddingHandler(int $modelId = 0): EmbeddingAiInterface
|
||
{
|
||
$handler = $modelId > 0 ? $this->getHandler($modelId) : $this->getYihaotongHandler();
|
||
if (!$handler instanceof EmbeddingAiInterface) {
|
||
throw new AdminException('该模型协议不支持文本向量化,请更换向量模型或在模型管理中勾选「支持文本向量化」');
|
||
}
|
||
if ($modelId > 0) {
|
||
$model = $this->dao->findById($modelId);
|
||
if ((int) ($model['supports_embedding'] ?? 0) !== 1) {
|
||
throw new AdminException('模型[' . ($model['name'] ?? '') . ']未勾选「支持文本向量化」,请先在「AI Agent → 模型管理」中勾选');
|
||
}
|
||
}
|
||
return $handler;
|
||
}
|
||
|
||
/**
|
||
* 创建内置一号通 AI 适配器
|
||
*
|
||
* 一号通 appid / appsecret 由「一号通登录」写入 eb_system_config 全站共享,
|
||
* 模型标识留空表示由一号通平台侧决定实际模型。
|
||
* @return AiInterface
|
||
*/
|
||
public function getYihaotongHandler(): AiInterface
|
||
{
|
||
return AiProtocol::make(self::DEFAULT_PROTOCOL, [
|
||
'api_key' => '',
|
||
'base_url' => '',
|
||
'model' => '',
|
||
'temperature' => 0.7,
|
||
'max_tokens' => 2048,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 内置一号通 AI 是否已配置
|
||
*
|
||
* 与 Yihaotong 协议的凭证读取保持同一口径:全局系统配置
|
||
* yihaotong_appid / yihaotong_appsecret 均非空视为已配置。
|
||
* @return bool
|
||
*/
|
||
public function isYihaotongConfigured(): bool
|
||
{
|
||
return sys_config('yihaotong_appid', '') !== ''
|
||
&& sys_config('yihaotong_appsecret', '') !== '';
|
||
}
|
||
|
||
/**
|
||
* 模型记录 → 协议适配器配置
|
||
* @param array $model 模型记录
|
||
* @return array
|
||
*/
|
||
public function buildConfig(array $model): array
|
||
{
|
||
return [
|
||
'api_key' => (string) ($model['api_key'] ?? ''),
|
||
'base_url' => (string) ($model['base_url'] ?? ''),
|
||
'full_url' => (int) ($model['full_url'] ?? 0),
|
||
'model' => (string) ($model['model'] ?? ''),
|
||
'temperature' => (float) ($model['temperature'] ?? 0.7),
|
||
'max_tokens' => (int) ($model['max_tokens'] ?? 2048),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 表单校验并归一化为可入库字段
|
||
* @param array $data 表单数据
|
||
* @param int $exceptId 编辑时排除自身ID
|
||
* @return array
|
||
*/
|
||
protected function buildData(array $data, int $exceptId = 0): array
|
||
{
|
||
$name = trim((string) ($data['name'] ?? ''));
|
||
if ($name === '') {
|
||
throw new AdminException('请填写模型显示名称');
|
||
}
|
||
if ($this->dao->countByName($name, $exceptId) > 0) {
|
||
throw new AdminException('模型显示名称已存在');
|
||
}
|
||
|
||
$protocol = trim((string) ($data['protocol'] ?? ''));
|
||
if (!AiProtocol::has($protocol)) {
|
||
throw new AdminException('请选择正确的 AI 协议');
|
||
}
|
||
|
||
$model = trim((string) ($data['model'] ?? ''));
|
||
if ($model === '') {
|
||
throw new AdminException('请填写模型标识(如 deepseek-chat、qwen-plus)');
|
||
}
|
||
|
||
$baseUrl = trim((string) ($data['base_url'] ?? ''));
|
||
$apiKey = trim((string) ($data['api_key'] ?? ''));
|
||
if ($protocol !== 'yihaotong') {
|
||
if ($baseUrl !== '') {
|
||
if (!preg_match('#^https?://#i', $baseUrl)) {
|
||
throw new AdminException('接口地址必须以 http:// 或 https:// 开头');
|
||
}
|
||
// 出站 SSRF 防护:拒绝内网/元数据地址与危险端口(「允许内网 AI 服务」开关打开时放行内网自建模型)
|
||
try {
|
||
SsrfGuard::assertSafeUrl($baseUrl, $this->privateEndpointAllowed() ? SsrfGuard::MODE_SELFCALL : SsrfGuard::MODE_STRICT);
|
||
} catch (\InvalidArgumentException $e) {
|
||
throw new AdminException('接口地址不安全:' . $e->getMessage());
|
||
}
|
||
}
|
||
} else {
|
||
// 一号通复用系统全局凭证,不落地独立地址与密钥
|
||
$baseUrl = '';
|
||
}
|
||
|
||
$temperature = (float) ($data['temperature'] ?? 0.7);
|
||
if ($temperature < 0 || $temperature > 2) {
|
||
throw new AdminException('采样温度取值范围为 0 ~ 2');
|
||
}
|
||
|
||
$status = (int) ($data['status'] ?? 1) === 0 ? 0 : 1;
|
||
return [
|
||
'name' => $name,
|
||
'protocol' => $protocol,
|
||
'provider' => trim((string) ($data['provider'] ?? '')),
|
||
'model' => $model,
|
||
'base_url' => $baseUrl,
|
||
'api_key' => $apiKey,
|
||
'full_url' => (int) ($data['full_url'] ?? 0) === 1 ? 1 : 0,
|
||
'supports_tools' => (int) ($data['supports_tools'] ?? 0) === 1 ? 1 : 0,
|
||
'supports_vision' => (int) ($data['supports_vision'] ?? 0) === 1 ? 1 : 0,
|
||
'supports_embedding' => (int) ($data['supports_embedding'] ?? 0) === 1 ? 1 : 0,
|
||
'context_window' => max(0, (int) ($data['context_window'] ?? 0)),
|
||
'max_tokens' => max(0, (int) ($data['max_tokens'] ?? 2048)),
|
||
'temperature' => $temperature,
|
||
// 停用的模型不能担任系统默认:强制取消默认标记,避免默认模型悬空不可用
|
||
'is_default' => $status === 1 && (int) ($data['is_default'] ?? 0) === 1 ? 1 : 0,
|
||
'sort' => (int) ($data['sort'] ?? 0),
|
||
'status' => $status,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 是否允许出站地址指向内网(自建本地模型如 Ollama/vLLM 场景),
|
||
* 由「AI 自动化任务 → 允许内网 AI 服务地址」系统配置控制,默认关闭
|
||
* @return bool
|
||
*/
|
||
protected function privateEndpointAllowed(): bool
|
||
{
|
||
return (int) sys_config('ai_allow_private_endpoint', 0) === 1;
|
||
}
|
||
|
||
/**
|
||
* 输出格式化:脱敏密钥并补充协议名
|
||
* @param array $item 模型记录
|
||
* @param bool $detail 是否详情场景(详情仅给出是否已配置的布尔提示)
|
||
* @return array
|
||
*/
|
||
protected function formatModel(array $item, bool $detail = false): array
|
||
{
|
||
if (!$item) {
|
||
return [];
|
||
}
|
||
$apiKey = (string) ($item['api_key'] ?? '');
|
||
$item['protocol_name'] = AiProtocol::name((string) $item['protocol']);
|
||
$item['has_api_key'] = $apiKey !== '' ? 1 : 0;
|
||
$item['api_key'] = $detail ? ($apiKey !== '' ? self::SECRET_KEEP : '') : $this->maskSecret($apiKey);
|
||
$item['temperature'] = (float) $item['temperature'];
|
||
$item['max_tokens'] = (int) $item['max_tokens'];
|
||
$item['context_window'] = (int) $item['context_window'];
|
||
$item['full_url'] = (int) ($item['full_url'] ?? 0);
|
||
$item['supports_tools'] = (int) $item['supports_tools'];
|
||
$item['supports_vision'] = (int) $item['supports_vision'];
|
||
$item['supports_embedding'] = (int) ($item['supports_embedding'] ?? 0);
|
||
$item['is_default'] = (int) $item['is_default'];
|
||
$item['status'] = (int) $item['status'];
|
||
$item['sort'] = (int) $item['sort'];
|
||
return $item;
|
||
}
|
||
|
||
/**
|
||
* 密钥脱敏展示
|
||
* @param string $secret 原始密钥
|
||
* @return string
|
||
*/
|
||
protected function maskSecret(string $secret): string
|
||
{
|
||
if ($secret === '') {
|
||
return '';
|
||
}
|
||
$len = strlen($secret);
|
||
if ($len <= 8) {
|
||
return str_repeat('*', $len);
|
||
}
|
||
return substr($secret, 0, 4) . str_repeat('*', min(12, $len - 8)) . substr($secret, -4);
|
||
}
|
||
}
|