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

View File

@ -22,7 +22,7 @@
"textField": "text" "textField": "text"
}, },
"endpoints": { "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() 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=domestic、timeout=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 页面调用) ────────────────────── # ────────────────────── CRUD(供生成的 CRUD 页面调用) ──────────────────────
def _clean(params_kw): def _clean(params_kw):
@ -56,17 +126,7 @@ async def create_llm_vendor(params_kw):
result = {'success': False, 'message': ''} result = {'success': False, 'message': ''}
try: try:
data = _clean(params_kw) data = _clean(params_kw)
endpoints = data.get('endpoints', '') or '' data['endpoints'] = normalize_endpoints(data.get('endpoints', ''))
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['id'] = getID() data['id'] = getID()
db, dbname = _get_sor() db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
@ -83,17 +143,7 @@ async def update_llm_vendor(params_kw):
try: try:
data = _clean(params_kw) data = _clean(params_kw)
if 'endpoints' in data: if 'endpoints' in data:
endpoints = data.get('endpoints', '') or '' data['endpoints'] = normalize_endpoints(data.get('endpoints', ''))
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
db, dbname = _get_sor() db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
await sor.U('llm_vendor', data) await sor.U('llm_vendor', data)
@ -137,18 +187,13 @@ async def create_llm_account(params_kw):
vid = data.get('vendor_id', '') vid = data.get('vendor_id', '')
if not vid: if not vid:
raise ValueError('必须选择供应商') raise ValueError('必须选择供应商')
# api_key 加密存储 # api_key 加密存储(掩码/空 = 不设置)
if data.get('api_key'): if data.get('api_key') and data['api_key'] != MASK:
data['api_key'] = encrypt_api_key(data['api_key']) data['api_key'] = encrypt_api_key(data['api_key'])
# 端点下标校验 else:
idxs_raw = data.get('endpoint_ids', '') or '' data.pop('api_key', None)
if isinstance(idxs_raw, (list, dict)): # 端点下标校验(JSON 或逗号分隔均可)
idxs_raw = json.dumps(idxs_raw, ensure_ascii=False) data['endpoint_ids'] = _norm_endpoint_ids(data.get('endpoint_ids', ''))
idxs = []
if idxs_raw.strip():
idxs = json.loads(idxs_raw)
if not isinstance(idxs, list):
raise ValueError('endpoint_ids 必须是 JSON 数组')
db, dbname = _get_sor() db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
recs = await sor.sqlExe("SELECT endpoints FROM llm_vendor WHERE id=${v}$", {"v": vid}) 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 [] eps = json.loads(getattr(recs[0], 'endpoints', '') or '') or []
except Exception: except Exception:
eps = [] eps = []
for i in idxs: for i in json.loads(data['endpoint_ids'] or '[]'):
if not isinstance(i, int) and not (isinstance(i, str) and i.isdigit()):
raise ValueError('endpoint_ids 元素必须是端点下标(数字)')
if int(i) < 0 or int(i) >= len(eps): if int(i) < 0 or int(i) >= len(eps):
raise ValueError('端点下标 %s 超出供应商端点目录(共 %d 个端点)' % (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() data['id'] = getID()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
await sor.C('llm_account', data) await sor.C('llm_account', data)
@ -181,20 +223,12 @@ async def update_llm_account(params_kw):
result = {'success': False, 'message': ''} result = {'success': False, 'message': ''}
try: try:
data = _clean(params_kw) 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']) data['api_key'] = encrypt_api_key(data['api_key'])
else: else:
data.pop('api_key', None) data.pop('api_key', None)
if 'endpoint_ids' in data: if 'endpoint_ids' in data:
idxs_raw = data.get('endpoint_ids', '') or '' data['endpoint_ids'] = _norm_endpoint_ids(data.get('endpoint_ids', ''))
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 ''
db, dbname = _get_sor() db, dbname = _get_sor()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
await sor.U('llm_account', data) await sor.U('llm_account', data)

View File

@ -0,0 +1,8 @@
# add_llm_account.dspy — 薄封装:走模块 helper(api_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 — 薄封装:走模块 helper(api_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}}