Complete admin model management

This commit is contained in:
2026-07-07 12:58:26 +08:00
parent 08c301c136
commit 55b7c5b0ea
12 changed files with 859 additions and 40 deletions

View File

@@ -32,6 +32,7 @@ const promptContent = ref("");
const chatDetail = ref<ChatDetail | null>(null);
const chatDetailOpen = ref(false);
const recordTab = ref("chats");
const editingModelId = ref<number | null>(null);
const knowledgeForm = reactive({
name: "",
@@ -42,15 +43,115 @@ const knowledgeForm = reactive({
});
const modelForm = reactive({
provider: "openai-compatible",
provider: "openai",
displayName: "",
apiType: "openai_compatible",
modelName: "",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
apiKey: "",
temperature: 0.2,
authType: "bearer",
apiVersion: "",
temperature: 0.2 as number | null,
topP: null as number | null,
topK: null as number | null,
presencePenalty: null as number | null,
frequencyPenalty: null as number | null,
maxToken: 1024,
contextWindow: null as number | null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
});
const modelPresets = [
{
label: "OpenAI",
provider: "openai",
apiType: "openai_compatible",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "DeepSeek",
provider: "deepseek",
apiType: "openai_compatible",
baseUrl: "https://api.deepseek.com/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "通义千问 DashScope",
provider: "qwen",
apiType: "openai_compatible",
baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "Kimi / Moonshot",
provider: "moonshot",
apiType: "openai_compatible",
baseUrl: "https://api.moonshot.cn/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "硅基流动 SiliconFlow",
provider: "siliconflow",
apiType: "openai_compatible",
baseUrl: "https://api.siliconflow.cn/v1",
apiUrl: "",
authType: "bearer",
},
{
label: "火山方舟 Ark",
provider: "volcengine-ark",
apiType: "openai_compatible",
baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
apiUrl: "",
authType: "bearer",
},
{
label: "Anthropic Claude",
provider: "anthropic",
apiType: "anthropic_messages",
baseUrl: "https://api.anthropic.com",
apiUrl: "",
authType: "api_key",
apiVersion: "2023-06-01",
temperature: null,
topP: null,
topK: null,
},
{
label: "Google Gemini",
provider: "gemini",
apiType: "gemini_generate_content",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiUrl: "",
authType: "api_key",
temperature: null,
topP: null,
topK: null,
},
];
const nullableModelFields = [
"temperature",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"contextWindow",
"responseFormat",
"extraParams",
"remark",
] as const;
const configForm = reactive({
configKey: "daily_chat_limit",
configValue: "100",
@@ -143,26 +244,141 @@ async function resetPrompt() {
}
async function saveModel() {
await api.createModel({ ...modelForm });
ElMessage.success("模型已新增");
Object.assign(modelForm, {
provider: "openai-compatible",
modelName: "",
apiUrl: "",
apiKey: "",
temperature: 0.2,
maxToken: 1024,
timeoutSecond: 30,
});
if (!validateModelForm()) return;
const payload = buildModelPayload();
if (editingModelId.value) {
await api.updateModel(editingModelId.value, payload);
ElMessage.success("模型已更新");
} else {
await api.createModel(payload);
ElMessage.success("模型已新增");
}
resetModelForm();
await loadCurrentMenu();
}
function buildModelPayload() {
const payload = { ...modelForm } as Record<string, unknown>;
nullableModelFields.forEach((field) => {
if (payload[field] === undefined || payload[field] === "") {
payload[field] = null;
}
});
return payload;
}
async function enableModel(id: number) {
await api.enableModel(id);
ElMessage.success("模型已启用");
await loadCurrentMenu();
}
async function testModel(id: number) {
const result = await api.testModel(id);
if (result.ok) {
ElMessage.success(result.message);
} else {
ElMessage.error(result.message);
}
}
function applyModelPreset(label: string) {
const preset = modelPresets.find((item) => item.label === label);
if (!preset) return;
Object.assign(
modelForm,
{
apiVersion: "",
authType: "bearer",
temperature: 0.2,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
responseFormat: "",
streamEnabled: 1,
},
preset,
);
}
function editModel(row: ModelItem) {
editingModelId.value = row.id;
Object.assign(modelForm, {
provider: row.provider,
displayName: row.displayName ?? "",
apiType: row.apiType,
modelName: row.modelName,
baseUrl: row.baseUrl ?? "",
apiUrl: row.apiUrl,
apiKey: "",
authType: row.authType,
apiVersion: row.apiVersion ?? "",
temperature: row.temperature ?? null,
topP: row.topP ?? null,
topK: row.topK ?? null,
presencePenalty: row.presencePenalty ?? null,
frequencyPenalty: row.frequencyPenalty ?? null,
maxToken: row.maxToken ?? 1024,
contextWindow: row.contextWindow ?? null,
streamEnabled: row.streamEnabled,
responseFormat: row.responseFormat ?? "",
extraParams: row.extraParams ?? "",
remark: row.remark ?? "",
timeoutSecond: row.timeoutSecond,
});
}
function resetModelForm() {
editingModelId.value = null;
Object.assign(modelForm, {
provider: "openai",
displayName: "",
apiType: "openai_compatible",
modelName: "",
baseUrl: "https://api.openai.com/v1",
apiUrl: "",
apiKey: "",
authType: "bearer",
apiVersion: "",
temperature: 0.2,
topP: null,
topK: null,
presencePenalty: null,
frequencyPenalty: null,
maxToken: 1024,
contextWindow: null,
streamEnabled: 1,
responseFormat: "",
extraParams: "",
remark: "",
timeoutSecond: 30,
});
}
function validateModelForm() {
if (!modelForm.apiUrl && !modelForm.baseUrl) {
ElMessage.error("请填写 Base URL 或 API URL");
return false;
}
if (!editingModelId.value && !modelForm.apiKey) {
ElMessage.error("新增模型必须填写 API Key");
return false;
}
if (modelForm.extraParams.trim()) {
try {
const extraParams = JSON.parse(modelForm.extraParams);
if (!extraParams || Array.isArray(extraParams) || typeof extraParams !== "object") {
throw new Error("高级 JSON 参数必须是对象");
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "高级 JSON 参数不是合法 JSON");
return false;
}
}
return true;
}
async function saveConfig() {
await api.saveConfig({ ...configForm });
ElMessage.success("配置已保存");
@@ -299,21 +515,117 @@ function buildChatQuery() {
</template>
<template v-if="activeMenu === 'models'">
<div class="page-head"><h2>模型管理</h2><p>同一时间只启用一个模型</p></div>
<el-form class="model-form" label-position="top" :model="modelForm">
<el-input v-model="modelForm.provider" placeholder="Provider" />
<el-input v-model="modelForm.modelName" placeholder="模型名" />
<el-input v-model="modelForm.apiUrl" placeholder="API URL" />
<el-input v-model="modelForm.apiKey" placeholder="API Key" show-password />
<el-button type="primary" @click="saveModel">新增模型</el-button>
<div class="page-head inline">
<div><h2>模型管理</h2><p>支持 OpenAI 兼容Anthropic MessagesGemini generateContent 和常见第三方 LLM</p></div>
<el-button @click="resetModelForm">清空表单</el-button>
</div>
<el-form class="model-config-panel" label-position="top" :model="modelForm">
<div class="model-config-grid">
<el-form-item label="供应商预设">
<el-select placeholder="选择预设" clearable @change="applyModelPreset">
<el-option v-for="preset in modelPresets" :key="preset.label" :label="preset.label" :value="preset.label" />
</el-select>
</el-form-item>
<el-form-item label="供应商标识">
<el-input v-model="modelForm.provider" placeholder="openai / deepseek / qwen" />
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="modelForm.displayName" placeholder="例如DeepSeek V3 生产" />
</el-form-item>
<el-form-item label="协议类型">
<el-select v-model="modelForm.apiType">
<el-option label="OpenAI 兼容" value="openai_compatible" />
<el-option label="Anthropic Messages" value="anthropic_messages" />
<el-option label="Gemini generateContent" value="gemini_generate_content" />
</el-select>
</el-form-item>
<el-form-item label="模型名">
<el-input v-model="modelForm.modelName" placeholder="gpt-4.1 / deepseek-chat / claude..." />
</el-form-item>
<el-form-item label="Base URL">
<el-input v-model="modelForm.baseUrl" placeholder="https://api.example.com/v1" />
</el-form-item>
<el-form-item label="API URL可覆盖">
<el-input v-model="modelForm.apiUrl" placeholder="留空则按协议自动拼接" />
</el-form-item>
<el-form-item label="鉴权方式">
<el-select v-model="modelForm.authType">
<el-option label="Bearer Token" value="bearer" />
<el-option label="x-api-key" value="api_key" />
</el-select>
</el-form-item>
<el-form-item label="API Key">
<el-input v-model="modelForm.apiKey" placeholder="保存时会覆盖旧 Key" show-password />
</el-form-item>
<el-form-item label="API Version">
<el-input v-model="modelForm.apiVersion" placeholder="Anthropic 默认 2023-06-01" />
</el-form-item>
<el-form-item label="Temperature">
<el-input-number v-model="modelForm.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Top P">
<el-input-number v-model="modelForm.topP" :min="0" :max="1" :step="0.05" />
</el-form-item>
<el-form-item label="Top K">
<el-input-number v-model="modelForm.topK" :min="1" :max="1000" />
</el-form-item>
<el-form-item label="Max Tokens">
<el-input-number v-model="modelForm.maxToken" :min="1" :max="100000" />
</el-form-item>
<el-form-item label="上下文窗口">
<el-input-number v-model="modelForm.contextWindow" :min="1" :max="2000000" />
</el-form-item>
<el-form-item label="Presence Penalty">
<el-input-number v-model="modelForm.presencePenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="Frequency Penalty">
<el-input-number v-model="modelForm.frequencyPenalty" :min="-2" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="流式输出">
<el-switch v-model="modelForm.streamEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="响应格式">
<el-select v-model="modelForm.responseFormat" clearable>
<el-option label="默认文本" value="" />
<el-option label="JSON Object" value="json_object" />
</el-select>
</el-form-item>
<el-form-item label="超时秒数">
<el-input-number v-model="modelForm.timeoutSecond" :min="1" :max="300" />
</el-form-item>
</div>
<el-form-item label="高级 JSON 参数">
<el-input
v-model="modelForm.extraParams"
type="textarea"
:rows="4"
placeholder='例如:{"stop":["###"],"seed":42}'
/>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="modelForm.remark" placeholder="用途、额度、注意事项等" />
</el-form-item>
<div class="actions">
<el-button type="primary" @click="saveModel">{{ editingModelId ? "保存模型" : "新增模型" }}</el-button>
<el-button @click="resetModelForm">取消编辑</el-button>
</div>
</el-form>
<el-table :data="models" stripe>
<el-table-column prop="displayName" label="显示名称" width="170" />
<el-table-column prop="provider" label="Provider" />
<el-table-column prop="apiType" label="协议" width="170" />
<el-table-column prop="modelName" label="模型" />
<el-table-column prop="apiUrl" label="API URL" />
<el-table-column prop="baseUrl" label="Base URL" min-width="220" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" />
<el-table-column prop="topP" label="Top P" width="80" />
<el-table-column prop="maxToken" label="Max" width="90" />
<el-table-column prop="enabled" label="启用" width="90" />
<el-table-column label="操作" width="110">
<template #default="{ row }"><el-button size="small" @click="enableModel(row.id)">启用</el-button></template>
<el-table-column label="操作" width="210" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editModel(row)">编辑</el-button>
<el-button size="small" @click="testModel(row.id)">测试</el-button>
<el-button size="small" type="primary" @click="enableModel(row.id)">启用</el-button>
</template>
</el-table-column>
</el-table>
</template>

View File

@@ -86,8 +86,15 @@ export const api = {
models: () => request<ModelItem[]>("/admin/model/list"),
createModel: (payload: Record<string, unknown>) =>
request<ModelItem>("/admin/model", { method: "POST", body: JSON.stringify(payload) }),
updateModel: (id: number, payload: Record<string, unknown>) =>
request<ModelItem>(`/admin/model/${id}`, { method: "PUT", body: JSON.stringify(payload) }),
enableModel: (modelId: number) =>
request<null>("/admin/model/enable", { method: "POST", body: JSON.stringify({ modelId }) }),
testModel: (modelId: number) =>
request<{ ok: boolean; message: string; answer: string }>(`/admin/model/${modelId}/test`, {
method: "POST",
body: "{}",
}),
configs: () => request<Record<string, unknown>[]>("/admin/config"),
saveConfig: (payload: Record<string, unknown>) =>
request<Record<string, unknown>>("/admin/config", { method: "PUT", body: JSON.stringify(payload) }),

View File

@@ -185,6 +185,30 @@ textarea {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.model-config-panel {
margin-bottom: 18px;
padding: 18px;
border: 1px solid #dfe8e5;
border-radius: 8px;
background: #ffffff;
}
.model-config-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 12px 14px;
}
.model-config-panel .el-form-item {
margin-bottom: 12px;
}
.model-config-panel .el-select,
.model-config-panel .el-input-number,
.model-config-panel .el-input {
width: 100%;
}
.actions {
margin-top: 12px;
}

View File

@@ -42,11 +42,25 @@ export interface KnowledgeItem {
export interface ModelItem {
id: number;
provider: string;
displayName?: string | null;
apiType: string;
modelName: string;
baseUrl?: string | null;
apiUrl: string;
apiKeyMasked: string;
authType: string;
apiVersion?: string | null;
temperature?: number | null;
topP?: number | null;
topK?: number | null;
presencePenalty?: number | null;
frequencyPenalty?: number | null;
maxToken?: number | null;
contextWindow?: number | null;
streamEnabled: number;
responseFormat?: string | null;
extraParams?: string | null;
remark?: string | null;
timeoutSecond: number;
enabled: number;
}

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0002_expand_model_config"
down_revision = "0001_initial_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("sys_model", sa.Column("display_name", sa.String(length=100), nullable=True))
op.add_column(
"sys_model",
sa.Column("api_type", sa.String(length=50), nullable=False, server_default="openai_compatible"),
)
op.add_column("sys_model", sa.Column("base_url", sa.String(length=255), nullable=True))
op.add_column(
"sys_model",
sa.Column("auth_type", sa.String(length=30), nullable=False, server_default="bearer"),
)
op.add_column("sys_model", sa.Column("api_version", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("top_p", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("top_k", sa.Integer(), nullable=True))
op.add_column("sys_model", sa.Column("presence_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("frequency_penalty", sa.Numeric(4, 2), nullable=True))
op.add_column("sys_model", sa.Column("context_window", sa.Integer(), nullable=True))
op.add_column(
"sys_model",
sa.Column("stream_enabled", sa.Integer(), nullable=False, server_default="1"),
)
op.add_column("sys_model", sa.Column("response_format", sa.String(length=50), nullable=True))
op.add_column("sys_model", sa.Column("extra_params", sa.Text(), nullable=True))
op.add_column("sys_model", sa.Column("remark", sa.String(length=255), nullable=True))
op.alter_column("sys_model", "api_type", server_default=None)
op.alter_column("sys_model", "auth_type", server_default=None)
op.alter_column("sys_model", "stream_enabled", server_default=None)
def downgrade() -> None:
op.drop_column("sys_model", "remark")
op.drop_column("sys_model", "extra_params")
op.drop_column("sys_model", "response_format")
op.drop_column("sys_model", "stream_enabled")
op.drop_column("sys_model", "context_window")
op.drop_column("sys_model", "frequency_penalty")
op.drop_column("sys_model", "presence_penalty")
op.drop_column("sys_model", "top_k")
op.drop_column("sys_model", "top_p")
op.drop_column("sys_model", "api_version")
op.drop_column("sys_model", "auth_type")
op.drop_column("sys_model", "base_url")
op.drop_column("sys_model", "api_type")
op.drop_column("sys_model", "display_name")

View File

@@ -11,6 +11,7 @@ from app.models.admin import Admin
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
from app.schemas.admin import EnableModelRequest, ModelSaveRequest, PromptSaveRequest, SystemConfigSaveRequest
from app.services.admin_service import OperationLogService
from app.services.model_service import ModelClientService
router = APIRouter()
@@ -59,13 +60,29 @@ def create_model(
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
if not payload.apiKey:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API Key 不能为空")
model = ModelConfig(
provider=payload.provider,
display_name=payload.displayName,
api_type=payload.apiType,
model_name=payload.modelName,
base_url=payload.baseUrl,
api_url=payload.apiUrl,
api_key=payload.apiKey,
auth_type=payload.authType,
api_version=payload.apiVersion,
temperature=payload.temperature,
top_p=payload.topP,
top_k=payload.topK,
presence_penalty=payload.presencePenalty,
frequency_penalty=payload.frequencyPenalty,
max_token=payload.maxToken,
context_window=payload.contextWindow,
stream_enabled=payload.streamEnabled,
response_format=payload.responseFormat,
extra_params=payload.extraParams,
remark=payload.remark,
timeout_second=payload.timeoutSecond,
enabled=0,
)
@@ -77,6 +94,46 @@ def create_model(
return api_success(_model_dict(model))
@router.put("/model/{model_id}")
def update_model(
model_id: int,
payload: ModelSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
model.provider = payload.provider
model.display_name = payload.displayName
model.api_type = payload.apiType
model.model_name = payload.modelName
model.base_url = payload.baseUrl
model.api_url = payload.apiUrl
if payload.apiKey:
model.api_key = payload.apiKey
model.auth_type = payload.authType
model.api_version = payload.apiVersion
model.temperature = payload.temperature
model.top_p = payload.topP
model.top_k = payload.topK
model.presence_penalty = payload.presencePenalty
model.frequency_penalty = payload.frequencyPenalty
model.max_token = payload.maxToken
model.context_window = payload.contextWindow
model.stream_enabled = payload.streamEnabled
model.response_format = payload.responseFormat
model.extra_params = payload.extraParams
model.remark = payload.remark
model.timeout_second = payload.timeoutSecond
db.add(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="update", target_id=model.id)
db.commit()
db.refresh(model)
return api_success(_model_dict(model))
@router.post("/model/enable")
def enable_model(
payload: EnableModelRequest,
@@ -109,6 +166,21 @@ def delete_model(
return api_success()
@router.post("/model/{model_id}/test")
def test_model(
model_id: int,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
model = db.get(ModelConfig, model_id)
if model is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模型不存在")
result = ModelClientService.test_model(model)
OperationLogService.write(db, admin_id=current_admin.id, module="model", action="test", target_id=model.id)
db.commit()
return api_success(result)
@router.get("/config")
def list_config(db: Session = Depends(get_db), current_admin: Admin = Depends(get_current_admin)) -> dict:
configs = db.scalars(select(SystemConfig).order_by(SystemConfig.config_key.asc())).all()
@@ -139,11 +211,25 @@ def _model_dict(model: ModelConfig) -> dict:
return {
"id": model.id,
"provider": model.provider,
"displayName": model.display_name,
"apiType": model.api_type,
"modelName": model.model_name,
"baseUrl": model.base_url,
"apiUrl": model.api_url,
"apiKeyMasked": "******" if model.api_key else "",
"authType": model.auth_type,
"apiVersion": model.api_version,
"temperature": float(model.temperature) if model.temperature is not None else None,
"topP": float(model.top_p) if model.top_p is not None else None,
"topK": model.top_k,
"presencePenalty": float(model.presence_penalty) if model.presence_penalty is not None else None,
"frequencyPenalty": float(model.frequency_penalty) if model.frequency_penalty is not None else None,
"maxToken": model.max_token,
"contextWindow": model.context_window,
"streamEnabled": model.stream_enabled,
"responseFormat": model.response_format,
"extraParams": model.extra_params,
"remark": model.remark,
"timeoutSecond": model.timeout_second,
"enabled": model.enabled,
}

View File

@@ -23,11 +23,25 @@ class ModelConfig(Base):
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
api_type: Mapped[str] = mapped_column(String(50), default="openai_compatible", nullable=False)
model_name: Mapped[str] = mapped_column(String(100), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
api_url: Mapped[str] = mapped_column(String(255), nullable=False)
api_key: Mapped[str] = mapped_column(Text, nullable=False)
auth_type: Mapped[str] = mapped_column(String(30), default="bearer", nullable=False)
api_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
temperature: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
top_p: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
top_k: Mapped[int | None] = mapped_column(Integer, nullable=True)
presence_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
frequency_penalty: Mapped[Decimal | None] = mapped_column(Numeric(4, 2), nullable=True)
max_token: Mapped[int | None] = mapped_column(Integer, nullable=True)
context_window: Mapped[int | None] = mapped_column(Integer, nullable=True)
stream_enabled: Mapped[int] = mapped_column(default=1, nullable=False)
response_format: Mapped[str | None] = mapped_column(String(50), nullable=True)
extra_params: Mapped[str | None] = mapped_column(Text, nullable=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
timeout_second: Mapped[int] = mapped_column(Integer, default=30, nullable=False)
enabled: Mapped[int] = mapped_column(default=0, nullable=False)

View File

@@ -53,11 +53,25 @@ class PromptSaveRequest(BaseModel):
class ModelSaveRequest(BaseModel):
provider: str = Field(min_length=1, max_length=50)
displayName: str | None = Field(default=None, max_length=100)
apiType: str = Field(default="openai_compatible", max_length=50)
modelName: str = Field(min_length=1, max_length=100)
apiUrl: str = Field(min_length=1, max_length=255)
apiKey: str = Field(min_length=1)
baseUrl: str | None = Field(default=None, max_length=255)
apiUrl: str = Field(default="", max_length=255)
apiKey: str = ""
authType: str = Field(default="bearer", max_length=30)
apiVersion: str | None = Field(default=None, max_length=50)
temperature: float | None = Field(default=0.2, ge=0, le=2)
topP: float | None = Field(default=None, ge=0, le=1)
topK: int | None = Field(default=None, ge=1, le=1000)
presencePenalty: float | None = Field(default=None, ge=-2, le=2)
frequencyPenalty: float | None = Field(default=None, ge=-2, le=2)
maxToken: int | None = Field(default=1024, ge=1, le=100000)
contextWindow: int | None = Field(default=None, ge=1, le=2000000)
streamEnabled: int = Field(default=1, ge=0, le=1)
responseFormat: str | None = Field(default=None, max_length=50)
extraParams: str | None = None
remark: str | None = Field(default=None, max_length=255)
timeoutSecond: int = Field(default=30, ge=1, le=300)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
from sqlalchemy import select
@@ -37,7 +39,7 @@ class ModelClientService:
answer = _mock_answer(rag_result)
else:
model_name = model.model_name
answer = _call_openai_compatible_model(model, rag_result)
answer = _call_configured_model(model, rag_result)
return ModelCompletion(
answer=answer,
@@ -47,6 +49,16 @@ class ModelClientService:
output_token=_rough_token_count(answer),
)
@staticmethod
def test_model(model: ModelConfig) -> dict[str, Any]:
test_prompt = "请回复 OK用于测试模型配置是否可用。"
rag_result = RagResult(question=test_prompt, knowledge_scopes=[], chunks=[], prompt=test_prompt)
try:
answer = _call_configured_model(model, rag_result, allow_no_hit=True)
except ExternalServiceError as exc:
return {"ok": False, "message": str(exc), "answer": ""}
return {"ok": True, "message": "模型配置可用", "answer": answer[:500]}
def _mock_answer(rag_result: RagResult) -> str:
if not rag_result.is_hit:
@@ -67,40 +79,116 @@ def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
if not rag_result.is_hit:
def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_no_hit: bool = False) -> str:
if not allow_no_hit and not rag_result.is_hit:
return NO_HIT_ANSWER
if not model.api_url or not model.api_key:
raise ExternalServiceError("模型 API URL 或 API Key 未配置", provider="model")
if not (model.api_url or model.base_url) or not model.api_key:
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
api_type = model.api_type or "openai_compatible"
if api_type == "anthropic_messages":
return _call_anthropic_messages(model, rag_result)
if api_type == "gemini_generate_content":
return _call_gemini_generate_content(model, rag_result)
return _call_openai_compatible_model(model, rag_result)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"messages": [
{"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"},
{"role": "user", "content": rag_result.prompt},
],
"temperature": float(model.temperature) if model.temperature is not None else 0.2,
"max_tokens": model.max_token or 1024,
"stream": False,
"max_tokens": model.max_token or 1024,
}
headers = {
"Authorization": f"Bearer {model.api_key}",
"Content-Type": "application/json",
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
_put_if_not_none(payload, "presence_penalty", _decimal_to_float(model.presence_penalty))
_put_if_not_none(payload, "frequency_penalty", _decimal_to_float(model.frequency_penalty))
if model.response_format == "json_object":
payload["response_format"] = {"type": "json_object"}
payload.update(_load_extra_params(model.extra_params))
headers = _auth_headers(model)
try:
response = httpx.post(
model.api_url,
_resolve_openai_endpoint(model),
json=payload,
headers=headers,
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_answer(response.json())
return _extract_openai_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _extract_answer(data: dict[str, Any]) -> str:
def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": "你是企业知识库问答助手,只能基于已提供的知识片段回答。",
"messages": [{"role": "user", "content": rag_result.prompt}],
"stream": False,
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
_put_if_not_none(payload, "top_k", model.top_k)
payload.update(_load_extra_params(model.extra_params))
headers = {
"x-api-key": model.api_key,
"anthropic-version": model.api_version or "2023-06-01",
"Content-Type": "application/json",
}
try:
response = httpx.post(
_resolve_anthropic_endpoint(model),
json=payload,
headers=headers,
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_anthropic_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) -> str:
generation_config: dict[str, Any] = {}
_put_if_not_none(generation_config, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(generation_config, "topP", _decimal_to_float(model.top_p))
_put_if_not_none(generation_config, "topK", model.top_k)
_put_if_not_none(generation_config, "maxOutputTokens", model.max_token)
if model.response_format == "json_object":
generation_config["responseMimeType"] = "application/json"
payload = {
"systemInstruction": {
"parts": [{"text": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}]
},
"contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}],
}
if generation_config:
payload["generationConfig"] = generation_config
payload.update(_load_extra_params(model.extra_params))
try:
response = httpx.post(
_resolve_gemini_endpoint(model),
json=payload,
headers={"Content-Type": "application/json"},
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_gemini_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _extract_openai_answer(data: dict[str, Any]) -> str:
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("模型响应缺少 choices")
@@ -117,3 +205,80 @@ def _extract_answer(data: dict[str, Any]) -> str:
return str(first_choice["text"])
raise ValueError("模型响应缺少回答内容")
def _extract_anthropic_answer(data: dict[str, Any]) -> str:
content = data.get("content")
if not isinstance(content, list):
raise ValueError("Anthropic 响应缺少 content")
texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
answer = "".join(texts).strip()
if not answer:
raise ValueError("Anthropic 响应缺少文本内容")
return answer
def _extract_gemini_answer(data: dict[str, Any]) -> str:
candidates = data.get("candidates")
if not isinstance(candidates, list) or not candidates:
raise ValueError("Gemini 响应缺少 candidates")
parts = candidates[0].get("content", {}).get("parts", [])
texts = [item.get("text", "") for item in parts if isinstance(item, dict)]
answer = "".join(texts).strip()
if not answer:
raise ValueError("Gemini 响应缺少文本内容")
return answer
def _resolve_openai_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "").rstrip("/")
return f"{base_url}/chat/completions"
def _resolve_anthropic_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "https://api.anthropic.com").rstrip("/")
return f"{base_url}/v1/messages"
def _resolve_gemini_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
query = urlencode({"key": model.api_key})
return f"{base_url}/models/{model.model_name}:generateContent?{query}"
def _auth_headers(model: ModelConfig) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if model.auth_type == "api_key":
headers["x-api-key"] = model.api_key
else:
headers["Authorization"] = f"Bearer {model.api_key}"
if model.api_version:
headers["api-version"] = model.api_version
return headers
def _decimal_to_float(value: Any) -> float | None:
return float(value) if value is not None else None
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
target[key] = value
def _load_extra_params(extra_params: str | None) -> dict[str, Any]:
if not extra_params:
return {}
try:
data = json.loads(extra_params)
except json.JSONDecodeError as exc:
raise ExternalServiceError(f"模型高级参数不是合法 JSON{exc}", provider="model") from exc
if not isinstance(data, dict):
raise ExternalServiceError("模型高级参数必须是 JSON 对象", provider="model")
return data