From c819faef2d600f382bd0e075ad12779f3d4c22de Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 5 Sep 2026 13:07:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(async):=20=E5=BC=82=E6=AD=A5=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=89=A7=E8=A1=8C=E5=99=A8(=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E2=86=92=E8=BD=AE=E8=AF=A2query=5Fprofile=5Fids=E2=86=92?= =?UTF-8?q?=E5=8F=96=E7=BB=93=E6=9E=9C)+usages=E7=94=A8=E9=87=8F=E5=9B=A0?= =?UTF-8?q?=E5=AD=90=E8=AE=B0=E8=B4=A6;=E4=BE=9B=E5=BA=94=E5=95=86?= =?UTF-8?q?=E5=8E=BBprotocol=E5=AD=97=E6=AE=B5(=E6=A8=A1=E5=BC=8F=E5=BD=92?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=82=E9=85=8D=E6=A8=A1=E6=9D=BF);?= =?UTF-8?q?=E4=BF=AE=5Fmodel=5Fchain=E6=BC=8F=E5=8F=96profile=5Fid?= =?UTF-8?q?=E7=AD=89=E5=AD=97=E6=AE=B5=E8=87=B4=E9=80=82=E9=85=8D=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=BB=8E=E6=9C=AA=E5=8A=A0=E8=BD=BD=E7=9A=84=E5=AD=98?= =?UTF-8?q?=E9=87=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- json/llm_vendor_list.json | 6 - models/llm_usage.json | 8 +- models/llm_vendor.json | 17 +-- mysql.ddl.sql | 2 +- pipeline_llm/accounting.py | 13 +- pipeline_llm/gateway.py | 29 ++-- pipeline_llm/inference.py | 258 +++++++++++++++++++++++++++++++++- scripts/migrate_legacy_llm.py | 2 +- 8 files changed, 298 insertions(+), 37 deletions(-) diff --git a/json/llm_vendor_list.json b/json/llm_vendor_list.json index a40cd3b..1166f53 100644 --- a/json/llm_vendor_list.json +++ b/json/llm_vendor_list.json @@ -9,12 +9,6 @@ "id" ], "alters": { - "protocol": { - "uitype": "code", - "dataurl": "{{entire_url('../api/get_llm_protocol_options.dspy')}}", - "valueField": "value", - "textField": "text" - }, "status": { "uitype": "code", "dataurl": "{{entire_url('../api/get_llm_status_options.dspy')}}", diff --git a/models/llm_usage.json b/models/llm_usage.json index 58487ef..0816577 100644 --- a/models/llm_usage.json +++ b/models/llm_usage.json @@ -103,6 +103,12 @@ "nullable": "no", "default": "'ok'" }, + { + "name": "usages", + "title": "用量详情(JSON)", + "type": "text", + "nullable": "yes" + }, { "name": "note", "title": "备注/原因", @@ -154,4 +160,4 @@ "cond": "parentid='llm_usage_status'" } ] -} +} \ No newline at end of file diff --git a/models/llm_vendor.json b/models/llm_vendor.json index 52e4181..bd77b54 100644 --- a/models/llm_vendor.json +++ b/models/llm_vendor.json @@ -24,14 +24,6 @@ "length": 100, "nullable": "no" }, - { - "name": "protocol", - "title": "协议类型", - "type": "str", - "length": 20, - "nullable": "no", - "default": "'openai_compat'" - }, { "name": "endpoints", "title": "端点目录(JSON)", @@ -80,13 +72,6 @@ } ], "codes": [ - { - "field": "protocol", - "table": "appcodes_kv", - "valuefield": "k", - "textfield": "v", - "cond": "parentid='llm_protocol'" - }, { "field": "status", "table": "appcodes_kv", @@ -95,4 +80,4 @@ "cond": "parentid='llm_status'" } ] -} +} \ No newline at end of file diff --git a/mysql.ddl.sql b/mysql.ddl.sql index 65780ea..67ea044 100644 --- a/mysql.ddl.sql +++ b/mysql.ddl.sql @@ -5,7 +5,6 @@ CREATE TABLE IF NOT EXISTS llm_vendor ( `id` varchar(32) NOT NULL comment '主键ID', `name` varchar(100) NOT NULL comment '供应商名称', - `protocol` varchar(20) NOT NULL DEFAULT 'openai_compat' comment '协议类型', `endpoints` text comment '端点目录(JSON): [{region,base_url,proxy,timeout,note}]', `description` text comment '描述', `status` varchar(20) NOT NULL DEFAULT 'active' comment '状态', @@ -126,6 +125,7 @@ CREATE TABLE IF NOT EXISTS llm_usage ( `ppid` varchar(32) comment '定价项目ID', `task_ref` varchar(100) comment '调用来源', `status` varchar(20) NOT NULL DEFAULT 'ok' comment '状态(ok/failed/recharge)', + `usages` text comment '非token用量因子JSON(时长/分辨率等,异步出账读入定价引擎)', `note` varchar(200) comment '备注/原因', `created_at` datetime NOT NULL DEFAULT current_timestamp() comment '创建时间', PRIMARY KEY (`id`), diff --git a/pipeline_llm/accounting.py b/pipeline_llm/accounting.py index acd9b92..064772c 100644 --- a/pipeline_llm/accounting.py +++ b/pipeline_llm/accounting.py @@ -12,6 +12,7 @@ """ import asyncio +import json import logging import time @@ -29,7 +30,7 @@ _LOCK_TTL = 90 # 锁 TTL(略大于单批预算耗时) async def _get_pending(sor): recs = await sor.sqlExe( - "SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid " + "SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid, usages " "FROM llm_usage WHERE accounting_status='pending' AND status='ok' " "ORDER BY created_at LIMIT %d" % _BATCH, {}) await sor.sqlExe("COMMIT", {}) @@ -63,6 +64,16 @@ async def _settle_one(sor, row): 'prompt_tokens': int(getattr(row, 'req_tokens', 0) or 0), 'completion_tokens': int(getattr(row, 'resp_tokens', 0) or 0), } + # 非 token 计价因子(视频时长/分辨率/模型名等,2026-09-05): + # llm_usage.usages JSON 合并进定价引擎输入,按量计费模型(视频/图像)用 + usages_raw = getattr(row, 'usages', '') or '' + if usages_raw: + try: + extra = json.loads(usages_raw) + if isinstance(extra, dict): + usage_data.update(extra) + except Exception: + pass fn = getattr(env, 'product_accounting_generic', None) if fn is None: # 产品模块未加载 → 保留 pending 下轮再试 diff --git a/pipeline_llm/gateway.py b/pipeline_llm/gateway.py index 5e13b80..f55e9a7 100644 --- a/pipeline_llm/gateway.py +++ b/pipeline_llm/gateway.py @@ -199,7 +199,7 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): for mid in chain_ids: recs = await sor.sqlExe( "SELECT id, name, vendor_id, vendor_model_id, capability, default_params, " - "ppid, org_id " + "ppid, org_id, profile_id, sync_mode, query_profile_ids " "FROM llm_model WHERE id=${i}$ AND status='active' LIMIT 1", {"i": mid}) await sor.sqlExe("COMMIT", {}) if recs: @@ -208,15 +208,18 @@ async def _model_chain(sor, org_id: str, model_name: str, purpose: str = ''): async def _vendor_endpoints(sor, vendor_id: str): - """供应商端点目录。返回 (protocol, [endpoint dict])。""" + """供应商端点目录。返回 [endpoint dict]。 + + 2026-09-05:供应商表不再有 protocol 字段——请求形态(协议/模式)由模型挂的 + 适配模板(llm_api_profile)决定,供应商只提供端点目录(账号/区域/超时)。 + """ recs = await sor.sqlExe( - "SELECT protocol, endpoints FROM llm_vendor WHERE id=${i}$ AND status='active' LIMIT 1", + "SELECT endpoints FROM llm_vendor WHERE id=${i}$ AND status='active' LIMIT 1", {"i": vendor_id}) await sor.sqlExe("COMMIT", {}) if not recs: - return '', [] - r = recs[0] - return getattr(r, 'protocol', '') or '', _parse_json(getattr(r, 'endpoints', ''), []) + return [] + return _parse_json(getattr(recs[0], 'endpoints', ''), []) async def _account_candidates(sor, model: dict): @@ -255,7 +258,7 @@ async def _pick_candidate(sor, model: dict, pref: str): vendor_id = model.get('vendor_id', '') if not vendor_id: return False, '模型「%s」未绑定供应商,无法选择账号' % model.get('name', '') - protocol, endpoints = await _vendor_endpoints(sor, vendor_id) + endpoints = await _vendor_endpoints(sor, vendor_id) if not endpoints: return False, '供应商端点目录为空(模型治理→供应商→端点目录),请配置端点' accounts = await _account_candidates(sor, model) @@ -478,11 +481,14 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '', async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_tokens: int = 0, - note: str = ''): + note: str = '', usages: dict = None): """结算 ⑧:成本侧扣减 + 用量流水(记账分流)。 ctx govern_resolve 成功时返回的 dict(含 policy_org_id) ok_call 上游调用是否成功(失败也记账:写 failed 流水) + usages 非 token 用量因子(JSON 落 llm_usage.usages):视频时长/分辨率、 + 图像张数等按量计费模型的计价因子,由异步出账读入定价引擎 + (与 req_tokens/resp_tokens 并列,定价引擎按 pricing_data 选用)。 记账分流(2026-09): 本机构模型(含 owner 自用):只记成本侧——供应商账号扣减, @@ -521,6 +527,12 @@ async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_toke if ok_call and is_owner_model: accounting_status = 'pending' from appPublic.uniqueID import getID + usages_str = '' + if usages: + try: + usages_str = json.dumps(usages, ensure_ascii=False, default=str)[:2000] + except Exception: + usages_str = '' await sor.C('llm_usage', { 'id': getID(), 'org_id': caller_org, @@ -536,6 +548,7 @@ async def govern_settle(ctx: dict, ok_call: bool, req_tokens: int = 0, resp_toke 'task_ref': ctx.get('task_ref', ''), 'status': 'ok' if ok_call else 'failed', 'accounting_status': accounting_status, + 'usages': usages_str, 'note': (note or '')[:200], }) await sor.sqlExe("COMMIT", {}) diff --git a/pipeline_llm/inference.py b/pipeline_llm/inference.py index b287139..eba6b38 100644 --- a/pipeline_llm/inference.py +++ b/pipeline_llm/inference.py @@ -16,6 +16,7 @@ path/headers 决定上游请求形态(缺省按 OpenAI 兼容);非 t2t 形 import json import logging +import time logger = logging.getLogger("pipeline_llm.inference") @@ -135,6 +136,33 @@ async def _resolve_call(sor, org_id, user_id, model_name, capability, ctx['default_params'] = _parse_json(model_row.get('default_params'), {}) ctx['profile'] = profile ctx['capability'] = cap + # 异步模型的后续步骤模板(提交→查询→[下载]):按 query_profile_ids 顺序加载 + qpids = _parse_json(model_row.get('query_profile_ids'), []) + qps = [] + for qid in qpids or []: + recs = await sor.sqlExe( + "SELECT path, headers, request_template, response_template FROM llm_api_profile " + "WHERE id=${i}$ AND status='active' LIMIT 1", {"i": qid}) + await sor.sqlExe("COMMIT", {}) + if not recs: + continue + r = recs[0] + # method 无独立列:从 request_template JSON 的 method 键取(缺省 GET) + qmethod = 'GET' + try: + rt = json.loads(getattr(r, 'request_template', '') or '{}') + if isinstance(rt, dict) and rt.get('method'): + qmethod = str(rt.get('method')).upper() + except Exception: + pass + qps.append({ + 'path': getattr(r, 'path', '') or '', + 'headers': getattr(r, 'headers', '') or '', + 'method': qmethod, + 'request_template': getattr(r, 'request_template', '') or '', + 'response_template': getattr(r, 'response_template', '') or '', + }) + ctx['query_profiles'] = qps return ctx @@ -331,14 +359,227 @@ async def _call_upstream_chat(ctx, payload): raise last if last else ValueError('上游调用失败') +# ────────────────────────── 异步执行器(提交→轮询→取结果) ────────────────────────── +# 异步模型(sync_mode='async',如 DashScope 视频/图像生成): +# 1. 提交:POST 提交模板(模型挂的 profile)→ 返回 output.task_id +# 2. 轮询:GET 查询步骤模板(query_profiles[0])直到终态 +# 3. 结算:用量因子(时长/分辨率等)落 llm_usage.usages,异步出账读入定价引擎 +# +# 模板驱动:提交/查询的 path/headers/body 全来自 llm_api_profile(Jinja2 渲染), +# 新增异步形态只加 profile 记录,不改代码。DashScope 约定(官方文档核实, +# 2026-09-05):提交响应 output.task_id;查询响应 output.task_status ∈ +# PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/UNKNOWN,成功时带结果产物。 +_ASYNC_POLL_START = 5.0 # 轮询起始间隔(秒) +_ASYNC_POLL_MAX = 15.0 # 轮询最大间隔(秒) +_ASYNC_TOTAL_TIMEOUT = 600 # 异步任务总等待预算(秒,可被 _timeout 覆盖,上限 900) +_ASYNC_TASK_TERMINAL = ('SUCCEEDED', 'FAILED', 'CANCELED', 'UNKNOWN') + + +def _last_prompt_text(messages): + """从 messages 提取提示词文本(取末条非空 content;多模态列表拼文本部分)。""" + for m in reversed(messages or []): + if not isinstance(m, dict): + continue + c = m.get('content', '') + if isinstance(c, str) and c.strip(): + return c + if isinstance(c, list): + parts = [] + for seg in c: + if isinstance(seg, dict): + t = seg.get('text') or '' + if t: + parts.append(str(t)) + if parts: + return '\n'.join(parts) + return '' + + +async def _build_async_body(ctx, payload): + """异步提交请求体渲染(同 _build_upstream_body,另注入 prompt 便利变量)。""" + profile = ctx.get('profile') or {} + tmpl = (profile.get('request_template') or '').strip() + if not tmpl: + raise GovernError('异步模型的提交适配模板缺 request_template——请完善模型挂的适配模板') + upstream = dict(payload or {}) + upstream.pop('stream', None) + upstream['model'] = ctx.get('model_id') or upstream.get('model') or '' + for k, v in (ctx.get('default_params') or {}).items(): + upstream.setdefault(k, v) + messages = upstream.pop('messages', []) + model = upstream.pop('model', '') + ns = { + 'model': model, 'messages': messages, + 'prompt': _last_prompt_text(messages), + 'params': upstream, # 业务参数(resolution/duration/image_url 等) + 'api_key': ctx.get('api_key') or '', + 'org_id': ctx.get('org_id') or '', + } + s = await _render_tmpl(tmpl, ns) + try: + body = json.loads(s) + except Exception as e: + raise GovernError('提交请求体模板渲染结果不是合法 JSON:%s(模板:%s)' % ( + str(e)[:120], tmpl[:120])) + if not isinstance(body, dict): + raise GovernError('提交请求体模板必须渲染成 JSON 对象') + return body + + +async def _http_request(method, url, headers, body, timeout): + """通用上游 HTTP 请求(异步执行器用:提交/查询)。 + + 返回 (status, data);data 尽量 json.loads,失败返回原文。 + 超时/连接错误抛 ValueError(消息真实可行动,含异常类型)。 + """ + import aiohttp + import asyncio + try: + async with aiohttp.ClientSession() as session: + async with session.request( + method.upper(), url, headers=headers, + json=body if body is not None else None, + timeout=aiohttp.ClientTimeout(total=timeout, connect=_CONNECT_TIMEOUT), + ) as resp: + text = await resp.text() + try: + data = json.loads(text) if text else {} + except Exception: + data = text + return resp.status, data + except (asyncio.TimeoutError, aiohttp.ClientError) as e: + raise ValueError('上游请求失败(%s):%s' % (type(e).__name__, repr(e))) + + +async def _async_query_once(ctx, qprofile, task_id, ns, timeout): + """按查询步骤模板请求一次任务状态。path 支持 {task_id} 占位符。""" + path = await _render_tmpl((qprofile.get('path') or '').strip(), ns) + path = path.replace('{task_id}', task_id) + if path.startswith('http://') or path.startswith('https://'): + url = path + else: + url = (ctx.get('api_base') or '').rstrip('/') + path + headers = await _render_headers(qprofile.get('headers', ''), ns) + method = (qprofile.get('method') or 'GET').upper() + status, data = await _http_request(method, url, headers, None, timeout) + if status != 200: + raise ValueError('任务查询失败 HTTP %d: %s' % ( + status, str(data)[:200])) + if not isinstance(data, dict): + raise ValueError('任务查询返回非 JSON:%s' % str(data)[:200]) + return data + + +async def _async_inference(ctx, payload, req_timeout): + """异步模型执行:提交任务 → 轮询至终态 → 组装结果 → 结算(usages 记账)。 + + 返回 OpenAI 兼容形态(choices[0].message.content = 产物地址/文本), + 另带 output(上游原始输出)与 task_id。 + 任何失败:先 govern_settle 记 failed 流水,再抛 GovernError(消息真实可行动)。 + """ + import asyncio + from .gateway import govern_settle + + async def _fail(msg): + await govern_settle(ctx, False, 0, 0, msg) + raise GovernError(msg) + + qprofiles = ctx.get('query_profiles') or [] + if not qprofiles: + await _fail('模型「%s」为异步模型但未登记查询步骤模板(query_profile_ids),' + '无法获取任务结果——请在模型治理补录查询步骤适配模板' % ctx.get('name', '')) + return {} + profile = ctx.get('profile') or {} + ns = {'api_key': ctx.get('api_key') or '', 'org_id': ctx.get('org_id') or ''} + step_timeout = int(_fnum(ctx.get('timeout')) or 60) + + # ── 1. 提交任务 ── + body = await _build_async_body(ctx, payload) + path = await _render_tmpl((profile.get('path') or '').strip(), ns) + if path.startswith('http://') or path.startswith('https://'): + url = path + else: + url = (ctx.get('api_base') or '').rstrip('/') + path + headers = await _render_headers(profile.get('headers', ''), ns) + try: + status, data = await _http_request('POST', url, headers, body, step_timeout) + except ValueError as e: + await _fail('异步任务提交失败:%s' % e) + return {} + if status != 200 or not isinstance(data, dict): + await _fail('异步任务提交失败 HTTP %d: %s' % (status, str(data)[:300])) + return {} + task_id = ((data.get('output') or {}).get('task_id') or data.get('task_id') or '').strip() + if not task_id: + await _fail('异步任务提交响应缺 task_id:%s' % ( + json.dumps(data, ensure_ascii=False, default=str)[:300])) + return {} + print("[inference] 异步任务已提交 task_id=%s model=%s" % (task_id, ctx.get('model_id', ''))) + + # ── 2. 轮询至终态 ── + total_budget = req_timeout if req_timeout > 0 else _ASYNC_TOTAL_TIMEOUT + deadline = time.time() + total_budget + interval = _ASYNC_POLL_START + last = data + finished = '' + while time.time() < deadline: + await asyncio.sleep(interval) + interval = min(interval * 1.5, _ASYNC_POLL_MAX) + try: + last = await _async_query_once(ctx, qprofiles[0], task_id, ns, step_timeout) + except ValueError as e: + # 单次查询失败不立即终止(可能瞬时抖动),预算内继续 + print("[inference] 异步查询瞬时失败(预算内继续): %s" % e) + continue + finished = str((last.get('output') or {}).get('task_status') or '').upper() + if finished in _ASYNC_TASK_TERMINAL: + break + if finished not in _ASYNC_TASK_TERMINAL: + await _fail('异步任务等待超时(%d 秒):最后状态 %s,task_id=%s——' + '可用 _timeout 延长等待(上限 900 秒)' % ( + total_budget, finished or 'UNKNOWN', task_id)) + if finished != 'SUCCEEDED': + out = last.get('output') or {} + await _fail('异步任务执行失败:%s %s(task_id=%s)' % ( + out.get('code') or finished, str(out.get('message') or '')[:200], task_id)) + + # ── 3. 组装结果 + 用量因子记账 ── + out = last.get('output') or {} + content = (out.get('video_url') or out.get('image_url') or out.get('result_url') + or out.get('text') or '') + usage = last.get('usage') or {} + usages = dict(usage) if isinstance(usage, dict) else {} + usages['model'] = ctx.get('model_id') or '' + # 计价过滤维度用请求值(与文档计价档位原文一致,如 resolution=720P); + # 时长以上游计费返回为准,缺则回请求值 + for k in ('resolution', 'size', 'duration'): + if payload.get(k) not in (None, ''): + usages.setdefault(k, payload.get(k)) + await govern_settle(ctx, True, 0, 0, '' if usage else 'no-usage', usages) + return { + "choices": [{"message": {"content": content, "role": "assistant"}, + "finish_reason": "stop"}], + "usage": usage, + "output": out, + "task_id": task_id, + } + + # ────────────────────────── 对外主入口 ────────────────────────── async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''): - """t2t 推理:门禁链 → 上游调用 → 结算。返回上游响应 dict(OpenAI 兼容)。 + """统一推理:门禁链 → 上游调用 → 结算。返回上游响应 dict(OpenAI 兼容形态)。 - payload: 客户端请求体(messages 必需;tools/temperature/max_tokens 透传)。 + payload: 客户端请求体(messages 必需;tools/temperature/max_tokens 透传; + 异步模型另收业务参数 resolution/duration/image_url 等)。 model_name 空 → 机构策略缺省模型(主→备链第一个)。 失败抛 GovernError(消息真实可行动)。 + + 同步/异步分流(2026-09-05):按模型 sync_mode——sync 走 chat/completions + 一次往返;async 走异步执行器(提交任务→按 query_profile_ids 轮询→取结果), + 用量因子(视频时长/分辨率等)落 llm_usage.usages 供定价引擎计费。 + 本入口不再限定能力类型——统一入口按模型实际形态执行,能力分栏在 + list_models/选择层体现。 """ from .gateway import govern_settle, _get_db @@ -357,9 +598,20 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref=''): db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: ctx = await _resolve_call( - sor, org_id, user_id or '', model_name, 't2t', est, task_ref, purpose) + sor, org_id, user_id or '', model_name, '', est, task_ref, purpose) if req_timeout > 0: ctx['timeout'] = min(req_timeout, 900) + # 异步模型:提交→轮询→取结果(用量因子走 usages 记账) + model_row = ctx.get('model_row') or {} + if (model_row.get('sync_mode') or 'sync') == 'async': + return await _async_inference(ctx, payload, req_timeout) + # 同步模型守能力边界:聊天入口只认 t2t(embedding/rerank 等另有入口) + cap = (model_row.get('capability') or 't2t').strip().lower() + if cap != 't2t': + from .gateway import govern_settle + await govern_settle(ctx, False, 0, 0, 'capability mismatch: %s != t2t' % cap) + raise GovernError('模型「%s」能力为 %s,聊天入口仅支持 t2t——请按能力分类选择模型' % ( + model_row.get('name', ''), cap)) try: data = await _call_upstream_chat(ctx, payload) except Exception as e: diff --git a/scripts/migrate_legacy_llm.py b/scripts/migrate_legacy_llm.py index 4be2603..1339a75 100644 --- a/scripts/migrate_legacy_llm.py +++ b/scripts/migrate_legacy_llm.py @@ -83,7 +83,7 @@ async def _get_or_create_vendor(sor, provider, base_url, dry): eps = [{'base_url': base_url, 'region': 'domestic', 'timeout': 60}] if not dry: await sor.C('llm_vendor', { - 'id': vid, 'name': name, 'protocol': 'openai_compat', + 'id': vid, 'name': name, 'endpoints': json.dumps(eps, ensure_ascii=False), 'description': '由旧 llm 表迁移生成', 'status': 'active', 'org_id': '0', })