From 3c3d6a35d33ccbc9e39e6ace60e15097030c5612 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 3 Sep 2026 17:36:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E7=AB=AF=E7=82=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=94=AF=E6=8C=81=E4=B8=80=E8=A1=8C=E4=B8=80URL?= =?UTF-8?q?=E8=BE=93=E5=85=A5(=E5=BD=92=E4=B8=80=E5=8C=96+=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=A0=A1=E9=AA=8C=E6=8F=90=E7=A4=BA);=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7APIKEY=E5=8F=AF=E9=85=8D=E7=BD=AE(=E5=8A=A0=E5=AF=86?= =?UTF-8?q?=E5=AD=98=E5=82=A8/=E6=8E=A9=E7=A0=81=E5=AE=88=E5=8D=AB/?= =?UTF-8?q?=E7=A9=BA=3D=E4=B8=8D=E6=94=B9)+=E6=9C=BA=E6=9E=84=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B3=A8=E5=85=A5+endpoint=5Fids=E9=80=97=E5=8F=B7?= =?UTF-8?q?=E5=85=BC=E5=AE=B9;CRUD=E6=8E=A5=E6=A8=A1=E5=9D=97helper?= =?UTF-8?q?=E8=96=84=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- json/llm_account_list.json | 16 +++- json/llm_vendor_list.json | 2 +- pipeline_llm/init.py | 128 ++++++++++++++++++---------- wwwroot/api/add_llm_account.dspy | 8 ++ wwwroot/api/add_llm_vendor.dspy | 8 ++ wwwroot/api/delete_llm_account.dspy | 5 ++ wwwroot/api/delete_llm_vendor.dspy | 5 ++ wwwroot/api/update_llm_account.dspy | 5 ++ wwwroot/api/update_llm_vendor.dspy | 5 ++ 9 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 wwwroot/api/add_llm_account.dspy create mode 100644 wwwroot/api/add_llm_vendor.dspy create mode 100644 wwwroot/api/delete_llm_account.dspy create mode 100644 wwwroot/api/delete_llm_vendor.dspy create mode 100644 wwwroot/api/update_llm_account.dspy create mode 100644 wwwroot/api/update_llm_vendor.dspy diff --git a/json/llm_account_list.json b/json/llm_account_list.json index d99cdbc..e6d4052 100644 --- a/json/llm_account_list.json +++ b/json/llm_account_list.json @@ -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" diff --git a/json/llm_vendor_list.json b/json/llm_vendor_list.json index 4cb341f..724b3b7 100644 --- a/json/llm_vendor_list.json +++ b/json/llm_vendor_list.json @@ -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 数组写法)" } } }, diff --git a/pipeline_llm/init.py b/pipeline_llm/init.py index 4ea8b25..c659185 100644 --- a/pipeline_llm/init.py +++ b/pipeline_llm/init.py @@ -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) diff --git a/wwwroot/api/add_llm_account.dspy b/wwwroot/api/add_llm_account.dspy new file mode 100644 index 0000000..c95392a --- /dev/null +++ b/wwwroot/api/add_llm_account.dspy @@ -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}} diff --git a/wwwroot/api/add_llm_vendor.dspy b/wwwroot/api/add_llm_vendor.dspy new file mode 100644 index 0000000..ff39e13 --- /dev/null +++ b/wwwroot/api/add_llm_vendor.dspy @@ -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}} diff --git a/wwwroot/api/delete_llm_account.dspy b/wwwroot/api/delete_llm_account.dspy new file mode 100644 index 0000000..19637b1 --- /dev/null +++ b/wwwroot/api/delete_llm_account.dspy @@ -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}} diff --git a/wwwroot/api/delete_llm_vendor.dspy b/wwwroot/api/delete_llm_vendor.dspy new file mode 100644 index 0000000..1a35aba --- /dev/null +++ b/wwwroot/api/delete_llm_vendor.dspy @@ -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}} diff --git a/wwwroot/api/update_llm_account.dspy b/wwwroot/api/update_llm_account.dspy new file mode 100644 index 0000000..2320248 --- /dev/null +++ b/wwwroot/api/update_llm_account.dspy @@ -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}} diff --git a/wwwroot/api/update_llm_vendor.dspy b/wwwroot/api/update_llm_vendor.dspy new file mode 100644 index 0000000..0dc42d3 --- /dev/null +++ b/wwwroot/api/update_llm_vendor.dspy @@ -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}}