feat(llm): 端点目录支持一行一URL输入(归一化+中文校验提示);账号APIKEY可配置(加密存储/掩码守卫/空=不改)+机构自动注入+endpoint_ids逗号兼容;CRUD接模块helper薄封装
This commit is contained in:
parent
82470ae7ab
commit
3c3d6a35d3
@ -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"
|
||||
|
||||
@ -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 数组写法)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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=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 页面调用) ──────────────────────
|
||||
|
||||
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)
|
||||
|
||||
8
wwwroot/api/add_llm_account.dspy
Normal file
8
wwwroot/api/add_llm_account.dspy
Normal 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}}
|
||||
8
wwwroot/api/add_llm_vendor.dspy
Normal file
8
wwwroot/api/add_llm_vendor.dspy
Normal 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}}
|
||||
5
wwwroot/api/delete_llm_account.dspy
Normal file
5
wwwroot/api/delete_llm_account.dspy
Normal 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}}
|
||||
5
wwwroot/api/delete_llm_vendor.dspy
Normal file
5
wwwroot/api/delete_llm_vendor.dspy
Normal 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}}
|
||||
5
wwwroot/api/update_llm_account.dspy
Normal file
5
wwwroot/api/update_llm_account.dspy
Normal 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}}
|
||||
5
wwwroot/api/update_llm_vendor.dspy
Normal file
5
wwwroot/api/update_llm_vendor.dspy
Normal 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}}
|
||||
Loading…
x
Reference in New Issue
Block a user