feat(llm): 端点目录支持一行一URL输入(归一化+中文校验提示);账号APIKEY可配置(加密存储/掩码守卫/空=不改)+机构自动注入+endpoint_ids逗号兼容;CRUD接模块helper薄封装
This commit is contained in:
parent
82470ae7ab
commit
3c3d6a35d3
@ -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"
|
||||||
|
|||||||
@ -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 数组写法)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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