feat(llm): 端点目录支持一行一URL输入(归一化+中文校验提示);账号APIKEY可配置(加密存储/掩码守卫/空=不改)+机构自动注入+endpoint_ids逗号兼容;CRUD接模块helper薄封装

This commit is contained in:
yumoqing 2026-09-03 17:36:57 +08:00
parent 82470ae7ab
commit 3c3d6a35d3
9 changed files with 130 additions and 52 deletions

View File

@ -3,6 +3,7 @@
"title": "供应商账号",
"params": {
"sortby": "name",
"logined_userorgid": "org_id",
"browserfields": {
"exclouded": [
"id",
@ -20,21 +21,28 @@
"dataurl": "{{entire_url('../api/get_llm_status_options.dspy')}}",
"valueField": "value",
"textField": "text"
},
"api_key": {
"uitype": "password",
"placeholder": "供应商 API Key加密存储编辑时留空=不修改)"
},
"endpoint_ids": {
"placeholder": "选用的端点下标从0开始对应供应商端点目录行号逗号分隔如 0,1"
}
}
},
"editexclouded": [
"id",
"api_key",
"org_id",
"balance",
"total_recharge",
"created_at",
"updated_at"
],
"editable": {
"new_data_url": "{{entire_url('./add_llm_account.dspy')}}",
"update_data_url": "{{entire_url('./update_llm_account.dspy')}}",
"delete_data_url": "{{entire_url('./delete_llm_account.dspy')}}"
"new_data_url": "{{entire_url('../api/add_llm_account.dspy')}}",
"update_data_url": "{{entire_url('../api/update_llm_account.dspy')}}",
"delete_data_url": "{{entire_url('../api/delete_llm_account.dspy')}}"
},
"confidential_fields": [
"api_key"

View File

@ -22,7 +22,7 @@
"textField": "text"
},
"endpoints": {
"placeholder": "[{\"base_url\": \"https://xxx/v1\", \"region\": \"domestic\", \"timeout\": 60}]"
"placeholder": "一行一个URL可选追加 region=domestic|international timeout=秒。示例:\nhttps://dashscope.aliyuncs.com/compatible-mode/v1\nhttps://api.example.com/v1 region=international timeout=30\n也兼容 JSON 数组写法)"
}
}
},

View File

