feat: llm_api_map CRUD management and ownerid-based data isolation

- Update json/llm.json subtable from llm_catelog_rel to llm_api_map
- Rewrite json/llm_api_map.json as standard CRUD format (tblname+params)
- Add models/llm_api_map.json table definition (summary/fields/indexes/codes)
- Add independent management UI (llm_api_map_manage.ui)
- Add CRUD DSPY APIs (list/create/delete/options) with ownerid filtering
- All operations verify l.ownerid for data isolation
- Add uapi_options.dspy for API selection dropdown
This commit is contained in:
yumoqing 2026-05-20 17:29:27 +08:00
parent 70e8fd791f
commit 1060cac2de
9 changed files with 488 additions and 17 deletions

View File

@ -48,8 +48,8 @@
"subtables":[
{
"field":"llmid",
"title":"模型类型",
"subtable":"llm_catelog_rel"
"title":"能力映射",
"subtable":"llm_api_map"
}
]
}

View File

@ -1,17 +1,27 @@
{
"summary": [{"name": "llm_api_map", "title": "模型能力映射表", "primary": ["id"], "catelog": "relation"}],
"fields": [
{"name": "id", "title": "主键", "type": "str", "length": 32, "nullable": "no"},
{"name": "llmid", "title": "模型ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "llmcatelogid", "title": "类目ID", "type": "str", "length": 32, "nullable": "no"},
{"name": "apiname", "title": "API接口名", "type": "str", "length": 100, "nullable": "no"},
{"name": "query_apiname", "title": "结果查询API名", "type": "str", "length": 100},
{"name": "query_period", "title": "查询间隔(秒)", "type": "int"},
{"name": "ppid", "title": "定价项目ID", "type": "str", "length": 32}
],
"indexes": [
{"name": "idx_llm_api_llm", "idxtype": "index", "idxfields": ["llmid"]},
{"name": "idx_llm_api_catelog", "idxtype": "unique", "idxfields": ["llmid", "llmcatelogid"]}
],
"codes": []
"tblname": "llm_api_map",
"title": "模型能力映射",
"params": {
"browserfields": {
"exclouded": ["id", "llmid"],
"alters": {
"llmcatelogid": {
"dataurl": "{{entire_url('../api/llm_catelog_options.dspy')}}",
"textField": "name",
"valueField": "id"
},
"apiname": {
"dataurl": "{{entire_url('../api/uapi_options.dspy')}}",
"textField": "name",
"valueField": "name"
},
"ppid": {
"dataurl": "{{entire_url('/pricing/get_all_pricing_programs.dspy')}}",
"textField": "name",
"valueField": "id"
}
}
},
"editexclouded": ["id", "llmid"]
}
}

99
models/llm_api_map.json Normal file
View File

@ -0,0 +1,99 @@
{
"table_name": "llm_api_map",
"summary": [
{
"name": "llm_api_map",
"title": "模型API映射表",
"primary": "id",
"catelog": "relation"
}
],
"fields": [
{
"name": "id",
"type": "varchar(32)",
"not_null": true,
"comment": "主键ID"
},
{
"name": "llmid",
"type": "varchar(32)",
"not_null": true,
"comment": "模型ID关联llm表"
},
{
"name": "llmcatelogid",
"type": "varchar(32)",
"not_null": true,
"comment": "模型分类ID关联llmcatelog表"
},
{
"name": "apiname",
"type": "varchar(100)",
"not_null": true,
"comment": "推理接口名称关联uapi表name字段"
},
{
"name": "query_apiname",
"type": "varchar(100)",
"comment": "异步任务结果查询接口名称,可逗号分隔多个"
},
{
"name": "query_period",
"type": "bigint",
"default": 30,
"comment": "异步任务查询轮询间隔默认30"
},
{
"name": "ppid",
"type": "varchar(32)",
"comment": "计费程序ID关联pricing_program表"
}
],
"indexes": [
{
"name": "idx_api_map_llmid",
"type": "normal",
"idxfields": ["llmid"],
"idxtype": "index"
},
{
"name": "idx_api_map_catelog",
"type": "normal",
"idxfields": ["llmcatelogid"],
"idxtype": "index"
},
{
"name": "idx_api_map_apiname",
"type": "normal",
"idxfields": ["apiname"],
"idxtype": "index"
},
{
"name": "uk_llmid_apiname",
"type": "unique",
"idxfields": ["llmid", "apiname"],
"idxtype": "unique"
}
],
"codes": [
{
"field": "llmid",
"table": "llm",
"valuefield": "id",
"textfield": "name"
},
{
"field": "llmcatelogid",
"table": "llmcatelog",
"valuefield": "id",
"textfield": "name"
},
{
"field": "ppid",
"table": "pricing_program",
"valuefield": "id",
"textfield": "name"
}
]
}

View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
try:
dbname = get_module_dbname('llmage')
llmid = params_kw.get('llmid', '')
catelogid = params_kw.get('llmcatelogid', '')
apiname = params_kw.get('apiname', '')
if not llmid or not catelogid or not apiname:
result['options'] = {'title': 'Error', 'message': '模型、分类和API接口为必填项', 'type': 'error'}
else:
user_orgid = await get_userorgid()
from appPublic.uniqueID import getID
new_id = getID()
async with DBPools().sqlorContext(dbname) as sor:
# 验证模型属于当前用户的机构
check_llm = await sor.sqlExe(
"select id from llm where id = ${llmid}$ and ownerid = ${ownerid}$",
{'llmid': llmid, 'ownerid': user_orgid}
)
if not check_llm:
result['options'] = {'title': 'Error', 'message': '无权操作该模型', 'type': 'error'}
else:
# 检查是否已存在
check_sql = """select id from llm_api_map
where llmid = ${llmid}$ and apiname = ${apiname}$"""
exists = await sor.sqlExe(check_sql, {'llmid': llmid, 'apiname': apiname})
if exists:
result['options'] = {'title': '提示', 'message': '该模型的此API映射已存在', 'type': 'warning'}
else:
data = {
'id': new_id,
'llmid': llmid,
'llmcatelogid': catelogid,
'apiname': apiname
}
query_apiname = params_kw.get('query_apiname', '').strip()
if query_apiname:
data['query_apiname'] = query_apiname
query_period = params_kw.get('query_period', '').strip()
if query_period:
try:
data['query_period'] = int(query_period)
except ValueError:
data['query_period'] = 30
else:
data['query_period'] = 30
ppid = params_kw.get('ppid', '').strip()
if ppid:
data['ppid'] = ppid
await sor.C('llm_api_map', data)
result['options'] = {'title': 'Success', 'message': '添加成功', 'type': 'success'}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'添加失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,31 @@
#!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
try:
dbname = get_module_dbname('llmage')
map_id = params_kw.get('id', '')
if not map_id:
result['options'] = {'title': 'Error', 'message': '缺少ID参数', 'type': 'error'}
else:
user_orgid = await get_userorgid()
async with DBPools().sqlorContext(dbname) as sor:
# 验证映射记录对应的模型属于当前用户的机构
check = await sor.sqlExe(
"""select m.id from llm_api_map m
join llm l on m.llmid = l.id
where m.id = ${id}$ and l.ownerid = ${ownerid}$""",
{'id': map_id, 'ownerid': user_orgid}
)
if not check:
result['options'] = {'title': 'Error', 'message': '无权删除该映射', 'type': 'error'}
else:
await sor.sqlExe("delete from llm_api_map where id = ${id}$", {'id': map_id})
result['options'] = {'title': 'Success', 'message': '删除成功', 'type': 'success'}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('llmage')
user_orgid = await get_userorgid()
sql = """
select m.id, m.llmid, l.name as llm_name,
m.llmcatelogid, c.name as catelog_name,
m.apiname, m.query_apiname, m.query_period,
m.ppid, p.name as ppid_name
from llm_api_map m
join llm l on m.llmid = l.id
join llmcatelog c on m.llmcatelogid = c.id
left join pricing_program p on m.ppid = p.id
where l.ownerid = ${ownerid}$
order by l.name, c.name, m.apiname
"""
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(sql, {'ownerid': user_orgid})
result['rows'] = [dict(r) for r in (rows or [])]
result['total'] = len(result['rows'])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,38 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'data': {'llms': [], 'catelogs': [], 'apis': [], 'ppids': []}}
try:
dbname = get_module_dbname('llmage')
user_orgid = await get_userorgid()
async with DBPools().sqlorContext(dbname) as sor:
# Active LLMs for user's organization
today = await get_business_date(sor)
llms_sql = """select id, name from llm
where enabled_date <= ${today}$
and expired_date > ${today}$
and ownerid = ${ownerid}$
order by name"""
llms = await sor.sqlExe(llms_sql, {'today': today, 'ownerid': user_orgid})
result['data']['llms'] = [{'id': r['id'], 'text': r['name']} for r in (llms or [])]
# All catalogs
catelogs = await sor.sqlExe("select id, name from llmcatelog order by name", {})
result['data']['catelogs'] = [{'id': r['id'], 'text': r['name']} for r in (catelogs or [])]
# uapi list (for apiname selection)
apis = await sor.sqlExe("select name, path from uapi order by name", {})
result['data']['apis'] = [{'id': r['name'], 'text': f"{r['name']} ({r['path']})"} for r in (apis or [])]
# Pricing programs
ppids = await sor.sqlExe("select id, name from pricing_program order by name", {})
result['data']['ppids'] = [{'id': r['id'], 'text': r['name']} for r in (ppids or [])]
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,17 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'data': []}
try:
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe("select name, path from uapi order by name", {})
result['data'] = [{'id': r['name'], 'text': f"{r['name']} ({r['path']})"} for r in (rows or [])]
result['success'] = True
except Exception as e:
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,181 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"spacing": 16
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "模型能力映射管理",
"halign": "left"
}
},
{
"widgettype": "VBox",
"options": {
"width": "calc(100% - 40px)",
"margin": "0 20px",
"padding": "16px",
"bgcolor": "#f5f5f5",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "添加新映射",
"fontSize": "16px",
"fontWeight": "bold"
}
},
{
"widgettype": "Form",
"id": "add_form",
"options": {
"layout": "horizontal",
"cols": 3,
"fields": [
{
"name": "llmid",
"label": "选择模型",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}",
"data_field": "llms",
"placeholder": "请选择模型"
},
{
"name": "llmcatelogid",
"label": "选择分类",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}",
"data_field": "catelogs",
"placeholder": "请选择分类"
},
{
"name": "apiname",
"label": "API接口",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}",
"data_field": "apis",
"placeholder": "请选择API接口"
},
{
"name": "query_apiname",
"label": "查询API",
"uitype": "text",
"placeholder": "异步查询API名多个用逗号分隔"
},
{
"name": "query_period",
"label": "查询间隔(秒)",
"uitype": "number",
"placeholder": "默认30"
},
{
"name": "ppid",
"label": "计费项目",
"uitype": "select",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}",
"data_field": "ppids",
"placeholder": "请选择计费项目"
}
],
"buttons": [
{
"name": "add_btn",
"label": "添加映射",
"variant": "primary"
}
]
},
"binds": [
{
"wid": "add_btn",
"event": "click",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"},
"options": {
"url": "{{entire_url('./api/llm_api_map_create.dspy')}}",
"params": {
"llmid": "$[add_form.llmid]$",
"llmcatelogid": "$[add_form.llmcatelogid]$",
"apiname": "$[add_form.apiname]$",
"query_apiname": "$[add_form.query_apiname]$",
"query_period": "$[add_form.query_period]$",
"ppid": "$[add_form.ppid]$"
}
}
}
]
}
]
},
{
"widgettype": "VBox",
"options": {
"width": "calc(100% - 40px)",
"margin": "0 20px",
"spacing": 12
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": "当前映射列表",
"fontSize": "16px",
"fontWeight": "bold"
}
},
{
"widgettype": "Tabular",
"id": "map_table",
"options": {
"width": "100%",
"height": "400px",
"data_url": "{{entire_url('./api/llm_api_map_list.dspy')}}",
"data_method": "GET",
"page_rows": 20,
"row_options": {
"fields": [
{"name": "llm_name", "title": "模型名称", "width": 180},
{"name": "catelog_name", "title": "分类", "width": 120},
{"name": "apiname", "title": "API接口", "width": 150},
{"name": "query_apiname", "title": "查询API", "width": 180},
{"name": "query_period", "title": "间隔(秒)", "width": 80},
{"name": "ppid_name", "title": "计费项目", "width": 150},
{
"name": "actions",
"title": "操作",
"width": 100,
"uitype": "button",
"data": [
{"text": "删除", "event": "delete_map"}
]
}
]
}
},
"binds": [
{
"wid": "self",
"event": "delete_map",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"},
"options": {
"url": "{{entire_url('./api/llm_api_map_delete.dspy')}}",
"params": {
"id": "$[event.params.id]$"
}
}
}
]
}
]
}
]
}