Compare commits

...
5 Commits
8 changed files with 79 additions and 12 deletions

No files matched your search

@@ -64,6 +64,27 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
private static final String DEFAULT_TITLE_PROMPT = "请根据以下对话内容,生成一个简洁的会话标题(约15字以内),只返回标题,不要包含任何解释或标点符号:\n{conversation}"; private static final String DEFAULT_TITLE_PROMPT = "请根据以下对话内容,生成一个简洁的会话标题(约15字以内),只返回标题,不要包含任何解释或标点符号:\n{conversation}";
/**
* Ollama的OpenAI兼容端点位于{base_url}/v1下,与xiaozhi-server侧ollama provider逻辑保持一致:
* 剥离用户误填的完整端点后缀后,仅对不含路径的纯主机地址补/v1
*/
private static String normalizeOllamaBaseUrl(String baseUrl) {
if (baseUrl == null) {
return null;
}
String url = baseUrl.trim();
if (url.endsWith("/chat/completions")) {
url = url.substring(0, url.length() - "/chat/completions".length());
}
while (url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
if (url.replaceFirst("^https?://[^/]+", "").isEmpty()) {
url = url + "/v1";
}
return url;
}
@Override @Override
public String generateSummary(String conversation) { public String generateSummary(String conversation) {
return generateSummary(conversation, null, null); return generateSummary(conversation, null, null);
@@ -101,6 +122,9 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
String baseUrl = configJson.getStr("base_url"); String baseUrl = configJson.getStr("base_url");
String model = configJson.getStr("model_name"); String model = configJson.getStr("model_name");
String apiKey = configJson.getStr("api_key"); String apiKey = configJson.getStr("api_key");
if ("ollama".equalsIgnoreCase(configJson.getStr("type"))) {
baseUrl = normalizeOllamaBaseUrl(baseUrl);
}
Double temperature = configJson.getDouble("temperature"); Double temperature = configJson.getDouble("temperature");
Integer maxTokens = configJson.getInt("max_tokens"); Integer maxTokens = configJson.getInt("max_tokens");
@@ -196,6 +220,9 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
String baseUrl = configJson.getStr("base_url"); String baseUrl = configJson.getStr("base_url");
String model = configJson.getStr("model_name"); String model = configJson.getStr("model_name");
String apiKey = configJson.getStr("api_key"); String apiKey = configJson.getStr("api_key");
if ("ollama".equalsIgnoreCase(configJson.getStr("type"))) {
baseUrl = normalizeOllamaBaseUrl(baseUrl);
}
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) { if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
log.error("LLM配置不完整,baseUrl或apiKey为空"); log.error("LLM配置不完整,baseUrl或apiKey为空");
@@ -354,6 +381,9 @@ public class OpenAIStyleLLMServiceImpl implements LLMService {
String baseUrl = configJson.getStr("base_url"); String baseUrl = configJson.getStr("base_url");
String model = configJson.getStr("model_name"); String model = configJson.getStr("model_name");
String apiKey = configJson.getStr("api_key"); String apiKey = configJson.getStr("api_key");
if ("ollama".equalsIgnoreCase(configJson.getStr("type"))) {
baseUrl = normalizeOllamaBaseUrl(baseUrl);
}
if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) { if (StringUtils.isBlank(baseUrl) || StringUtils.isBlank(apiKey)) {
log.error("LLM配置不完整,baseUrl或apiKey为空"); log.error("LLM配置不完整,baseUrl或apiKey为空");
@@ -0,0 +1,17 @@
-- Ollama LLM配置补充api_key占位值,走鉴权网关/Ollama Cloud时使用
UPDATE `ai_model_config`
SET `config_json` = JSON_SET(`config_json`, '$.api_key', 'ollama')
WHERE `id` = 'LLM_OllamaLLM' AND JSON_EXTRACT(`config_json`, '$.api_key') IS NULL;
-- Ollama供应商追加api_key字段定义,password类型
UPDATE `ai_model_provider`
SET `fields` = JSON_ARRAY_APPEND(COALESCE(`fields`, JSON_ARRAY()), '$',
JSON_OBJECT('key', 'api_key', 'label', 'API密钥', 'type', 'password'))
WHERE `id` = 'SYSTEM_LLM_ollama'
AND JSON_SEARCH(`fields`, 'one', 'api_key', NULL, '$[*].key') IS NULL;
-- 配置说明补充api_key条目
UPDATE `ai_model_config`
SET `remark` = CONCAT(`remark`, '\n4. 走鉴权网关或使用Ollama Cloud时,需填写api_key')
WHERE `id` = 'LLM_OllamaLLM'
AND `remark` IS NOT NULL AND `remark` NOT LIKE '%api_key%';
@@ -753,6 +753,13 @@ databaseChangeLog:
- sqlFile: - sqlFile:
encoding: utf8 encoding: utf8
path: classpath:db/changelog/202609172100.sql path: classpath:db/changelog/202609172100.sql
- changeSet:
id: 202610291451
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610291451.sql
- changeSet: - changeSet:
id: 202610302051 id: 202610302051
author: shinjiyu author: shinjiyu
+1
View File
@@ -666,6 +666,7 @@ LLM:
type: ollama type: ollama
model_name: qwen2.5 # 使用的模型名称,需要预先使用ollama pull下载 model_name: qwen2.5 # 使用的模型名称,需要预先使用ollama pull下载
base_url: http://localhost:11434 # Ollama服务地址 base_url: http://localhost:11434 # Ollama服务地址
api_key: ollama # 本地部署无需真实密钥,占位即可;走鉴权网关或Ollama Cloud时修改为真实key
DifyLLM: DifyLLM:
# 定义LLM API类型 # 定义LLM API类型
type: dify type: dify
@@ -1,5 +1,6 @@
from config.logger import setup_logging from config.logger import setup_logging
from openai import OpenAI from openai import OpenAI
from urllib.parse import urlsplit
import json import json
from core.providers.llm.base import LLMProviderBase from core.providers.llm.base import LLMProviderBase
@@ -10,15 +11,20 @@ logger = setup_logging()
class LLMProvider(LLMProviderBase): class LLMProvider(LLMProviderBase):
def __init__(self, config): def __init__(self, config):
self.model_name = config.get("model_name") self.model_name = config.get("model_name")
self.base_url = config.get("base_url", "http://localhost:11434") self.base_url = (
str(config.get("base_url") or "").strip() or "http://localhost:11434"
).rstrip("/")
# 兼容处理用户输入完整端点的情况
if self.base_url.endswith("/chat/completions"):
self.base_url = self.base_url[:-len("/chat/completions")].rstrip("/")
# Initialize OpenAI client with Ollama base URL # Initialize OpenAI client with Ollama base URL
# 如果没有v1,增加v1 # 纯主机地址补/v1(Ollama默认约定);带路径的地址尊重用户输入,不再改写
if not self.base_url.endswith("/v1"): if not urlsplit(self.base_url).path.strip("/"):
self.base_url = f"{self.base_url}/v1" self.base_url = f"{self.base_url}/v1"
self.client = OpenAI( self.client = OpenAI(
base_url=self.base_url, base_url=self.base_url,
api_key="ollama", # Ollama doesn't need an API key but OpenAI client requires one api_key=config.get("api_key") or "ollama",
) )
# 检查是否是qwen3模型 # 检查是否是qwen3模型
@@ -119,7 +119,12 @@ class LLMPerformanceTester:
return chunks, first_token_time return chunks, first_token_time
async def _check_ollama_service(self, base_url: str, model_name: str) -> bool: async def _check_ollama_service(self, base_url: str, model_name: str) -> bool:
"""异步检查 Ollama 服务状态""" """异步检查 Ollama 服务状态(兼容带/v1或完整端点的base_url写法)"""
base_url = base_url.strip().rstrip("/")
if base_url.endswith("/chat/completions"):
base_url = base_url[: -len("/chat/completions")].rstrip("/")
if base_url.endswith("/v1"):
base_url = base_url[: -len("/v1")].rstrip("/")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
try: try:
async with session.get(f"{base_url}/api/version") as response: async with session.get(f"{base_url}/api/version") as response:
@@ -230,7 +235,7 @@ class LLMPerformanceTester:
"""异步测试单个 LLM 性能""" """异步测试单个 LLM 性能"""
try: try:
# 对于 Ollama,跳过 api_key 检查并进行特殊处理 # 对于 Ollama,跳过 api_key 检查并进行特殊处理
if llm_name == "Ollama": if llm_name == "OllamaLLM" or str(config.get("type", "")).lower() == "ollama":
base_url = config.get("base_url", "http://localhost:11434") base_url = config.get("base_url", "http://localhost:11434")
model_name = config.get("model_name") model_name = config.get("model_name")
if not model_name: if not model_name:
@@ -474,7 +479,7 @@ class LLMPerformanceTester:
continue continue
# 对于 Ollama,先检查服务状态 # 对于 Ollama,先检查服务状态
if llm_name == "Ollama": if llm_name == "OllamaLLM" or str(config.get("type", "")).lower() == "ollama":
base_url = config.get("base_url", "http://localhost:11434") base_url = config.get("base_url", "http://localhost:11434")
model_name = config.get("model_name") model_name = config.get("model_name")
if not model_name: if not model_name:
+6 -2
View File
@@ -184,7 +184,8 @@ def get_provider_type(module: str, provider_name: str | None = None) -> str | No
def has_real_key(config: dict, *keys: str) -> bool: def has_real_key(config: dict, *keys: str) -> bool:
"""任一候选 key 名下值不是占位符就算有真密钥。 """任一候选 key 名下值不是占位符就算有真密钥。
占位符判定:空 / "你的..." / 包含 "placeholder" / 字面 "none"。 占位符判定:空 / "你的..." / 包含 "placeholder" / 字面 "none" / "ollama"
(Ollama本地服务无需鉴权,config.yaml默认占位值即为"ollama")。
""" """
if not config: if not config:
return False return False
@@ -193,7 +194,7 @@ def has_real_key(config: dict, *keys: str) -> bool:
if val is None: if val is None:
continue continue
s = str(val).strip().lower() s = str(val).strip().lower()
if not s or s == "none": if not s or s == "none" or s == "ollama":
continue continue
if s.startswith("你的") or "placeholder" in s: if s.startswith("你的") or "placeholder" in s:
continue continue
@@ -390,6 +391,9 @@ def _check_modules_ready() -> None:
f"{module}.{provider_name} 配置缺失(CFG 字典里没这个 provider)" f"{module}.{provider_name} 配置缺失(CFG 字典里没这个 provider)"
) )
continue continue
# Ollama免鉴权供应器无需真实key,跳过占位符检查
if str(cfg.get("type", "")).lower() == "ollama":
continue
secret_keys_present = [k for k in _SECRET_FIELDS if k in cfg] secret_keys_present = [k for k in _SECRET_FIELDS if k in cfg]
if secret_keys_present and not any( if secret_keys_present and not any(
has_real_key(cfg, k) for k in secret_keys_present has_real_key(cfg, k) for k in secret_keys_present
-3
View File
@@ -50,9 +50,6 @@ def _build_provider():
x in cfg.get("user_id", "") for x in ["你的"] x in cfg.get("user_id", "") for x in ["你的"]
): ):
pytest.skip("LLM 'CozeLLM' 的 bot_id/user_id 未配置") pytest.skip("LLM 'CozeLLM' 的 bot_id/user_id 未配置")
elif name == "Ollama":
if not cfg.get("model_name"):
pytest.skip("LLM 'Ollama' 的 model_name 未配置")
elif not has_real_key(cfg, "api_key"): elif not has_real_key(cfg, "api_key"):
if LIVE_API_TESTS: if LIVE_API_TESTS:
pytest.skip(f"LLM '{name}' 的 api_key 未配置") pytest.skip(f"LLM '{name}' 的 api_key 未配置")