@ -43,6 +43,76 @@ def _get_sor():
return DBPools(), _dbname()
# ────────────────────── 端点目录输入归一化 ──────────────────────
def normalize_endpoints(text):
"""端点目录输入归一化:支持「一行一个 URL」或 JSON 数组两种写法。
行格式URL [region=domestic|international] [timeout=]例如
https://dashscope.aliyuncs.com/compatible-mode/v1
https://api.example.com/v1 region=international timeout=30
缺省 region=domestictimeout=60存储格式恒为 JSON 数组
[{base_url, region, timeout}]治理链按此读取
非法输入抛 ValueError中文提示直接展示给用户
"""
s = (text or '').strip()
if not s:
return ''
if s.startswith('['):
eps = json.loads(s)
if not isinstance(eps, list):
raise ValueError('endpoints 必须是 JSON 数组')
else:
eps = []
for ln_no, ln in enumerate([l.strip() for l in s.splitlines()], 1):
if not ln:
continue
parts = ln.split()
url = parts[0].rstrip('/')
if not (url.startswith('http://') or url.startswith('https://')):
raise ValueError('%d 行不是合法 URL须以 http(s):// 开头):%s' % (ln_no, parts[0]))
ep = {'base_url': url, 'region': 'domestic', 'timeout': 60}
for kv in parts[1:]:
if '=' not in kv:
raise ValueError('%d 行选项 %s 应为 key=value 形式region= / timeout=' % (ln_no, kv))
k, v = kv.split('=', 1)
if k == 'region':
if v not in ('domestic', 'international'):
raise ValueError('%d 行 region 只能是 domestic 或 international' % ln_no)
ep['region'] = v
elif k == 'timeout':
ep['timeout'] = int(v)
else:
raise ValueError('%d 行未知选项 %s(支持 region= / timeout=' % (ln_no, k))
eps.append(ep)
for i, ep in enumerate(eps):
if not isinstance(ep, dict) or not (ep.get('base_url') or '').strip():
raise ValueError('端点 #%d 缺少 base_url' % (i + 1))
return json.dumps(eps, ensure_ascii=False)
def _norm_endpoint_ids(raw):
"""endpoint_ids 归一化JSON 数组或逗号分隔数字均可,返回 JSON 数组串。"""
if isinstance(raw, (list, dict)):
raw = json.dumps(raw, ensure_ascii=False)
raw = (raw or '').strip()
if not raw:
return ''
if raw.startswith('['):
idxs = json.loads(raw)
else:
idxs = [x.strip() for x in raw.split(',') if x.strip()]
if not isinstance(idxs, list):
raise ValueError('endpoint_ids 必须是 JSON 数组')
for i in idxs:
if not (isinstance(i, int) or (isinstance(i, str) and i.lstrip('-').isdigit())):
raise ValueError('endpoint_ids 元素必须是端点下标(数字)')
return json.dumps([int(i) for i in idxs], ensure_ascii=False)
MASK = '******' # 列表脱敏占位;编辑表单原样提交视为「不修改」
# ────────────────────── CRUD供生成的 CRUD 页面调用) ──────────────────────
def _clean(params_kw):
@ -56,17 +126,7 @@ async def create_llm_vendor(params_kw):
result = {'success': False, 'message': ''}
try:
data = _clean(params_kw)
endpoints = data.get('endpoints', '') or ''
if isinstance(endpoints, (list, dict)):
endpoints = json.dumps(endpoints, ensure_ascii=False)
if endpoints.strip():
eps = json.loads(endpoints)
if not isinstance(eps, list):
raise ValueError('endpoints 必须是 JSON 数组')
for i, ep in enumerate(eps):
if not isinstance(ep, dict) or not (ep.get('base_url') or '').strip():
raise ValueError('端点 #%d 缺少 base_url' % (i + 1))
data['endpoints'] = endpoints
data['endpoints'] = normalize_endpoints(data.get('endpoints', ''))
data['id'] = getID()
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
@ -83,17 +143,7 @@ async def update_llm_vendor(params_kw):
try:
data = _clean(params_kw)
if 'endpoints' in data:
endpoints = data.get('endpoints', '') or ''
if isinstance(endpoints, (list, dict)):
endpoints = json.dumps(endpoints, ensure_ascii=False)
if endpoints.strip():
eps = json.loads(endpoints)
if not isinstance(eps, list):
raise ValueError('endpoints 必须是 JSON 数组')
for i, ep in enumerate(eps):
if not isinstance(ep, dict) or not (ep.get('base_url') or '').strip():
raise ValueError('端点 #%d 缺少 base_url' % (i + 1))
data['endpoints'] = endpoints
data['endpoints'] = normalize_endpoints(data.get('endpoints', ''))
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
await sor.U('llm_vendor', data)
@ -137,18 +187,13 @@ async def create_llm_account(params_kw):
vid = data.get('vendor_id', '')
if not vid:
raise ValueError('必须选择供应商')
# api_key 加密存储
if data.get('api_key'):
# api_key 加密存储(掩码/空 = 不设置)
if data.get('api_key') and data['api_key'] != MASK:
data['api_key'] = encrypt_api_key(data['api_key'])
# 端点下标校验
idxs_raw = data.get('endpoint_ids', '') or ''
if isinstance(idxs_raw, (list, dict)):
idxs_raw = json.dumps(idxs_raw, ensure_ascii=False)
idxs = []
if idxs_raw.strip():
idxs = json.loads(idxs_raw)
if not isinstance(idxs, list):
raise ValueError('endpoint_ids 必须是 JSON 数组')
else:
data.pop('api_key', None)
# 端点下标校验JSON 或逗号分隔均可)
data['endpoint_ids'] = _norm_endpoint_ids(data.get('endpoint_ids', ''))
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe("SELECT endpoints FROM llm_vendor WHERE id=${v}$", {"v": vid})
@ -160,12 +205,9 @@ async def create_llm_account(params_kw):
eps = json.loads(getattr(recs[0], 'endpoints', '') or '') or []
except Exception:
eps = []
for i in idxs:
if not isinstance(i, int) and not (isinstance(i, str) and i.isdigit()):
raise ValueError('endpoint_ids 元素必须是端点下标(数字)')
for i in json.loads(data['endpoint_ids'] or '[]'):
if int(i) < 0 or int(i) >= len(eps):
raise ValueError('端点下标 %s 超出供应商端点目录(共 %d 个端点)' % (i, len(eps)))
data['endpoint_ids'] = json.dumps([int(i) for i in idxs], ensure_ascii=False) if idxs else ''
data['id'] = getID()
async with db.sqlorContext(dbname) as sor:
await sor.C('llm_account', data)
@ -181,20 +223,12 @@ async def update_llm_account(params_kw):
result = {'success': False, 'message': ''}
try:
data = _clean(params_kw)
if data.get('api_key'):
if data.get('api_key') and data['api_key'] != MASK:
data['api_key'] = encrypt_api_key(data['api_key'])
else:
data.pop('api_key', None)
if 'endpoint_ids' in data:
idxs_raw = data.get('endpoint_ids', '') or ''
if isinstance(idxs_raw, (list, dict)):
idxs_raw = json.dumps(idxs_raw, ensure_ascii=False)
idxs = []
if idxs_raw.strip():
idxs = json.loads(idxs_raw)
if not isinstance(idxs, list):
raise ValueError('endpoint_ids 必须是 JSON 数组')
data['endpoint_ids'] = json.dumps([int(i) for i in idxs], ensure_ascii=False) if idxs else ''
data['endpoint_ids'] = _norm_endpoint_ids(data.get('endpoint_ids', ''))
db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor:
await sor.U('llm_account', data)

View File

@ -0,0 +1,8 @@
# add_llm_account.dspy — 薄封装:走模块 helperapi_key AES 加密+端点下标校验),机构自动注入登录用户
org = await get_userorgid()
pk = dict(params_kw or {})
pk['org_id'] = org or '0'
r = json.loads(await create_llm_account(pk))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,8 @@
# add_llm_vendor.dspy — 薄封装:走模块 helper端点归一化「一行一URL」+校验),机构自动注入登录用户
org = await get_userorgid()
pk = dict(params_kw or {})
pk['org_id'] = org or '0'
r = json.loads(await create_llm_vendor(pk))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# delete_llm_account.dspy — 薄封装:走模块 helper被模型引用时禁止删除
r = json.loads(await delete_llm_account(dict(params_kw or {})))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# delete_llm_vendor.dspy — 薄封装:走模块 helper有账号/模型引用时禁止删除,防孤儿)
r = json.loads(await delete_llm_vendor(dict(params_kw or {})))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# update_llm_account.dspy — 薄封装:走模块 helperapi_key 非空才重新加密,空=不改)
r = json.loads(await update_llm_account(dict(params_kw or {})))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}

View File

@ -0,0 +1,5 @@
# update_llm_vendor.dspy — 薄封装:走模块 helper端点归一化「一行一URL」+校验)
r = json.loads(await update_llm_vendor(dict(params_kw or {})))
if r.get('success'):
return {"widgettype": "Message", "options": {"title": "Success", "message": r.get('message') or 'ok', "timeout": 3, "cwidth": 16, "cheight": 9}}
return {"widgettype": "Error", "options": {"title": "Error", "message": r.get('message') or 'failed', "timeout": 3, "cwidth": 16, "cheight": 9}}