Compare commits

..

170 Commits

Author SHA1 Message Date
Hermes Agent
30c429ad0c feat: add i18n translations (zh/en/jp/ko) for all modules 2026-06-19 15:01:28 +08:00
Hermes Agent
71ccf230e9 fix: 按钮使用id属性替代name,确保binds能正确绑定事件 2026-06-18 16:35:44 +08:00
Hermes Agent
2efb1268b7 refactor: 模型上线检查UI改为VScrollPanel+HBox按钮布局
- UI: 检查结果区域改为VScrollPanel(filler),两个按钮用HBox并排
- check_pricing_program.dspy: 优先显示display_text字段,回退到name字段
2026-06-18 16:26:51 +08:00
Hermes Agent
5ab8171552 refactor: failed_accounting.ui布局优化 - 移除外层HBox包装,InlineForm直接作为VBox子控件 - 重试记账按钮移至Tabular toolbar 2026-06-18 14:21:10 +08:00
Hermes Agent
c0c9973e23 fix: failed_accounting_list.dspy JOIN条件修正 - users表主键是id不是userid - filter模糊搜索改用JOIN后的表别名(u.username, l.name) 2026-06-18 14:19:14 +08:00
Hermes Agent
4abd553302 fix: failed_accounting.ui显示优化 + dspy跨库JOIN返回_text字段 - failed_accounting.ui: userid→username, userorgid→orgname, llmid→llm.name - 移除cost/use_date列, InlineForm紧凑布局 - failed_accounting_list.dspy: 跨库JOIN users/organization/llm获取名称 - handled保持原始值, 由UI的code控件显示 2026-06-18 14:15:28 +08:00
Hermes Agent
6f977450c9 refactor: 记账失败记录优化显示 - userid/userorgid/llmid显示名称 - 移除cost/use_date字段 - InlineForm紧凑布局 2026-06-18 13:58:05 +08:00
Hermes Agent
cb99a83364 revert: 恢复tbl.render(params),排查InlineForm数据传递问题 2026-06-18 13:37:40 +08:00
Hermes Agent
716876fd92 fix: failed_accounting查询用event传表单数据 2026-06-18 12:51:27 +08:00
Hermes Agent
74aca298a7 llmage: failed_accounting.ui加原因toolbar按钮弹窗显示失败原因 2026-06-18 12:14:06 +08:00
Hermes Agent
6bcacaf94a fix: 记账失败原因toolbar-params改为URL传参避免xls2crud序列化错误 2026-06-18 12:00:00 +08:00
Hermes Agent
88d2fd2c86 llmage: 记账失败记录-原因改为toolbar按钮弹窗显示 2026-06-18 11:55:08 +08:00
Hermes Agent
1aa28430e0 refactor: appcodes编码改用models codes段定义,清理json alters 2026-06-17 19:09:51 +08:00
Hermes Agent
2e2fa36896 fix: query_apiname双问号修复+isdefaultcatelog改硬编码 2026-06-17 18:42:48 +08:00
Hermes Agent
ac79d6c0d0 fix: get_search_apiname返回value/text双键兼容表单下拉 2026-06-17 18:32:09 +08:00
Hermes Agent
eaf440a6a9 feat: 添加llmage编码字典init/data.json,CRUD alters改用appcodes数据源 2026-06-17 18:04:44 +08:00
Hermes Agent
fe348b070e fix: 简化体验一次和检查计费为配置验证,避免复杂API调用 2026-06-16 18:02:58 +08:00
Hermes Agent
ab83f05d63 fix: 修复check_*.dspy的false/False错误和pricingdata表查询 2026-06-16 17:19:03 +08:00
Hermes Agent
cef9e0bc52 fix: 修正check_*.dspy路径为绝对路径,修复按钮API路径 2026-06-16 16:47:37 +08:00
Hermes Agent
039814b281 fix: 修正check_*.dspy路径为../,修复usages参数JSON编码 2026-06-16 16:41:22 +08:00
Hermes Agent
ea2f08e443 fix: 上线检查改为urlwidget子控件模式,每个检查项独立dspy(bug3) 2026-06-16 16:25:26 +08:00
Hermes Agent
75fe89ac2e fix: 修复llm_launch_check_page.dspy缩进和错误处理 2026-06-16 15:46:16 +08:00
Hermes Agent
5deecc67ce fix: 上线检查改为服务端渲染(.dspy),解决bricks script不支持async问题(bug3) 2026-06-16 14:09:12 +08:00
Hermes Agent
b53eb61fbf fix: 修复记账失败记录查询无响应(bug4/5)+模型上线检查无响应(bug3) 2026-06-16 10:45:27 +08:00
Hermes Agent
fe4e8271bf fix: recover_usages支持ioinfo两种存储格式
ioinfo字段有两种存储方式:
1. JSON内容(流式模型如qwen3-max) - 直接解析
2. 文件路径(异步模型如viduq3-pro) - 读取文件再解析

修改后两种情况都能正确提取usage
2026-06-15 17:16:35 +08:00
Hermes Agent
0d2b39ddd7 feat: add recover_usages button to accounting failed page
- Add recover_usages.dspy: reads ioinfo files, extracts usage from
  last output, writes back to llmusage.usages field
- Add toolbar button in llmusage_accounting_failed/index.ui
- Register new path in load_path.py RBAC config
- Force-add dspy (parent dir in .gitignore for CRUD auto-gen)
2026-06-15 17:01:29 +08:00
Hermes Agent
2789f191d4 bugfix 2026-06-14 15:27:51 +08:00
f5a9ce2c12 fix: DataViewer改Tabular,fields移入row_options用cwidth比例布局 2026-06-13 19:04:45 +08:00
79a99f2dba fix: DataViewer去掉title避免白色背景,修正page_rows参数名 2026-06-13 19:00:40 +08:00
335a06d5ea refactor: failed_accounting.ui改用InlineForm简化过滤栏 2026-06-13 18:41:04 +08:00
a70933c44c fix: dspy添加InlineForm过滤条件支持,UI字段名对齐 2026-06-13 18:27:33 +08:00
7dd0886193 feat: 记账失败页面添加InlineForm过滤栏 2026-06-13 18:24:52 +08:00
89928a68e7 fix: 补全wan2.7-t2v视频生成API接入SQL(原文件截断重写) 2026-06-13 14:33:35 +08:00
37c8d4127e fix: 添加数字人模型定价修复SQL(wan2.6-i2v-flash + wan2.2-s2v) 2026-06-13 14:30:34 +08:00
c55b4c7a83 fix: INSERT改为INSERT IGNORE避免重复执行时主键冲突 2026-06-12 18:44:18 +08:00
9b00d02365 docs: M3定价表更新为分段显示(≤512K/512K~1M) 2026-06-12 17:31:50 +08:00
72f2e81291 fix: M3分段定价添加prompt_tokens range filter (≤512K/512K~1M)
- 11a: 添加prompt_tokens字段定义(value_mode:between)
- 11b: M3定价按context长度分段
  ≤512K: 输入2.1/输出8.4/缓存0.42 (永久五折)
  512K~1M: 输入4.2/输出16.8/缓存0.84
- range filter应用到所有factor items
2026-06-12 17:31:30 +08:00
f0b29759cd docs: 更新vendor-minimax,httpapi改为uapi模块说明 2026-06-12 16:29:37 +08:00
f18d370354 fix: 移除httpapi,改用uapi模块接入MiniMax M3
- 新增uapi: minimax t2t (纯文本, OpenAI兼容, path=/chat/completions)
- 新增uapi: minimax tm2t (多模态, 支持图片/视频/音频)
- 复用共享ioid: Is8l4TGkcZcqFSjbbeIK2 (t2t), t-ujII59ku45tIPcdXu4O (tm2t)
- 新增llm_api_map: M3的tm2t多模态映射
- 其余llm/llm_api_map/pricing保持不变
2026-06-12 16:29:14 +08:00
336f614041 feat: MiniMax M3接入 + M2.7-highspeed + 补充全模型定价
- 新增httpapi: minimax_openai t2t (OpenAI兼容)
- 新增llm: MiniMax-M3, MiniMax-M2.7-highspeed
- 新增llm_api_map: M3和M2.7-highspeed的t2t映射
- 新增pricing_program: mm_tts_pricing (TTS定价)
- 补充6个现有模型的ppid (Hailuo/S2V/Music/TTS)
- 更新5jmzup定价: 追加M3定价条目
- 供应商文档: docs/vendor-minimax.md
2026-06-12 16:17:44 +08:00
3b25b9cfb4 fix: llminference.dspy 增加余额/定价校验拦截
checkCustomerBalance 返回值 f 之前被丢弃,导致即使余额不足或
模型未配置定价,直接调 API 仍可执行推理。

添加 if not f 判断,返回 UiError 拦截。
2026-06-12 16:02:33 +08:00
6bc04897ab bugfix 2026-06-12 15:36:02 +08:00
dd8f2d23f6 chore: untrack .nfs lock file 2026-06-12 15:33:11 +08:00
c90cd88dd4 feat: show_llms_cards_by_provider 添加定价展示 + 优化批量查询
1. get_llms_sort_by_provider: 添加 ppid 批量查询和 pricing_display
2. get_llms_by_catelog: N+1 查询改为批量 ppid 查询,性能优化
3. show_llms_cards_by_provider.ui: 添加 pricing-box 展示区块
4. 两个函数都处理 get_pricing_display 返回 None 的情况
2026-06-12 15:32:34 +08:00
c15cb0416e feat: 模型卡片批量加载定价显示(preview_llm) 2026-06-12 15:32:05 +08:00
647e63eb04 fix: failed_accounting.ui - Combobox→UiCode, 移除formatter, 修复dspy import 2026-06-12 15:23:05 +08:00
39af416625 fix: 记账失败记录页面的查询和重试按钮
修复 failed_accounting.ui 中两个按钮的 JavaScript 错误:

查询按钮:
- 替换 this.root.getElementById() 为 bricks.getWidgetById()
- 替换 DataViewer.load() 为 render() (正确的公开API)
- 使用 getValue() 获取输入控件值

重试按钮:
- 替换 this.root.getElementById() 为 bricks.getWidgetById()
- 替换 selected_row 为 select_row (正确的内部属性名)
- 通过 .user_data 访问行数据
- 替换 dv.load() 为 dv.render()
- 使用 entire_url() 模板生成正确的 API URL
- 改用 async/await 替代 Promise chain

根本原因: 原代码使用了不存在的 bricks API
(this.root.getElementById, DataViewer.load, selected_row)
2026-06-12 14:28:50 +08:00
34627054b1 feat: code-type fields use fieldname/fieldname_text format
- get_search_providerid.dspy: return {providerid, providerid_text}
- get_search_upappid.dspy: return {upappid, upappid_text}
- get_search_apiname.dspy: return {apiname, apiname_text}
- json/llm.json: add valueField/textField for providerid, upappid
- json/llm_api_map.json: add valueField/textField for apiname, query_apiname

This ensures filter form and add/edit form use the same data format.
2026-06-12 11:40:54 +08:00
8ee2eccc55 feat: add pricing display to model cards
- utils.py: get_llms_by_catelog now queries all distinct ppids for each model
  and calls get_pricing_display to get pricing text
- show_llms_cards.ui: added Filler with pricing-box CSS to display pricing info
- model_plaza.css: added styles for pricing-box and pricing-text (light/dark mode)
2026-06-12 11:28:16 +08:00
2792fc7bda fix: show_llms_cards card overlap - set col_cwidth:25 matching card width, col_cgap:1 for spacing 2026-06-12 11:20:41 +08:00
4d455da18c fix: catelog selection logic and backward compatibility
- utils.py: get_llmage_llm - when catelogid provided, use it instead of forcing isdefaultcatelog=1
- list_paging_catelog_llms.dspy: handle missing llmid in SQL, add llmcatelogid to returned rows
- llm_dialog.ui: use Jinja2 namespace for variable scoping in for loop
2026-06-11 19:29:22 +08:00
52312b0a06 feat: llm_dialog.ui catelog tabs + get_llm_catelogs + inference passes catelogid
- utils.py: get_llmcatelogid -> get_llm_catelogs (returns ALL catelogs with name+isdefault)
- llmclient.py: inference() passes params_kw.llmcatelogid to get_llm()
- llm_dialog.ui: renders catelog tab buttons when model has multiple catelogs,
  defaults to isdefaultcatelog, passes llmcatelogid to both LlmIO models and list_models_url
- init.py: export get_llm_catelogs
2026-06-11 19:22:17 +08:00
982517a1c8 feat: add get_llmcatelogid function and pass llmcatelogid to list_paging_catelog_llms.dspy 2026-06-11 19:02:50 +08:00
767539fabd fix: derive llmcatelogid from llmid when not provided in params 2026-06-11 18:58:33 +08:00
3423d5752f fix: move llmcatelogid filter into subquery (m alias not visible in outer scope) 2026-06-11 18:55:00 +08:00
0f2d84bd00 fix: use m.apiname from llm_api_map instead of a.apiname (column not in llm table) 2026-06-11 17:44:11 +08:00
3947fb3587 refactor: consolidate API docs - wwwroot/api_doc.md symlinked to docs/API.md 2026-06-11 16:08:15 +08:00
bbe067e2b9 docs: update docs/API.md with /v1/pricing endpoint 2026-06-11 15:20:31 +08:00
242839d0bb docs: add /v1/pricing endpoint to API documentation 2026-06-11 15:20:06 +08:00
4d69d54e20 feat: add /v1/pricing endpoint to get model pricing display info 2026-06-11 15:17:25 +08:00
63c8a42215 bugfix 2026-06-10 18:28:17 +08:00
1096e85720 bugfix 2026-06-10 16:17:25 +08:00
6fd1f2ee5c bugfix 2026-06-10 16:10:42 +08:00
de695424d3 bugfix 2026-06-10 13:55:45 +08:00
e29cdadf18 bugfix 2026-06-09 14:01:08 +08:00
f5ded344f6 bugfix 2026-06-09 13:58:03 +08:00
f1d02f9d16 bugfix 2026-06-09 13:55:33 +08:00
ca1abb008a bugfix 2026-06-09 13:53:14 +08:00
dfb0794ee2 bugfix 2026-06-09 13:51:41 +08:00
892f0c5002 bugfix 2026-06-09 13:36:07 +08:00
6123c45c10 bugfix 2026-06-09 11:55:02 +08:00
08a409c00f fix: add llmid parameter to apiname/query_apiname dataurl 2026-06-05 18:30:42 +08:00
a1c3eb4b25 feat: apiname/query_apiname改为从uapi动态选择
- 新增 get_search_apiname.dspy: 根据llmid查upappid, 从uapi获取API列表
- apiname: code类型下拉选择(必选)
- query_apiname: code类型下拉选择(可选, 含'不指定'选项)
- 更新 json/llm_api_map.json CRUD定义 alters
- 更新 llm_api_map_manage.ui 表单字段
- 重新生成 wwwroot/llm_api_map/index.ui
- 注册 load_path.py RBAC权限
2026-06-05 18:22:14 +08:00
d4e455ba9a perf: optimize get_inference_history query and add indexes
Query optimization (dspy):
- Replace UNION ALL + sort with two parallel queries (asyncio.gather)
  that each use (userid, use_time) composite index
- Python-side merge-sort of two pre-sorted sequences O(n)
- Concurrent FileStorage reads for ioinfo (asyncio.gather)

Indexes (models/*.json + /tmp/llmage_history_indexes.sql):
- llmusage: add idx_llmusage_userid_usetime (userid, use_time)
- llmusage_history: add idx_lh_userid_usetime (userid, use_time)
  (was missing userid index entirely - main bottleneck)
2026-06-05 17:42:15 +08:00
2ebe811c34 fix: use llm_api_map for llmcatelogid filter (llm table has no catelog column) 2026-06-05 17:38:18 +08:00
6f8c14c329 feat: add llmcatelogid filter and pagerows param to get_inference_history
- Add llmcatelogid parameter to filter by model catalog (joins llm table)
- Change default pagerows from 50 to 10
- Add pagerows parameter for custom page size
2026-06-05 17:34:26 +08:00
eee648038a docs: add get_inference_history API documentation to README 2026-06-05 17:19:40 +08:00
1d12d42e80 feat: add get_inference_history API - cross-table paginated query with ioinfo content
- UNION ALL query from llmusage + llmusage_history tables
- Filter by current user's userid, sorted by use_time desc
- 50 records per page with pagination support
- Reads ioinfo webpath via FileStorage to return actual input/output content
- Registered in load_path.py for RBAC (logined role)
2026-06-05 17:15:05 +08:00
6876edae62 bugfix 2026-06-05 16:58:09 +08:00
186f64d544 fix: prepend 全部 option to get_search results 2026-06-04 18:52:10 +08:00
134bd1ca68 fix: replace 'from datetime import date' with pre-loaded curDateString() 2026-06-04 18:39:50 +08:00
9212cf8afb fix: remove import statements from dspy file (violates dspy spec) 2026-06-04 18:35:53 +08:00
faba862336 feat: add check_charging action to test pricing calculation with usage data
- llm_launch_check_api.dspy: add check_charging action
  * Takes usages JSON from inference result
  * Calls env.buffered_charging(ppid, usages) to verify pricing works
  * Returns pricing breakdown with amounts and costs
- llm_launch_check.ui: add '检查计费' button
  * Appears after successful inference
  * Passes usage data to check_charging API
  * Displays pricing calculation results
- llm_launch_check_api.dspy: simplify inference action
  * Direct uapi.call() instead of full inference pipeline
  * Extracts usage from response without writing to database
2026-06-04 18:29:37 +08:00
3a0a8d4c86 feat: 添加模型上线检查功能
- 新增 llm_launch_check_api.dspy:执行完整的上线前检查
  * 检查模型记录、日期、状态
  * 检查上位系统(upapp)关联
  * 检查API映射(uapi)
  * 检查IO定义(uapiio)
  * 检查能力映射(llm_api_map)
  * 检查定价项目(pricing_program)
  * 检查定价数据(pricingdata)
  * 支持体验测试(action=inference)
- 新增 llm_launch_check.ui:检查结果展示界面
- 修改 llm.json:将'体验'按钮改为'上线检查'
- 更新 load_path.py:注册新路径
2026-06-04 18:11:12 +08:00
308e91c61c fix: align get_search_providerid.dspy with {value, text} format using SQL aliases 2026-06-04 17:58:00 +08:00
9377cfabb8 fix: align get_search_upappid.dspy with {value, text} format 2026-06-04 17:56:34 +08:00
e6958f277b fix: remove invalid data_url override pointing to non-existent api/get_llm.dspy 2026-06-04 17:32:47 +08:00
bb4900f997 feat: add get_search_{fieldname}.dspy for codes fields with 全部 option
- get_search_providerid.dspy: organization list with 全部 as first entry
- get_search_upappid.dspy: upapp list with 全部 as first entry
- json/llm.json: update alters dataurl to use search scripts
- load_path.py: register new RBAC paths
2026-06-04 17:22:50 +08:00
6bfa0cb27c feat: add llmcatelogid filter param (default t2t) to get_my_asynctasks API 2026-06-04 17:01:29 +08:00
90c93dbe07 feat: get_my_asynctasks 返回记录增加 llmcatelogid 属性
通过 llmid 查询 llm_api_map 表获取对应的 llmcatelogid
2026-06-04 15:12:29 +08:00
ffb10827bb refactor: get_type_llms 参数名 catelogid -> llmcatelogid 2026-06-04 14:36:04 +08:00
df8aafe1d8 feat: add TTS and ASR audio API endpoints
- POST /v1/audio/speech (TTS): MiniMax Speech 2.6 Turbo/HD, 2.5 HD, F5-TTS local
- POST /v1/audio/transcriptions (ASR): qwen3-asr-flash, Nvidia parakeet
- Add comprehensive docs for both endpoints in API.md
- Update load_path.py RBAC (logined + customer roles)
2026-06-04 13:58:26 +08:00
ae02a7e88c feat: add music generation API (MiniMax Music 2.5/2.6)
- Add POST /v1/music/generations endpoint (index.dspy)
- Add music generation section to API docs
- Update load_path.py RBAC permissions for new path
- Models: music-2.6, music-2.5 (MiniMax, sync, returns audio URL)
- Required params: model, catelogid=music_gen, prompt, lyrics
2026-06-04 13:40:08 +08:00
fb7fa8c082 fix: replace wildcard patterns with explicit per-file entries in load_path.py 2026-06-04 13:03:31 +08:00
3743dec00d fix: dark mode overrides for plaza sidebar and nav buttons 2026-06-02 21:00:27 +08:00
311b0aec6f bugfix 2026-06-02 13:59:24 +08:00
cab7843f95 bugfix 2026-06-02 13:56:23 +08:00
d4c079d11e bugfix 2026-06-02 13:53:47 +08:00
151fb14b25 bugfix 2026-06-02 13:53:17 +08:00
565e9cd8a4 fix: get_type_llms use catelogid param instead of type, remove alias mapping 2026-06-02 11:33:32 +08:00
2b121077c6 fix: change llmage_content from VBox to VScrollPanel for right-side scrolling
The model list page content area (llmage_content) had no scrollbar because
it was a VBox with css:filler (overflow:hidden). Changed to VScrollPanel
which provides overflow:auto, enabling scrolling when content overflows.
2026-06-02 11:26:06 +08:00
6cafd70b34 feat: add /v1/models/catelog endpoint - list models by catalog with exclude 2026-06-02 11:21:25 +08:00
76ddfaabc7 fix: 移除DynamicColumn的filler类,修复卡片区域无法滚动
DynamicColumn的css=filler会导致overflow:hidden,裁剪卡片内容。
改为css=plaza-grid让DynamicColumn有自然高度,VScrollPanel可正常滚动。
2026-06-02 00:02:06 +08:00
c3abbf9bfe fix: llmage index布局改为flex,llmage_content填满剩余空间
VScrollPanel改为VBox(filler),卡片区自然高度,
llmage_content设为css=filler+height=100%,
Tabular加载后有固定高度约束,内部表格可滚动。
2026-06-01 23:52:54 +08:00
a0f38df113 debug: add hot_reload handler logging 2026-06-01 22:53:11 +08:00
450c9009a5 style: move _on_hot_reload after all imports 2026-06-01 18:15:38 +08:00
87040915ee refactor: bind hot_reload event via EventDispatcher 2026-06-01 18:10:32 +08:00
bab415ba83 Revert "fix: 模型列表右侧面板改为VScrollPanel支持滚动"
This reverts commit e186e74b631e162161d5525b21a00191ccf35f1a.
2026-06-01 17:38:27 +08:00
e186e74b63 fix: 模型列表右侧面板改为VScrollPanel支持滚动 2026-06-01 17:25:52 +08:00
8e9ab5008c fix: get_llm_llmusage用JOIN从llm_api_map获取query_apiname,修复异步任务轮询卡在CREATED 2026-06-01 14:39:00 +08:00
be3c939955 fix: 模型广场彻底重构 — 移除TabPanel,用VBox+script切换视图
根因链:
1. TabPanel内部容器结构导致getWidgetById找不到tab content中的widget
2. urlwidget渲染后替换自身DOM,id丢失

修复:
- model_plaza.ui: 移除TabPanel,用两个VBox(按分类/按供应商)+script切换display
- plaza_cards_panel/plaza_provider_panel改为VBox容器(urlwidget作子组件),id不丢失
- CSS用#plaza_view_provider{display:none}初始隐藏供应商视图
- 切换按钮用getElementById直接操作display,不依赖bricks widget寻址
- 全链路filler确保VScrollPanel获得确定高度可滚动
2026-06-01 13:44:20 +08:00
fed36ff079 fix: 模型广场导航找不到目标 — 内联布局替代嵌套urlwidget
根因:TabPanel tab content通过urlwidget加载show_llms.ui时,
urlwidget渲染后替换自身DOM,导致plaza_cards_panel的id丢失,
getWidgetById()返回null。

修复:将左右分栏布局直接内联到model_plaza.ui的TabPanel tab content中,
plaza_cards_panel改为VBox容器(保持id),初始内容通过子urlwidget加载,
点击导航按钮时用mode:replace替换VBox内容。
2026-06-01 13:35:51 +08:00
063e158989 fix: 右侧模型列表不可滚动 — 添加filler CSS和flex布局
- show_llms/show_llms_by_providers的HBox加css:filler
- 右侧urlwidget加css:filler
- show_llms_cards两个文件的VScrollPanel加css:filler
- CSS中为tabpanel-content和scrollpanel添加flex布局确保高度传递
2026-06-01 13:29:57 +08:00
dac3ebb5a7 fix: 重写show_llms_cards_by_provider.ui修复500错误
- 移除Jinja2列表推导式(可能有兼容性问题)
- 改用inline if过滤,更安全可靠
- 添加|string确保类型安全的比较
- 处理description为None的情况(llm.description or '')
2026-06-01 13:26:27 +08:00
2f75784ea6 fix: 模型广场左侧导航点击右侧不更新 — target加app.前缀+mode:replace
根因:左侧按钮与右侧面板是HBox下的兄弟关系,非父子关系。
bricks框架要求兄弟间引用需加app.前缀才能正确寻址。
同时添加mode:replace确保每次点击替换旧内容。
2026-06-01 13:20:16 +08:00
2b30a3f0dc feat: 模型广场改为左右分栏布局,左侧分类/供应商导航,右侧模型卡片 2026-06-01 11:56:00 +08:00
cfa355a7a5 refactor: 删除自定义llm_list.dspy,改用CRUD自动生成的get_llm.dspy 2026-05-31 20:01:41 +08:00
a228095220 fix: get_organizations/get_upapps返回providerid_text/upappid_text格式匹配alters字段名 2026-05-31 19:56:35 +08:00
93e3f17a67 fix: 恢复providerid/upappid的alters配置,llm_list.dspy返回_text字段用于列表展示 2026-05-31 19:53:19 +08:00
9019f6c48e fix: 仅删除llm.json中providerid和upappid的alters,恢复其他文件 2026-05-31 19:41:24 +08:00
c345238eaa fix: 删除browserfields中的alters配置 2026-05-31 19:40:18 +08:00
d4406a60fd Revert "fix: llm_list.dspy用JOIN查询返回upappid_text和providerid_text供前端code列显示"
This reverts commit 5c021b81cba6da9c08954d2c5e075cd1e83f45cf.
2026-05-31 19:26:32 +08:00
5c021b81cb fix: llm_list.dspy用JOIN查询返回upappid_text和providerid_text供前端code列显示 2026-05-31 15:59:21 +08:00
57d77dc819 fix: status字段改用uitype:code+data格式,移除providerid/upappid的废弃属性textField/valueField 2026-05-31 15:48:09 +08:00
2f2841c16c fix: 为providerid和upappid的alter添加显式的textField和valueField 2026-05-31 15:46:39 +08:00
37c6814b2d fix: 移除get_organizations的过滤条件,返回所有组织;添加错误日志 2026-05-31 15:41:21 +08:00
314da7ae44 fix: get_upapps和get_organizations用属性访问替代字典访问(sqlExe返回对象) 2026-05-31 15:36:13 +08:00
dc007a30a9 feat: 模型广场页面 - 新增 model_plaza.ui (TabPanel按分类/按供应商) - 新增 model_plaza.css (卡片悬浮效果/间距优化) - show_llms/show_llms_by_providers 添加 plaza-card/plaza-grid CSS - load_path.py 注册权限 2026-05-31 11:08:10 +08:00
ce5cfc4463 feat: add customer role RBAC permissions for v1 API endpoints
Grant customer.admin and customer.user roles access to llmage v1 API:
- /v1/chat/completions
- /v1/video/generations
- /v1/image/generations
- /v1/models
- /v1/tasks

Updated both load_path.py and setup_llmage_perms.sh
2026-05-31 09:05:07 +08:00
022269040f feat: load_path添加api_doc.ui和api_doc.md权限路径 2026-05-31 09:03:17 +08:00
1dc7df71ef feat: 添加大模型API文档页面供客户查阅
- api_doc.ui: MarkdownViewer渲染API文档
- api_doc.md: 从docs/API.md复制的v1接口文档
- 包含chat/completions、video/generations、image/generations、models、tasks接口说明
2026-05-31 09:02:23 +08:00
45458159d4 docs: 快乐马 I2V 图片参数名更正为 image_file(非 image_url) 2026-05-30 23:15:52 +08:00
a7099d37f3 docs: add video model-specific input parameters to API.md
Extracted input parameters from video-model-api-doc.md for each platform:
- Vidu: T2V, I2V, 2I2V, Ref2V (v1/v2 subject/non-subject modes)
- Seedance: T2V, TI2V, Ref2V
- Tongyi/DashScope: T2V, I2V, 2I2V, Ref2V, IA2V
- Kling: T2V
- Hailuo/MiniMax: TI2V
- HappyHorse: T2V, I2V, Ref2V
2026-05-30 22:45:51 +08:00
fa99d04595 fix: reduce module card height (remove cheight, compact padding/icons) 2026-05-30 21:20:46 +08:00
5d52d02319 refactor: V1 API 支持新 catelogid 缩写(t2t/t2v/t2i等),向后兼容中文名
- v1/chat/completions: 默认值 '文生文' → 't2t',SQL 匹配 b.id OR b.name
- v1/video/generations: SQL 匹配 b.id OR b.name,注释示例更新
- v1/image/generations: SQL 匹配 b.id OR b.name,注释示例更新
- t2t/index.dspy: 默认值 '文生文' → 't2t',SQL 匹配 b.id OR b.name
- get_type_llms.dspy: 硬编码中文名改为 alias 映射(t2v/i2v/r2v)
- docs/API.md: 添加完整 catelogid ID 对照表,示例参数更新
2026-05-30 20:36:16 +08:00
08bebcd257 refactor: v1 API 统一使用 catelogid 参数替代 lctype/llmcatelogid 2026-05-30 16:22:21 +08:00
f32f49fb85 bugix 2026-05-30 16:13:45 +08:00
62dce1d3d7 chore: remove build/ from git tracking, add to .gitignore 2026-05-30 13:54:44 +08:00
5ec5946a90 fix: derive sage_root from script location instead of hardcoded path 2026-05-30 13:41:01 +08:00
e494c88977 fix: migrate_llmcatelog_ids script missing database config init
DBPools() was called without config.databases, causing NoneType error.
Load config from sage_root and pass config.databases like the other
migration script does.
2026-05-30 13:30:50 +08:00
7e4069f3b6 refactor: get_llm() uses get_llmage_llm() + cached uapi/uapiio lookups
Replace 6-table JOIN with 3-step approach:
1. get_llmage_llm() for base info (llm + llm_api_map + llmcatelog)
2. Cached uapi lookup (ioid, stream, callbackurl)
3. Cached uapiio lookup (input_fields)

Benefits:
- Code reuse: eliminates duplicate SQL
- Performance: uapi/uapiio cached with 5min TTL
- Maintainability: separate concerns for model info vs API config
- Adds invalidate_uapi_cache() for config changes
2026-05-30 12:19:42 +08:00
3ba1c50eb6 feat: add llmcatelog ID migration script (random IDs to meaningful abbreviations) 2026-05-30 12:07:19 +08:00
d84cc1d859 fix: API返回纯数组格式,添加模型上下架功能
- get_organizations/get_upapps/uapi_options: 返回纯数组[{value,text}]
- 新增 get_catelogs/get_apis/get_ppids: 独立下拉数据API
- llm_api_map_manage.ui: 使用独立API替代data_field嵌套格式
- json/llm.json: 移除data_field,添加上架/下架toolbar
- llm_status_update.dspy: 模型上下架状态更新API
- 重新生成 llm/index.ui CRUD界面
2026-05-30 01:43:09 +08:00
283b7d498c fix: wrap Tabular in VBox with cheight for proper scrolling 2026-05-29 22:09:51 +08:00
42f3a41b06 fix: remove duplicate get_llmage_llm (user's buggy version), keep corrected version 2026-05-29 17:44:06 +08:00
93ec47f198 refactor: add get_llmage_llm() for non-API-call llm data access, replace get_llm() in accounting and pricing 2026-05-29 17:41:43 +08:00
cef4859574 Merge branch 'main' of git.opencomputing.cn:yumoqing/llmage 2026-05-29 17:23:54 +08:00
4cc818b98b bugfix 2026-05-29 17:22:08 +08:00
adb0bafc0a chore: remove CRUD definition dirs from tracking, add gitignore 2026-05-29 13:18:20 +08:00
44d94dace5 refactor: optimize debug output - use debug_params for compact logging, truncate SQL; add CRUD definitions 2026-05-29 12:07:53 +08:00
c65cf35a85 refactor: use wildcard % in load_path.py for auto-coverage 2026-05-29 00:52:19 +08:00
eed21ce6a5 fix: responsive UI with VScrollPanel, cfontsize, css:card 2026-05-29 00:13:08 +08:00
d6e4221a7b feat: add model publish/unpublish (上架/下架) functionality
- llm table: add status field (published/unpublished, default unpublished)
- User-facing queries: filter by status='published' in 11 query points:
  - utils.py: get_llms_by_catelog_to_customer, get_llms_by_catelog,
    get_llm, get_llmproviders, get_llms_sort_by_provider
  - v1 endpoints: chat/completions, image/generations, video/generations
  - user pages: t2t, get_type_llms, list_catelog_models,
    list_paging_catelog_llms, llmcheck
- CRUD: status column visible/editable with select dropdown
- Admin CRUD list shows ALL models regardless of status
- Migration SQL: sql/add_status_field.sql (existing models set to published)
2026-05-28 23:42:29 +08:00
cb5efd5550 Revert "fix: 展平嵌套usage数据以支持pricing引擎点号路径查找"
This reverts commit 71626468e2cc701410801db31c5879ce6dfdb59f.
2026-05-28 16:58:37 +08:00
71626468e2 fix: 展平嵌套usage数据以支持pricing引擎点号路径查找
根因:pricing引擎的config_data.get(k)是平面dict查找,
当k='prompt_tokens_details.cached_tokens'时无法从嵌套结构取值。
在llm_charging()中将prompt_tokens_details和completion_tokens_details
的子键展平为顶层key(如'prompt_tokens_details.cached_tokens')。
2026-05-28 16:57:08 +08:00
534e4fe8e0 fix: remove hardcoded dark theme colors from stat cards for light theme support 2026-05-28 16:16:07 +08:00
65d5020fc7 bugfix 2026-05-27 13:41:48 +08:00
d44c2dae74 refactor(models): convert to json format per database-table-definition-spec 2026-05-27 13:23:28 +08:00
59d3c406ab fix: remove hardcoded dark theme colors from index.ui and show_llms pages
- Remove bgcolor/color/border hardcoded dark theme values from index.ui
- Use css:'card' class instead of inline bgcolor for navigation cards
- Remove conflicting bgcolor:#def0f0 from show_llms/show_llms_by_providers/show_same_catelog_llm
- Let system theme (bricks.css/shell_theme.css) handle styling
2026-05-27 11:31:13 +08:00
a4e3411584 feat: add scripts/load_path.py for RBAC permission management
- Migrate all llmage permission entries from sage/load_path.py
- Include new data_filter API endpoints (llm_list/create/update/delete, get_organizations, get_upapps)
- Include all existing v1, api, CRUD directory, and page endpoints
- Follow product_management/scripts/load_path.py pattern
2026-05-26 14:32:44 +08:00
9aa917bce5 feat: add data_filter and CRUD endpoints for llm table
- Add data_filter with 4 searchable fields (name LIKE, model LIKE, providerid, upappid)
- Add filter_labels for search form display
- Create llm_list.dspy with DBFilter support and LIKE wildcard handling
- Create llm_create.dspy, llm_update.dspy, llm_delete.dspy
- Create get_organizations.dspy and get_upapps.dspy for dropdown options
- Add browserfields alters for providerid and upappid dropdowns
- Add editable URLs for DataViewer CRUD operations
2026-05-26 14:26:38 +08:00
04913dbe42 refactor: 3-part layout - title (fixed), tab header/cards (fixed), content (filler, scrollable) 2026-05-26 13:42:44 +08:00
ca51e168dc docs: add API documentation for all /v1 endpoints
- docs/API.md: comprehensive API docs covering:
  - POST /v1/chat/completions (text generation)
  - POST /v1/video/generations (video generation, new)
  - POST /v1/image/generations (image generation, new)
  - GET /v1/tasks (async task status)
  - GET /v1/models (list available models)
  - Authentication, balance check, and billing notes
2026-05-26 13:10:05 +08:00
e9a20a091f revert: restore self-orgid balance bypass in checkCustomerBalance()
The 'if llm.ownerid == userorgid: return True' shortcut is correct
behavior — own organization's models should not require balance check.
2026-05-26 12:05:46 +08:00
265702b894 fix: remove self-orgid balance bypass in checkCustomerBalance()
Removed the 'if llm.ownerid == userorgid: return True' shortcut from
checkCustomerBalance() in llmage/accounting.py. All requests now go
through the full balance check regardless of whether the model belongs
to the caller's organization.
2026-05-26 12:02:23 +08:00
f151ad2c30 fix: add dark mode background (#0B1120) to llmage index.ui 2026-05-26 11:56:45 +08:00
146ebb2b4a feat: add /v1/video/generations and /v1/image/generations API endpoints
- wwwroot/v1/video/generations/index.dspy: video generation endpoint
  Required params: model, llmcatelogid, prompt
  Supports async task submission via existing inference infrastructure

- wwwroot/v1/image/generations/index.dspy: image generation endpoint
  Required params: model, llmcatelogid, prompt
  Supports both sync and async models depending on config

Both endpoints follow the same pattern as /v1/chat/completions:
  1. Validate required params (model + llmcatelogid + prompt)
  2. Look up llm via llm_api_map join with catalog type
  3. Check customer balance
  4. Route to inference (async/sync based on model config)
2026-05-26 11:45:37 +08:00
b558059dc8 Merge feat/dataviz-llmage: add llmage module stat cards
- Create stats.py with get_llmage_stats() helper function
- Add 4 stat widgets: stat_total_models, stat_today_calls, stat_today_amount, stat_catelog_count
- Update index.ui to display stat cards row above navigation cards
- Register get_llmage_stats in load_llmage()
2026-05-26 11:27:29 +08:00
9364989be3 Merge feat/modern-ui-llmage: modernize llmage index.ui with standardized card navigation
- Replace hardcoded colors with modern #1E293B card style
- Add 12px borderRadius to match design system
- Standardize SVG icons (36px, 1.5 stroke width)
- Fix entire_url paths to use /llmage/ module prefix
- Add page header with Title2 + description text
2026-05-26 11:27:23 +08:00
fd6d17e3c2 feat: add llmage module stat cards - model count, today's usage, amount, catalog count
- Create stats.py with get_llmage_stats() helper function
- Add 4 stat widgets: stat_total_models, stat_today_calls, stat_today_amount, stat_catelog_count
- Update index.ui to display stat cards row above navigation cards
- Register get_llmage_stats in load_llmage()
2026-05-25 18:48:09 +08:00
100 changed files with 6587 additions and 731 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
__pycache__/
# CRUD definition directories (auto-generated by Sage platform)
wwwroot/llm/
wwwroot/llm_api_map/
wwwroot/llmcatelog_list/
wwwroot/llmusage/
wwwroot/llmusage_accounting_failed/
!wwwroot/llmusage_accounting_failed/recover_usages.dspy
wwwroot/llmusage_history/
build/

View File

@ -278,6 +278,74 @@ tasks = await get_today_asynctask_list(userid)
await query_task_status(request, luid, onetime=False) await query_task_status(request, luid, onetime=False)
``` ```
### 历史推理记录查询
`GET /llmage/api/get_inference_history.dspy`
跨表llmusage + llmusage_history分页查询当前用户的推理历史按时间倒序返回默认每页 10 条。自动通过 FileStorage 读取 ioinfo 文件内容,返回实际输入输出。
**请求参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认 1 |
| pagerows | int | 否 | 每页条数,默认 10 |
| llmcatelogid | str | 否 | 按模型分类 ID 过滤,仅返回该分类下模型的记录 |
**返回字段**
| 字段 | 说明 |
|------|------|
| success | 是否成功 |
| total | 两表合计总记录数 |
| page | 当前页码 |
| page_size | 每页条数(默认 10可通过 pagerows 参数指定) |
| rows | 记录列表 |
**rows 中每条记录**
| 字段 | 说明 |
|------|------|
| id | 记录 ID |
| llmid | 模型 ID |
| use_date | 使用日期 |
| use_time | 使用时间(排序依据) |
| userid | 用户 ID |
| usages | token 用量JSON 对象) |
| status | 调用状态ok/failed 等) |
| ioinfo | 原始 webpath |
| io_content | 解析后的输入输出内容,包含 input 和 output读取失败时为 null |
| amount | 费用金额 |
| userorgid | 组织 ID |
| accounting_status | 记账状态 |
**返回示例**
```json
{
"success": true,
"rows": [
{
"id": "abc123",
"llmid": "model001",
"use_date": "2026-06-05",
"use_time": "2026-06-05 12:30:00",
"userid": "user001",
"usages": {"total_tokens": 1000, "prompt_tokens": 800, "completion_tokens": 200},
"status": "ok",
"io_content": {"input": [...], "output": [...]},
"amount": 0.05,
"accounting_status": "accounted"
}
],
"total": 156,
"page": 1,
"page_size": 50
}
```
**权限**logined所有已登录用户仅返回当前登录用户自己的记录。
--- ---
## 前端页面 ## 前端页面

801
docs/API.md Normal file
View File

@ -0,0 +1,801 @@
# llmage API 文档
Base Path: `/llmage/v1`
所有 API 端点需要 Bearer Token 认证(`logined` 权限)。
---
## POST /v1/chat/completions
文本生成接口,兼容 OpenAI 格式。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"qwen3-max"` |
| `messages``prompt` | array / string | 对话消息数组或文本提示 |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `catelogid` | string | 目录类型ID默认 `"t2t"`,也支持中文名(向后兼容) |
| `stream` | boolean | 是否启用流式输出 |
| `off_peak` | boolean | 是否使用非高峰时段 |
| `transno` | string | 交易流水号(不传则自动生成) |
### 请求示例
```json
{
"model": "qwen3-max",
"messages": [
{"role": "user", "content": "Hello"}
],
"stream": false
}
```
### 响应格式
**非流式响应:**
```json
{
"id": "luid_xxx",
"object": "chat.completion",
"model": "qwen3-max",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hi there!"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
}
```
**流式响应 (SSE):**
```
data: {"choices": [{"delta": {"content": "Hi"}, "index": 0}]}
data: {"choices": [{"delta": {"content": " there!"}, "index": 0}]}
data: [DONE]
```
### 错误响应
| 状态码 | 说明 |
|--------|------|
| 400 | 缺少必填参数或模型不存在 |
| 403 | 未登录 |
| 429 | 账户余额不足 |
---
## POST /v1/video/generations
视频生成接口。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"keling-2.1"` |
| `catelogid` | string | 目录类型ID`"t2v"` / `"i2v"` / `"r2v"` |
| `prompt` | string | 生成提示词 |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `image_url` | string | 图生视频时提供参考图 URL |
| `duration` | string | 视频时长,如 `"5s"` |
| `resolution` | string | 分辨率,如 `"1080p"` |
| `n` | integer | 生成数量 |
| `transno` | string | 交易流水号 |
### 请求示例
```json
{
"model": "keling-2.1",
"catelogid": "t2v",
"prompt": "A beautiful sunset over the ocean",
"duration": "5s",
"resolution": "1080p"
}
```
### 响应格式
视频生成通常为异步任务,提交后返回任务信息:
```json
{
"id": "luid_xxx",
"object": "video.generation",
"model": "keling-2.1",
"status": "submitted",
"taskid": "task_xxx",
"created": 1716912000
}
```
通过 `/v1/tasks?taskid=xxx` 查询任务状态。
### 各模型输入参数明细
> 以下为各平台/模型的具体输入参数。调用时通过 `model` + `catelogid` 自动路由到对应供应商。
---
#### Vidu 平台
##### T2V - 文生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | `viduq3-pro` | 模型名称 | `viduq3-turbo`, `viduq3-pro` |
| `prompt` | string | 是 | - | 提示词 | - |
| `off_peak` | string | 否 | `N` | 错峰执行 | `Y`, `N` |
| `duration` | integer | 否 | `10` | 视频长度1-16秒 | 1-16 |
| `ratio` | string | 否 | `16:9` | 长宽比 | `16:9`, `9:16`, `4:3`, `3:4`, `1:1` |
| `resolution` | string | 否 | `1080p` | 分辨率 | `540p`, `720p`, `1080p` |
##### I2V - 图生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | `viduq3-pro` | 模型名称 | `viduq3-pro`, `viduq3-turbo` |
| `prompt` | string | 是 | - | 提示词 | - |
| `image_file` | image | 是 | - | 首帧图片 | - |
| `off_peak` | string | 否 | `N` | 错峰执行 | `Y`, `N` |
| `duration` | integer | 否 | `10` | 视频长度1-16秒 | 1-16 |
| `ratio` | string | 否 | `16:9` | 长宽比 | `16:9`, `9:16`, `4:3`, `3:4`, `1:1` |
| `resolution` | string | 否 | `1080p` | 分辨率 | `540p`, `720p`, `1080p` |
##### 2I2V - 首尾帧生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `model` | string | 否 | `viduq2` | 模型名称 |
| `payload` | string | 是 | `2i2v` | 固定值 |
| `off_peak` | boolean | 否 | `false` | 错峰模式 |
| `images` | array | 是 | - | 两张图片URL `[首帧, 尾帧]` |
| `duration` | integer | 否 | `10` | 视频时长 |
| `prompt` | string | 是 | - | 提示词 |
| `audio` | boolean | 否 | `true` | 音频直出 |
| `seed` | integer | 否 | `12345` | 随机种子 |
| `aspect_ratio` | string | 否 | `16:9` | 画面比例 |
| `resolution` | string | 否 | `1080p` | 分辨率 |
##### Ref2V - 参考生视频 v2主体模式
> 使用主体(图片/视频/文字)生成视频,支持 viduq3-turbo/q3/q2-pro/q2/q1/2.0
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `model` | string | 是 | 模型名称 |
| `subjects` | array | 是 | 主体列表最多7个图片/文字主体每个主体最多3张图 |
| `prompt` | string | 是 | 提示词 |
| `audio` | boolean | 否 | 音视频直出 |
| `audio_type` | string | 否 | 音频类型 |
| `duration` | integer | 否 | 视频时长 |
| `seed` | integer | 否 | 随机种子 |
| `aspect_ratio` | string | 否 | 画面比例 |
| `resolution` | string | 否 | 分辨率 |
| `movement_amplitude` | string | 否 | 运动幅度 |
| `off_peak` | boolean | 否 | 错峰模式 |
| `auto_subjects` | boolean | 否 | 智能主体 |
##### Ref2V - 参考生视频 v2非主体模式
> 直接上传图片参考生成视频,支持 viduq3-mix/q3-turbo/q3/q2-pro/q2/q1/2.0
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `model` | string | 是 | 模型名称 |
| `images` | array | 是 | 参考图片URL列表1-7张 |
| `videos` | array | 否 | 参考视频URL列表仅viduq2-pro |
| `prompt` | string | 是 | 提示词 |
| `audio` | boolean | 否 | 音视频直出 |
| `bgm` | boolean | 否 | 背景音乐 |
| `duration` | integer | 否 | 视频时长 |
| `seed` | integer | 否 | 随机种子 |
| `aspect_ratio` | string | 否 | 画面比例 |
| `resolution` | string | 否 | 分辨率 |
| `off_peak` | boolean | 否 | 错峰模式 |
##### Ref2V - 参考生视频 v1
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | `viduq2-pro` | 模型名称 | `viduq2`, `viduq1`, `vidu2.0` |
| `prompt` | string | 是 | - | 提示词 | - |
| `off_peak` | string | 否 | `N` | 错峰执行 | `Y`, `N` |
| `duration` | integer | 否 | `10` | 视频长度 | - |
| `ratio` | string | 否 | `16:9` | 长宽比 | `16:9`, `9:16`, `4:3`, `3:4`, `1:1` |
| `resolution` | string | 否 | `1080p` | 分辨率 | `540p`, `720p`, `1080p` |
---
#### Seedance 平台(火山方舟)
##### T2V - 文生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | `doubao-seedance-2-0-260128` | 模型名称 | `doubao-seedance-2-0-260128`, `doubao-seedance-2-0-fast-260128` |
| `prompt` | string | 是 | - | 提示词 | - |
| `resolution` | string | 否 | `720p` | 尺寸 | `480p`, `720p`, `1080p` |
| `duration` | integer | 否 | `8` | 视频长度 | - |
| `ratio` | string | 否 | `1:1` | 宽高比 | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `9:21` |
##### TI2V - 文图生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | `doubao-seedance-2-0-260128` | 模型名称 | `doubao-seedance-2-0-260128`, `doubao-seedance-2-0-fast-260128` |
| `prompt` | string | 是 | - | 提示词 | - |
| `image1_file` | image | 是 | - | 首帧图片 | - |
| `image2_file` | image | 否 | - | 尾帧图片 | - |
| `resolution` | string | 否 | `720p` | 尺寸 | `480p`, `720p`, `1080p` |
| `duration` | integer | 否 | `8` | 视频长度 | - |
| `ratio` | string | 否 | `1:1` | 宽高比 | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `9:21` |
##### Ref2V - 参考生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `model` | string | 是 | - | 模型名称 |
| `prompt` | string | 是 | - | 提示词 |
| `image_file` | image | 否 | - | 参考图片(支持数组,多张参考图) |
| `video_file` | video | 否 | - | 参考视频(支持数组) |
| `audio_file` | audio | 否 | - | 参考音频(支持数组) |
| `duration` | integer | 否 | `12` | 视频长度 |
| `resolution` | string | 否 | `720p` | 尺寸 |
| `ratio` | string | 否 | - | 宽高比 |
---
#### 通义万象DashScope
##### T2V - 文生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `model` | string | 是 | - | 模型名称(如 `wan2.6-t2v` |
| `prompt` | string | 是 | - | 提示词 |
| `negative_prompt` | string | 否 | - | 反向提示词 |
| `audio_file` | audio | 否 | - | 配音文件 |
| `size` | string | 否 | `1920*1080` | 视频尺寸 |
| `duration` | string | 否 | `15` | 视频时长 |
**size 可选值:** `832*480`, `480*832`, `624*624`, `1280*720`, `720*1280`, `960*960`, `1088*832`, `832*1088`, `1920*1080`, `1080*1920`, `1440*1440`, `1632*1248`, `1248*1632`
**duration 可选值:** `5`, `10`, `15`
##### I2V - 图生视频
可用模型:`wan2.6-i2v`, `wan2.6-i2v-flash`
> 输入参数与 T2V 类似,额外需要首帧图片。
##### 2I2V - 首尾帧生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `model` | string | 是 | - | 模型名称 |
| `prompt` | string | 是 | - | 提示词 |
| `negative_prompt` | string | 否 | - | 反向提示词 |
| `image1_file` | image | 是 | - | 首帧图片 |
| `image2_file` | image | 是 | - | 尾帧图片 |
| `resolution` | string | 否 | `1080P` | 分辨率 |
| `duration` | integer | - | `5` | 固定5秒 |
##### Ref2V - 角色参考生视频
> 参考输入视频中的角色形象和音色搭配提示词生成保持角色一致性的视频。可以输入1-3个人物视频每个视频一个角色。
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `model` | string | 是 | - | 模型名称(如 `wan2.6-r2v` |
| `prompt` | string | 是 | - | 提示词 |
| `video1_file` | video | 是 | - | 角色一视频 |
| `video2_file` | video | 否 | - | 角色二视频 |
| `video3_file` | video | 否 | - | 角色三视频 |
| `size` | string | 否 | `1920*1080` | 视频尺寸 |
| `duration` | string | 否 | `10` | 视频时长 |
**size 可选值:** 同 T2V
**duration 可选值:** `10`, `15`
##### IA2V - 图像音频生视频
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `image_file` | image | 是 | 图像 |
| `audio_file` | audio | 是 | 音频 |
---
#### 可灵Kling
##### T2V - 文生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `model` | string | 是 | - | 模型名称 | `kling-v2-1-master`, `kling-v2-master`, `kling-v1-6`, `kling-v1` |
| `prompt` | string | 是 | - | 提示词 | - |
| `negative_prompt` | string | 否 | - | 反向提示词 | - |
---
#### 海螺Hailuo/MiniMax
##### TI2V - 图生视频
| 参数名 | 类型 | 必填 | 默认值 | 说明 | 可选值 |
|--------|------|------|--------|------|--------|
| `prompt` | string | 是 | - | 提示词 | - |
| `image_file` | image | 否 | - | 首帧图片 | - |
| `image_file1` | image | 否 | - | 尾帧图片 | - |
| `resolution` | string | 否 | `768P` | 尺寸 | `768P`, `1080P` |
| `duration` | integer | 否 | `6` | 视频长度 | `6`6秒, `10`10秒 |
---
#### 快乐马HappyHorse
> 基于通义万象平台tongyi-wan输入参数与通义万象对应类型一致。
##### T2V - 文生视频
输入参数同通义万象 T2V。可用模型`happyhorse-1.0-t2v`
##### I2V - 图生视频
输入参数同通义万象 I2V。可用模型`happyhorse-1.0-i2v`
> **注意:** 图片参数名为 `image_file`(非 `image_url`),传入图片 URL。
##### Ref2V - 参考生视频
输入参数同通义万象 Ref2V额外支持
| 参数名 | 说明 |
|--------|------|
| `resolution` | 可选 `1080P`(默认), `720P` |
| `ratio` | 可选 `16:9`(默认), `9:16`, `3:4`, `4:3` |
可用模型:`happyhorse-1.0-r2v`参考图像数量1-9张支持多角色参考
---
## POST /v1/image/generations
图像生成接口。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"jimeng-4.0"` |
| `catelogid` | string | 目录类型ID`"t2i"` |
| `prompt` | string | 生成提示词 |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `image_url` | string | 图生图时提供参考图 URL |
| `size` | string | 尺寸,如 `"1024x1024"` |
| `n` | integer | 生成数量 |
| `style` | string | 风格参数 |
| `quality` | string | 质量参数 |
| `transno` | string | 交易流水号 |
### 请求示例
```json
{
"model": "jimeng-4.0",
"catelogid": "t2i",
"prompt": "A beautiful sunset over the ocean",
"size": "1024x1024",
"n": 1
}
```
### 响应格式
响应格式取决于上游模型配置(同步返回图像数据,异步返回任务信息):
```json
{
"id": "luid_xxx",
"object": "image.generation",
"model": "jimeng-4.0",
"status": "submitted",
"taskid": "task_xxx",
"created": 1716912000
}
```
---
## POST /v1/music/generations
音乐生成接口。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"music-2.6"`, `"music-2.5"` |
| `catelogid` | string | 目录类型ID固定为 `"music_gen"` |
| `prompt` | string | 音乐风格描述(风格、情绪、场景),如 `"流行音乐, 开心, 适合阳光明媚的下午"` |
| `lyrics` | string | 歌词内容,使用 `\n` 分隔每行,可包含结构标签 |
### 歌词结构标签
歌词中可包含以下结构标签来优化生成的音乐结构:
- `[Intro]` - 前奏
- `[Verse]` - 主歌
- `[Pre Chorus]` - 预副歌
- `[Chorus]` - 副歌
- `[Bridge]` - 桥段
- `[Outro]` - 尾声
- `[Interlude]` - 间奏
- `[Hook]` - 记忆点
- `[Build Up]` - 情绪铺垫
- `[Solo]` - 独奏
### 请求示例
```json
{
"model": "music-2.6",
"catelogid": "music_gen",
"prompt": "Pop music, happy, suitable for a sunny day",
"lyrics": "[Intro]\n\n[Verse]\nWalking down the street\nFeeling the beat\n\n[Chorus]\nDancing in the sun\nHaving so much fun"
}
```
### 响应格式
MiniMax 音乐生成为同步接口直接返回音频URL
```json
{
"id": "luid_xxx",
"object": "music.generation",
"model": "music-2.6",
"status": "SUCCEEDED",
"audio": "https://...",
"created": 1716912000
}
```
### 可用模型
| 模型名称 | model 参数 | 说明 |
|---------|-----------|------|
| MiniMax Music 2.6 | `music-2.6` | 最新版本,音质最佳 |
| MiniMax Music 2.5 | `music-2.5` | 支持14种段落级结构标签物理级高保真 |
### MiniMax Music 2.5 特性
Music 2.5 在「段落级强控制」与「物理级高保真」两大技术难题上实现突破:
- 开放全段落标签控制精准支持14种结构变体
- 长度限制:歌词内容 [1, 3500] 个字符
- prompt 长度限制:[10, 300] 个字符
### MiniMax Music 2.0 特性(已过期)
Music 2.0 能根据文本描述和歌词直接生成包含人声的完整歌曲:
- prompt 长度限制:[10, 300] 个字符
- lyrics 长度限制:[10, 3000] 个字符
- 状态已过期expired_date: 2026-01-01
### 错误响应
| 状态码 | 说明 |
|--------|------|
| 400 | 缺少必填参数或模型不存在 |
| 403 | 未登录 |
| 429 | 账户余额不足 |
---
## POST /v1/audio/speech
文本转语音TTS接口。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"speech-2.6-turbo"`, `"speech-2.6-hd"` |
| `catelogid` | string | 目录类型ID固定为 `"tts"` |
| `prompt` | string | 需要合成的文本内容,最长 10,000 字符 |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `speaker` | string | 说话人/音色ID`"female-tianmei"` |
| `speed` | float | 语速,默认 `1.0` |
| `emotion` | string | 情感,如 `"happy"`, `"sad"` |
| `transno` | string | 交易流水号 |
### 请求示例
```json
{
"model": "speech-2.6-turbo",
"catelogid": "tts",
"prompt": "你好,欢迎使用语音合成服务",
"speaker": "female-tianmei",
"speed": 1.0,
"emotion": "happy"
}
```
### 响应格式
MiniMax TTS 为流式接口逐块返回音频数据hex编码自动转base64
```json
{
"status": "SUCCEEDED",
"audio": "base64_encoded_audio_data"
}
```
### 可用模型
| 模型名称 | model 参数 | 说明 |
|---------|-----------|------|
| MiniMax Speech 2.6 Turbo | `speech-2.6-turbo` | 极速版,更快更优惠,适用于语音聊天和数字人 |
| MiniMax Speech 2.6 HD | `speech-2.6-hd` | 高清版,超低延时,更高自然度 |
| MiniMax Speech 2.5 HD | `speech-2.5-hd-preview` | Preview版本 |
| F5-TTS 本地 | `f5tts` | 本地部署,零样本声音克隆,多语言支持 |
### 错误响应
| 状态码 | 说明 |
|--------|------|
| 400 | 缺少必填参数或模型不存在 |
| 403 | 未登录 |
| 429 | 账户余额不足 |
---
## POST /v1/audio/transcriptions
语音识别ASR接口将音频转为文本。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"qwen3-asr-flash"`, `"parakeet-tdt-0.6b-v2"` |
| `catelogid` | string | 目录类型ID固定为 `"asr"` |
| `audio_file` | string | 音频文件URL |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `language` | string | 语言代码(部分模型支持) |
| `transno` | string | 交易流水号 |
### 请求示例
```json
{
"model": "qwen3-asr-flash",
"catelogid": "asr",
"audio_file": "https://example.com/audio.wav"
}
```
### 响应格式
```json
{
"text": "识别出的文本内容",
"usage": {
"duration_seconds": 5.2
}
}
```
### 可用模型
| 模型名称 | model 参数 | 说明 |
|---------|-----------|------|
| 通义千问 ASR | `qwen3-asr-flash` | 多语种识别、歌唱识别、情感识别、噪声拒识0.00026元/秒 |
| Nvidia ASR | `parakeet-tdt-0.6b-v2` | 仅支持英文6亿参数支持标点/大小写/时间戳 |
### 通义千问 ASR 核心功能
- 多语种识别:涵盖普通话及多种方言(粤语、四川话等)
- 复杂环境适应:自动语种检测与智能非人声过滤
- 歌唱识别伴随BGM下也能实现整首歌曲转写
- 上下文增强:通过配置上下文提高识别准确率
- 情感识别:支持惊讶、平静、愉快、悲伤、厌恶、愤怒、恐惧
### 错误响应
| 状态码 | 说明 |
|--------|------|
| 400 | 缺少必填参数或模型不存在 |
| 403 | 未登录 |
| 429 | 账户余额不足 |
---
## GET /v1/tasks
查询异步任务状态。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `taskid` | string | 任务 ID |
### 请求示例
```
GET /llmage/v1/tasks?taskid=task_xxx
```
### 响应格式
```json
{
"status": "ok",
"data": {
"status": "SUCCEEDED",
"output": [...]
}
}
```
任务状态值: `UNKNOWN` / `SUCCEEDED` / `FAILED`
---
## GET /v1/models
列出可用模型列表。
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `catelogid` | string | 按目录类型过滤 |
| `orderby` | string | 排序字段 |
### 请求示例
```
GET /llmage/v1/models
```
### 响应格式
```json
{
"object": "list",
"data": [
{
"id": "qwen3-max",
"object": "model",
"created": 1748044800,
"owned_by": "opencomputing.ai"
}
]
}
```
---
## GET /v1/pricing
获取模型定价展示信息。
### 必填参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `model` | string | 模型名称,如 `"qwen3.7-max"` |
### 可选参数
| 参数 | 类型 | 说明 |
|------|------|------|
| `catelogid` | string | 目录类型ID默认 `"t2t"` |
### 请求示例
```
GET /llmage/v1/pricing?model=qwen3.7-max
```
### 响应格式
```json
{
"status": "ok",
"data": {
"ppid": "pp_xxx",
"name": "qwen3.7-max",
"pricing_type": "per_use",
"display_text": "【通义千问 qwen3.7-max】定价:\n - 输入Token: 12 元/百万 [模型=qwen3.7-max]\n - 输出Token: 48 元/百万 [模型=qwen3.7-max]",
"items": [...]
}
}
```
### 错误响应
| 状态 | 说明 |
|------|------|
| error | 缺少 model 参数 |
| error | 模型不存在或无定价配置 |
---
## 通用说明
### catelogid 目录类型ID对照表
| ID | 中文名 | 说明 |
|----|--------|------|
| `t2t` | 文生文 | 文本生成(默认) |
| `t2i` | 文生图 | 图像生成 |
| `t2v` | 文生视频 | 文本生成视频 |
| `i2v` | 图生视频 | 图像生成视频 |
| `r2v` | 参考生视频 | 参考图像生成视频 |
| `tts` | 语音合成 | 文本转语音 |
| `asr` | 语音识别 | 语音转文本 |
| `vision` | 图理解 | 图像理解 |
| `ai_search` | AI搜索 | AI搜索 |
| `digital_human` | 数字人 | 数字人 |
| `music_gen` | 音乐生成 | 音乐生成 |
| `text_cls` | 文本分类 | 文本分类 |
| `3d_gen` | 3D生成 | 3D模型生成 |
| `video_tool` | 视频工具 | 视频处理工具 |
| `translate` | 翻译 | 文本翻译 |
> 向后兼容catelogid 参数同时支持新ID`"t2v"`)和旧中文名(如 `"文生视频"`推荐使用新ID。
### 参数统一
所有 v1 接口统一使用 `catelogid` 参数标识目录类型,替代原有的 `lctype` / `llmcatelogid`
### 认证
所有接口需要 Bearer Token 认证,请求头中携带:
```
Authorization: Bearer ***
```
### 余额检查
每次请求都会自动调用 `checkCustomerBalance()` 进行余额检查:
- 如果模型属于用户所在组织(`llm.ownerid == userorgid`),则跳过余额检查
- 否则检查 tpac 余额或本地余额
- 余额不足时返回 429 状态码
### 计费
请求成功后自动创建 `llmusage` 记录,状态为 `created`。后台定时任务会定期执行计费流程。

118
docs/vendor-minimax.md Normal file
View File

@ -0,0 +1,118 @@
# MiniMax 供应商接入记录
## 供应商信息
| 项目 | 值 |
|------|-----|
| 供应商名称 | MiniMax (上海稀宇科技有限公司) |
| 平台网址 | https://platform.minimaxi.com |
| API文档 | https://platform.minimaxi.com/docs/api-reference/text-chat-openai |
| 定价页面 | https://platform.minimaxi.com/subscribe/token-plan?tab=api-enterprise |
| API基础URL | https://api.minimaxi.com/v1 (upapp.baseurl) |
| 系统upappid | minimax |
| 系统providerid | ww4e_kfX3Lh65Sdys0Vku |
| API认证方式 | Bearer Token (Authorization: Bearer *** |
## 已接入模型 (共11个, 截至2026-06-12)
### 文本生成 (t2t) — 定价项目: 5jmzupARABxkDFwUraFiQ
| 模型名称 | model | llm.id | 状态 | httpapi |
|----------|-------|--------|------|---------|
| **MiniMax M3** | MiniMax-M3 | mm3_MiniMax_M3 | 新增 | minimax_openai t2t |
| MiniMax M2.7 | minimax-m2.7 | oiLvLl75qNX9IQkWFm60i | 已有 | t2t |
| **MiniMax M2.7 Highspeed** | MiniMax-M2.7-highspeed | mm_m27_highspeed | 新增 | minimax_openai t2t |
### 视频生成 (i2v) — 定价项目: 0V89eilc_UQ2KiZIRJO8M
| 模型名称 | model | llm.id | 状态 |
|----------|-------|--------|------|
| MiniMax Hailuo 2.3 | MiniMax-Hailuo-2.3 | AU1f40HV3tqFjxcVWWpyR | 已有, 补充ppid |
| 海螺参考生视频 | S2V-01 | oks-VG9D8p2b0Agvs-LeQ | 已有, 补充ppid |
### 语音合成 (tts) — 定价项目: mm_tts_pricing (新增)
| 模型名称 | model | llm.id | 状态 |
|----------|-------|--------|------|
| speech-2.6-hd | speech-2.6-hd | q6rdMUsGD1z3S3NyZh_A_ | 已有, 补充ppid |
| speech-2.6-turbo | speech-2.6-turbo | CEYD4YWRxjCj4k_6bpzIM | 已有, 补充ppid |
| speech-2.5-hd-preview | speech-2.5-hd-preview | Si2g0XJ9ym3P5jlrdmcfB | 已有, 补充ppid |
### 音乐生成 (music_gen) — 定价项目: fQzkUeS6t6NBz_Fu4Fi77
| 模型名称 | model | llm.id | 状态 |
|----------|-------|--------|------|
| Music 2.6 | music-2.6 | dleFKyYSSllCl70etn7yU | 已有 |
| Music 2.5 | music-2.5 | tTREa9nNy3yIRxywQLjvT | 已有 |
| Music 2.0 | music-2.0 | ns7egG9aXi91wjI62yKfu | 已有, 补充ppid |
## 定价信息
### 文本模型 (元/百万tokens) — 5jmzupARABxkDFwUraFiQ
| 模型 | 输入 | 输出 | 缓存 | 备注 |
|------|------|------|------|------|
| MiniMax-M3 (≤512K) | ¥2.1 | ¥8.4 | ¥0.42 | 永久五折 |
| MiniMax-M3 (512K~1M) | ¥4.2 | ¥16.8 | ¥0.84 | 永久五折 |
| MiniMax-M2.7 | ¥2.1 | ¥8.4 | - | 五折 |
| MiniMax-M2.7-highspeed | ¥4.2 | ¥16.8 | - | - |
| MiniMax-M2.5 | ¥2.1 | ¥8.4 | - | - |
| MiniMax-M2.5-highspeed | ¥4.2 | ¥16.8 | - | - |
| M2-her | ¥2.1 | ¥8.4 | - | - |
### TTS (元/万字符) — mm_tts_pricing
| 模型 | 单价 |
|------|------|
| speech-2.6-hd | ¥3.5 |
| speech-2.6-turbo | ¥2.0 |
| speech-2.5-hd-preview | ¥3.5 |
### 视频 (元/次) — 0V89eilc_UQ2KiZIRJO8M
| 模型 | 分辨率 | 时长 | 单价 |
|------|--------|------|------|
| Hailuo-2.3 | 768P | 6s | ¥2.00 |
| Hailuo-2.3 | 768P | 10s | ¥3.50 |
| Hailuo-2.3 | 1080P | 6s | ¥2.00 |
| Hailuo-2.3-Fast | 768P | 6s | ¥2.25 |
### 音乐 (元/次) — fQzkUeS6t6NBz_Fu4Fi77
| 模型 | 单价 |
|------|------|
| Music-2.6/2.5/2.0 | ¥1.0 |
## uapi配置 (uapi模块)
### minimax t2t (新增, id=mm_minimax_t2t)
- path: /chat/completions (upapp.baseurl拼接)
- 完整URL: https://api.minimaxi.com/v1/chat/completions
- ioid: Is8l4TGkcZcqFSjbbeIK2 (文本会话, 共享)
- stream: stream, chunk_match: data:
- headers: Bearer {{apikey}}, Content-Type: application/json
### minimax tm2t (新增, id=mm_minimax_tm2t)
- 多模态对话, 支持image_file/video_file/audio_file
- ioid: t-ujII59ku45tIPcdXu4O (文本媒体转文本, 共享)
- 与ali-qwen的tm2t模板相同(b64media2url处理)
## SQL文件
`scripts/minimax_m3_add.sql` — 包含11条SQL语句:
1. INSERT httpapi (minimax_openai t2t)
2. INSERT llm (MiniMax-M3)
3. INSERT llm (MiniMax-M2.7-highspeed)
4. INSERT llm_api_map (M3)
5. INSERT llm_api_map (M2.7-highspeed)
6. UPDATE llm_api_map ppid × 6 (视频/TTS/音乐)
7. INSERT pricing_program (mm_tts_pricing)
8. INSERT pricing_program_timing (TTS定价)
9. UPDATE 5jmzup timing (追加M3定价)
10. UPDATE 5jmzup spec (添加M3到模型选项)
## 变更记录
| 日期 | 操作 |
|------|------|
| 2026-06-12 | 新增M3+M2.7-highspeed, 补齐Hailuo/S2V/TTS/Music定价 |

127
i18n/en/msg.txt Normal file
View File

@ -0,0 +1,127 @@
模型管理: Model Management
模型名称: Model Name
模型编码: Model Code
模型类型: Model Type
模型提供商: Model Provider
模型版本: Model Version
模型状态: Model Status
API接口: API Interface
API密钥: API Key
API地址: API Endpoint
最大Token: Max Tokens
输入价格: Input Price
输出价格: Output Price
折扣: Discount
启用: Enable
停用: Disable
已启用: Enabled
已停用: Disabled
新增模型: Add Model
编辑模型: Edit Model
删除模型: Delete Model
测试模型: Test Model
模型分组: Model Group
分组名称: Group Name
分组描述: Group Description
新增分组: Add Group
编辑分组: Edit Group
删除分组: Delete Group
使用统计: Usage Statistics
调用次数: Call Count
成功次数: Success Count
失败次数: Failure Count
Token用量: Token Usage
输入Token: Input Tokens
输出Token: Output Tokens
总Token: Total Tokens
费用统计: Cost Statistics
总费用: Total Cost
本月费用: Monthly Cost
今日费用: Daily Cost
按模型统计: By Model
按用户统计: By User
按日期统计: By Date
趋势图: Trend Chart
日: Day
周: Week
月: Month
年: Year
用户管理: User Management
用户名称: User Name
用户Token配额: User Token Quota
已使用: Used
剩余配额: Remaining Quota
配额重置: Quota Reset
模型映射: Model Mapping
映射名称: Mapping Name
源模型: Source Model
目标模型: Target Model
映射状态: Mapping Status
新增映射: Add Mapping
编辑映射: Edit Mapping
删除映射: Delete Mapping
密钥管理: Key Management
密钥名称: Key Name
密钥值: Key Value
密钥状态: Key Status
新增密钥: Add Key
编辑密钥: Edit Key
删除密钥: Delete Key
日志: Log
请求日志: Request Log
错误日志: Error Log
请求时间: Request Time
响应时间: Response Time
耗时: Duration
状态码: Status Code
错误信息: Error Message
请求参数: Request Parameters
响应内容: Response Content
供应商: Vendor
所属机构: Organization
定价项目: Pricing Item
定价属于: Pricing Belongs To
供应商折扣: Vendor Discount
描述: Description
规格明细: Specification Details
项目名称: Item Name
模型: Model
API: API
定价: Pricing
时序: Timeline
开始日期: Start Date
结束日期: End Date
生效日期: Effective Date
失效日期: Expiration Date
定价数据: Pricing Data
定价项目时序: Pricing Item Timeline
测试: Test
定价测试: Pricing Test
新增: Add
保存: Save
取消: Cancel
确认: Confirm
删除: Delete
编辑: Edit
查看: View
导出: Export
打印: Print
刷新: Refresh
返回: Back
提交: Submit
重置: Reset
Conform: Confirm
Discard: Discard
Submit: Submit
Reset: Reset
Cancel: Cancel
搜索: Search
操作: Action
类型: Type
状态: Status
名称: Name
编码: Code
备注: Remark
创建时间: Created Time
更新时间: Updated Time
全部: All

127
i18n/jp/msg.txt Normal file
View File

@ -0,0 +1,127 @@
模型管理: モデル管理
模型名称: モデル名
模型编码: モデルコード
模型类型: モデルタイプ
模型提供商: モデルプロバイダー
模型版本: モデルバージョン
模型状态: モデル状態
API接口: APIインターフェース
API密钥: APIキー
API地址: APIエンドポイント
最大Token: 最大トークン
输入价格: 入力価格
输出价格: 出力価格
折扣: 割引
启用: 有効化
停用: 無効化
已启用: 有効
已停用: 無効
新增模型: モデル追加
编辑模型: モデル編集
删除模型: モデル削除
测试模型: モデルテスト
模型分组: モデルグループ
分组名称: グループ名
分组描述: グループ説明
新增分组: グループ追加
编辑分组: グループ編集
删除分组: グループ削除
使用统计: 使用統計
调用次数: 呼び出し回数
成功次数: 成功回数
失败次数: 失敗回数
Token用量: トークン使用量
输入Token: 入力トークン
输出Token: 出力トークン
总Token: 合計トークン
费用统计: コスト統計
总费用: 合計コスト
本月费用: 今月コスト
今日费用: 今日コスト
按模型统计: モデル別統計
按用户统计: ユーザー別統計
按日期统计: 日付別統計
趋势图: トレンドチャート
日: 日
周: 週
月: 月
年: 年
用户管理: ユーザー管理
用户名称: ユーザー名
用户Token配额: ユーザートークンクォータ
已使用: 使用済み
剩余配额: 残りクォータ
配额重置: クォータリセット
模型映射: モデルマッピング
映射名称: マッピング名
源模型: ソースモデル
目标模型: ターゲットモデル
映射状态: マッピング状態
新增映射: マッピング追加
编辑映射: マッピング編集
删除映射: マッピング削除
密钥管理: キー管理
密钥名称: キー名
密钥值: キー値
密钥状态: キー状態
新增密钥: キー追加
编辑密钥: キー編集
删除密钥: キー削除
日志: ログ
请求日志: リクエストログ
错误日志: エラーログ
请求时间: リクエスト時間
响应时间: レスポンス時間
耗时: 所要時間
状态码: ステータスコード
错误信息: エラーメッセージ
请求参数: リクエストパラメータ
响应内容: レスポンス内容
供应商: ベンダー
所属机构: 所属組織
定价项目: 価格設定項目
定价属于: 価格設定帰属
供应商折扣: ベンダー割引
描述: 説明
规格明细: 仕様詳細
项目名称: 項目名
模型: モデル
API: API
定价: 価格設定
时序: 時系列
开始日期: 開始日
结束日期: 終了日
生效日期: 有効開始日
失效日期: 有効終了日
定价数据: 価格設定データ
定价项目时序: 価格設定項目時系列
测试: テスト
定价测试: 価格設定テスト
新增: 新規追加
保存: 保存
取消: キャンセル
确认: 確認
删除: 削除
编辑: 編集
查看: 表示
导出: エクスポート
打印: 印刷
刷新: 更新
返回: 戻る
提交: 送信
重置: リセット
Conform: 確認
Discard: 破棄
Submit: 送信
Reset: リセット
Cancel: キャンセル
搜索: 検索
操作: 操作
类型: タイプ
状态: ステータス
名称: 名前
编码: コード
备注: 備考
创建时间: 作成日時
更新时间: 更新日時
全部: 全部

127
i18n/ko/msg.txt Normal file
View File

@ -0,0 +1,127 @@
模型管理: 모델 관리
模型名称: 모델 이름
模型编码: 모델 코드
模型类型: 모델 유형
模型提供商: 모델 제공자
模型版本: 모델 버전
模型状态: 모델 상태
API接口: API 인터페이스
API密钥: API 키
API地址: API 엔드포인트
最大Token: 최대 토큰
输入价格: 입력 가격
输出价格: 출력 가격
折扣: 할인
启用: 활성화
停用: 비활성화
已启用: 활성화됨
已停用: 비활성화됨
新增模型: 모델 추가
编辑模型: 모델 편집
删除模型: 모델 삭제
测试模型: 모델 테스트
模型分组: 모델 그룹
分组名称: 그룹 이름
分组描述: 그룹 설명
新增分组: 그룹 추가
编辑分组: 그룹 편집
删除分组: 그룹 삭제
使用统计: 사용 통계
调用次数: 호출 횟수
成功次数: 성공 횟수
失败次数: 실패 횟수
Token用量: 토큰 사용량
输入Token: 입력 토큰
输出Token: 출력 토큰
总Token: 총 토큰
费用统计: 비용 통계
总费用: 총 비용
本月费用: 이번 달 비용
今日费用: 오늘 비용
按模型统计: 모델별 통계
按用户统计: 사용자별 통계
按日期统计: 날짜별 통계
趋势图: 추세 차트
日: 일
周: 주
月: 월
年: 년
用户管理: 사용자 관리
用户名称: 사용자 이름
用户Token配额: 사용자 토큰 쿼터
已使用: 사용됨
剩余配额: 잔여 쿼터
配额重置: 쿼터 초기화
模型映射: 모델 매핑
映射名称: 매핑 이름
源模型: 소스 모델
目标模型: 대상 모델
映射状态: 매핑 상태
新增映射: 매핑 추가
编辑映射: 매핑 편집
删除映射: 매핑 삭제
密钥管理: 키 관리
密钥名称: 키 이름
密钥值: 키 값
密钥状态: 키 상태
新增密钥: 키 추가
编辑密钥: 키 편집
删除密钥: 키 삭제
日志: 로그
请求日志: 요청 로그
错误日志: 오류 로그
请求时间: 요청 시간
响应时间: 응답 시간
耗时: 소요 시간
状态码: 상태 코드
错误信息: 오류 메시지
请求参数: 요청 파라미터
响应内容: 응답 내용
供应商: 공급업체
所属机构: 소속 기관
定价项目: 가격 항목
定价属于: 가격 귀속
供应商折扣: 공급업체 할인
描述: 설명
规格明细: 규격 상세
项目名称: 항목 이름
模型: 모델
API: API
定价: 가격
时序: 시계열
开始日期: 시작 날짜
结束日期: 종료 날짜
生效日期: 시작일
失效日期: 만료일
定价数据: 가격 데이터
定价项目时序: 가격 항목 시계열
测试: 테스트
定价测试: 가격 테스트
新增: 추가
保存: 저장
取消: 취소
确认: 확인
删除: 삭제
编辑: 편집
查看: 조회
导出: 내보내기
打印: 인쇄
刷新: 새로고침
返回: 뒤로
提交: 제출
重置: 초기화
Conform: 확인
Discard: 폐기
Submit: 제출
Reset: 초기화
Cancel: 취소
搜索: 검색
操作: 작업
类型: 유형
状态: 상태
名称: 이름
编码: 코드
备注: 비고
创建时间: 생성 시간
更新时间: 업데이트 시간
全部: 전체

140
i18n/zh/msg.txt Normal file
View File

@ -0,0 +1,140 @@
API接口: API接口
Add Error: Add Error
Add Success: Add Success
Authorization Error: Authorization Error
Cancel: Cancel
Conform: Conform
Delete Error: Delete Error
Delete Success: Delete Success
Discard: Discard
Error: Error
ID: ID
Invalid: Invalid
Messages array cannot be empty: Messages array cannot be empty
Missing required parameter\x3A model: Missing required parameter\x3A model
Not enrogh balance to use llm: Not enrogh balance to use llm
Please login: Please login
Record no exist or with wrong ownership: Record no exist or with wrong ownership
Reset: Reset
Submit: Submit
Success: Success
Update Error: Update Error
Update Success: Update Success
You need login to use llm: You need login to use llm
failed: failed
id: id
model parameter required: model parameter required
need a config_data: need a config_data
need a llmid: need a llmid
ok: ok
server error: server error
上位系统id: 上位系统id
上架: 上架
上架状态: 上架状态
上线检查: 上线检查
下架: 下架
主键ID: 主键ID
交互内容: 交互内容
交易号: 交易号
交易成本: 交易成本
交易金额: 交易金额
从IO文件恢复Usages: 从IO文件恢复Usages
任务号: 任务号
任务查询间隔(秒): 任务查询间隔(秒)
任务结果查询接口名称: 任务结果查询接口名称
体验一次: 体验一次
使用信息: 使用信息
使用日期: 使用日期
使用时间: 使用时间
使用记录ID: 使用记录ID
使用记录id: 使用记录id
供应商id: 供应商id
供应商模型列表: 供应商模型列表
全部: 全部
分类: 分类
删除成功: 删除成功
历史数据为只读,不可修改: 历史数据为只读,不可修改
历史数据为只读,不可删除: 历史数据为只读,不可删除
历史数据为只读,不可新增: 历史数据为只读,不可新增
原因: 原因
名称: 名称
启用日期: 启用日期
响应时间: 响应时间
图标id: 图标id
处理备注: 处理备注
处理时间: 处理时间
处理状态: 处理状态
备份时间: 备份时间
大语言模型: 大语言模型
失效日期: 失效日期
失败原因: 失败原因
失败时间: 失败时间
定价ID: 定价ID
开始日期: 开始日期
恢复Usages: 恢复Usages
恢复Usages失败: 恢复Usages失败
恢复Usages完成: 恢复Usages完成
成功: 成功
所属机构id: 所属机构id
按供应商: 按供应商
按分类: 按分类
探索和使用各类AI模型: 探索和使用各类AI模型
接口名称: 接口名称
提示: 提示
操作: 操作
无效的参数未找到模型ID: 无效的参数未找到模型ID
无权删除该映射: 无权删除该映射
无权操作该模型: 无权操作该模型
是否已处理: 是否已处理
最低余额: 最低余额
机构: 机构
查询API: 查询API
查询间隔(秒): 查询间隔(秒)
检查计费: 检查计费
模型: 模型
模型API映射表: 模型API映射表
模型ID: 模型ID
模型id: 模型id
模型、分类和API接口为必填项: 模型、分类和API接口为必填项
模型使用: 模型使用
模型使用历史记录: 模型使用历史记录
模型分类ID: 模型分类ID
模型列表: 模型列表
模型名称: 模型名称
模型广场: 模型广场
模型机构id: 模型机构id
模型用量: 模型用量
模型类型: 模型类型
模型类型管理: 模型类型管理
模型类目: 模型类目
没找到模型: 没找到模型
没有找到需要恢复的记录: 没有找到需要恢复的记录
添加成功: 添加成功
添加映射: 添加映射
状态: 状态
用户: 用户
用户id: 用户id
用户机构id: 用户机构id
类型名: 类型名
类型名不能为空: 类型名不能为空
类型说明: 类型说明
结束日期: 结束日期
结束时间: 结束时间
缺少ID参数: 缺少ID参数
缺省分类: 缺省分类
能力映射: 能力映射
计费项目: 计费项目
记录ID不能为空: 记录ID不能为空
记账失败记录: 记账失败记录
记账状态: 记账状态
识别名: 识别名
该模型的此API映射已存在: 该模型的此API映射已存在
说明: 说明
账户余额不够: 账户余额不够
选择分类: 选择分类
重试: 重试
重试次数: 重试次数
重试记账: 重试记账
金额: 金额
错误: 错误
间隔(秒): 间隔(秒)

47
init/data.json Normal file
View File

@ -0,0 +1,47 @@
{
"appcodes": [
{
"id": "llm_status",
"name": "模型上架状态",
"hierarchy_flg": "0"
},
{
"id": "llmusage_status",
"name": "调用状态",
"hierarchy_flg": "0"
},
{
"id": "accounting_status",
"name": "记账状态",
"hierarchy_flg": "0"
},
{
"id": "handled_flg",
"name": "是否已处理",
"hierarchy_flg": "0"
},
{
"id": "isdefaultcatelog_flg",
"name": "是否缺省分类",
"hierarchy_flg": "0"
}
],
"appcodes_kv": [
{"id": "llm_status_published", "parentid": "llm_status", "k": "published", "v": "已上架"},
{"id": "llm_status_unpublished", "parentid": "llm_status", "k": "unpublished", "v": "已下架"},
{"id": "llmusage_status_succeeded", "parentid": "llmusage_status", "k": "SUCCEEDED", "v": "成功"},
{"id": "llmusage_status_failed", "parentid": "llmusage_status", "k": "FAILED", "v": "失败"},
{"id": "llmusage_status_unknown", "parentid": "llmusage_status", "k": "UNKNOWN", "v": "未知"},
{"id": "accounting_status_created", "parentid": "accounting_status", "k": "created", "v": "待记账"},
{"id": "accounting_status_accounted", "parentid": "accounting_status", "k": "accounted", "v": "已记账"},
{"id": "accounting_status_failed", "parentid": "accounting_status", "k": "failed", "v": "记账失败"},
{"id": "handled_flg_0", "parentid": "handled_flg", "k": "0", "v": "未处理"},
{"id": "handled_flg_1", "parentid": "handled_flg", "k": "1", "v": "已处理"},
{"id": "isdefaultcatelog_flg_0", "parentid": "isdefaultcatelog_flg", "k": "0", "v": "否"},
{"id": "isdefaultcatelog_flg_1", "parentid": "isdefaultcatelog_flg", "k": "1", "v": "是"}
]
}

View File

@ -4,21 +4,59 @@
"params": { "params": {
"sortby":"model", "sortby":"model",
"logined_userorgid": "ownerid", "logined_userorgid": "ownerid",
"data_filter": {
"AND": [
{"field": "name", "op": "LIKE", "var": "name_input"},
{"field": "model", "op": "LIKE", "var": "model_input"},
{"field": "providerid", "op": "=", "var": "providerid_input"},
{"field": "upappid", "op": "=", "var": "upappid_input"},
{"field": "status", "op": "=", "var": "status_input"}
]
},
"filter_labels": {
"name_input": "名称",
"model_input": "识别名",
"providerid_input": "供应商",
"upappid_input": "上位系统",
"status_input": "上架状态"
},
"browserfields": { "browserfields": {
"exclouded": ["id", "ownerid"], "exclouded": ["id", "ownerid"],
"alters": { "alters": {
"ppid":{ "ppid":{
"dataurl":"{{entire_url('/pricing/get_all_pricing_programs.dspy')}}", "dataurl":"{{entire_url('/pricing/get_all_pricing_programs.dspy')}}",
"textField": "name", "textField": "name",
"valueField": "id" "valueField": "id"
} },
"providerid": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_providerid.dspy')}}",
"valueField": "providerid",
"textField": "providerid_text"
},
"upappid": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_upappid.dspy')}}",
"valueField": "upappid",
"textField": "upappid_text"
}
} }
}, },
"toolbar": { "toolbar": {
"tools":[ "tools":[
{ {
"name":"test", "name":"launch_check",
"label":"体验", "label":"上线检查",
"selected_row":true
},
{
"name":"publish",
"label":"上架",
"selected_row":true
},
{
"name":"unpublish",
"label":"下架",
"selected_row":true "selected_row":true
} }
] ]
@ -26,25 +64,66 @@
"binds":[ "binds":[
{ {
"wid":"self", "wid":"self",
"event":"test", "event":"launch_check",
"actiontype":"urlwidget", "actiontype":"urlwidget",
"target":"PopupWindow", "target":"PopupWindow",
"popup_options":{ "popup_options":{
"title":"model Test", "title":"上线检查",
"cwidth":22, "cwidth":25,
"height":"75%" "cheight":20
}, },
"options":{ "options":{
"url":"{{entire_url('./llm_dialog.ui')}}", "url":"{{entire_url('./llm_launch_check.ui')}}",
"params":{ "params":{
"id":"${id}" "id":"${id}"
} }
} }
},
{
"wid":"self",
"event":"publish",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"上架",
"cwidth":20,
"cheight":8
},
"options":{
"url":"{{entire_url('../api/llm_status_update.dspy')}}",
"params":{
"id":"${id}",
"action":"published"
}
}
},
{
"wid":"self",
"event":"unpublish",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"下架",
"cwidth":20,
"cheight":8
},
"options":{
"url":"{{entire_url('../api/llm_status_update.dspy')}}",
"params":{
"id":"${id}",
"action":"unpublished"
}
}
} }
], ],
"editexclouded": [ "editexclouded": [
"id", "ownerid" "id", "ownerid"
], ],
"editable": {
"new_data_url": "{{entire_url('../api/llm_create.dspy')}}",
"update_data_url": "{{entire_url('../api/llm_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/llm_delete.dspy')}}"
},
"subtables":[ "subtables":[
{ {
"field":"llmid", "field":"llmid",

View File

@ -5,6 +5,18 @@
"browserfields": { "browserfields": {
"exclouded": ["id", "llmid"], "exclouded": ["id", "llmid"],
"alters": { "alters": {
"apiname": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_apiname.dspy')}}?llmid={{params_kw.llmid}}",
"valueField": "apiname",
"textField": "apiname_text"
},
"query_apiname": {
"uitype": "code",
"dataurl": "{{entire_url('../api/get_search_apiname.dspy')}}?allow_empty=1&llmid={{params_kw.llmid}}",
"valueField": "apiname",
"textField": "apiname_text"
}
} }
}, },
"editexclouded": ["id", "llmid"] "editexclouded": ["id", "llmid"]

View File

@ -9,6 +9,7 @@
}, },
"editexclouded": ["id"], "editexclouded": ["id"],
"editable": { "editable": {
"get_data_url": "{{entire_url(get_llmusage.dspy')}}?pagerows=50",
"new_data_url": "{{entire_url('../api/llmusage_create.dspy')}}", "new_data_url": "{{entire_url('../api/llmusage_create.dspy')}}",
"update_data_url": "{{entire_url('../api/llmusage_update.dspy')}}", "update_data_url": "{{entire_url('../api/llmusage_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/llmusage_delete.dspy')}}" "delete_data_url": "{{entire_url('../api/llmusage_delete.dspy')}}"

View File

@ -3,17 +3,34 @@
"title": "记账失败记录", "title": "记账失败记录",
"params": { "params": {
"sortby": "failed_time desc", "sortby": "failed_time desc",
"browserfields": { "toolbar": {
"exclouded": ["id"], "tools": [
"alters": { {
"handled": { "name": "show_reason",
"uitype": "code", "label": "原因",
"data": [ "selected_row": true
{"value": "0", "text": "未处理"}, }
{"value": "1", "text": "已处理"} ]
] },
"binds": [
{
"wid": "self",
"event": "show_reason",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "失败原因",
"cwidth": 30,
"cheight": 20
},
"options": {
"url": "{{entire_url('../api/show_failed_reason.dspy')}}?id=${id}$"
} }
} }
],
"browserfields": {
"exclouded": ["id", "failed_reason"],
"alters": {}
}, },
"editexclouded": ["id", "llmusageid", "failed_time"], "editexclouded": ["id", "llmusageid", "failed_time"],
"editable": { "editable": {

View File

@ -41,7 +41,7 @@ async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None):
debug(f'checkCustomerBalance(): llmid is None') debug(f'checkCustomerBalance(): llmid is None')
return False return False
env = ServerEnv() env = ServerEnv()
llm = await get_llm(llmid) llm = await get_llmage_llm(llmid)
if llm.ownerid == userorgid: if llm.ownerid == userorgid:
debug(f'self orgid user') debug(f'self orgid user')
return True return True
@ -64,37 +64,34 @@ async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None):
async def llm_accounting(llmusage): async def llm_accounting(llmusage):
env = ServerEnv() env = ServerEnv()
llmid = llmusage.llmid llmid = llmusage.llmid
llm = await get_llmage_llm(llmid)
if llm is None:
async with get_sor_context(env, 'llmage') as sor:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm not found({llmid})')
exception(f'{e}')
raise e
if llm.ppid is None:
async with get_sor_context(env, 'llmage') as sor:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm ({llmid}) donot has a pricing_program')
exception(f'{e}')
raise e
customerid = llmusage.userorgid
userid = llmusage.userid
resellerid = llm.ownerid
providerid = llm.providerid
trans_amount = llmusage.amount
trans_cost = llmusage.cost
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
sql = """select a.*, b.ppid from llm a, llm_api_map b
where a.id=${llmid}$
and a.id = b.llmid
and b.isdefaultcatelog = '1'
"""
recs = await sor.sqlExe(sql, {'llmid': llmusage.llmid})
if len(recs) == 0:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm not found({llmid})')
exception(f'{e}')
raise e
if recs[0].ppid is None:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm ({llmid}) donot has a pricing_program')
exception(f'{e}')
raise e
customerid = llmusage.userorgid
userid = llmusage.userid
resellerid = recs[0].ownerid
providerid = recs[0].providerid
trans_amount = llmusage.amount
trans_cost = llmusage.cost
biz_date = await env.get_business_date(sor) biz_date = await env.get_business_date(sor)
timestamp = env.timestampstr() timestamp = env.timestampstr()
orderid = getID() orderid = getID()
@ -163,7 +160,7 @@ async def get_accounting_llmusages(luid=None):
dt = datetime.fromtimestamp(t) dt = datetime.fromtimestamp(t)
tsstr = dt.strftime('%Y-%m-%d %H:%M:%S.') + f'{dt.microsecond // 1000:03d}' tsstr = dt.strftime('%Y-%m-%d %H:%M:%S.') + f'{dt.microsecond // 1000:03d}'
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
sql = """select a.*, c.ppid sql = """select a.*, b.model, c.ppid
from llmusage a, llm b, llm_api_map c from llmusage a, llm b, llm_api_map c
where a.llmid = b.id where a.llmid = b.id
and a.llmid = c.llmid and a.llmid = c.llmid
@ -335,7 +332,7 @@ async def backend_accounting():
tpac = await get_user_tpac(lu.userid) tpac = await get_user_tpac(lu.userid)
if tpac: if tpac:
debug(f'{lu.id=},{lu.userid=}, {tpac=}, go tpac') debug(f'{lu.id=},{lu.userid=}, {tpac=}, go tpac')
await tpac_accounting(tpac, lu.userid, lu.llmid, lu.amount, lu.usages, lu.id) await tpac_accounting(tpac, lu.userid, lu.llmid, lu.amount, lu.usages, lu.id, lu.model)
else: else:
debug(f'{lu.id=},{lu.userid=}, {tpac=}, go local') debug(f'{lu.id=},{lu.userid=}, {tpac=}, go local')
await llm_accounting(lu) await llm_accounting(lu)

View File

@ -75,7 +75,8 @@ async def async_uapi_request(request, llm,
estr = erase_apikey(e) estr = erase_apikey(e)
ed = {"error": f"ERROR:{estr}", "status": "FAILED"} ed = {"error": f"ERROR:{estr}", "status": "FAILED"}
exception(f'{ed}') exception(f'{ed}')
yield f'{ed}\n' estr = json.dumps(ed, ensure_ascii=False)
yield f'{estr}\n'
return return
if isinstance(b, bytes): if isinstance(b, bytes):
b = b.decode('utf-8') b = b.decode('utf-8')
@ -141,9 +142,15 @@ async def get_llm_llmusage(luid):
return return
if llmusage.status == 'FAILED': if llmusage.status == 'FAILED':
return return
llms = await sor.R('llm', {'id': llmusage.llmid}) # Use JOIN to get query_apiname/query_period from llm_api_map
sql = """select a.id, a.name, a.model, a.upappid, a.ownerid, a.status,
m.apiname, m.query_apiname, m.query_period, m.ppid
from llm a
join llm_api_map m on a.id = m.llmid
where a.id = ${llmid}$ and m.isdefaultcatelog = '1'"""
llms = await sor.sqlExe(sql, {'llmid': llmusage.llmid})
if len(llms) == 0: if len(llms) == 0:
e = Exception(f'{llmusage.llmid=} not found in llm') e = Exception(f'{llmusage.llmid=} not found in llm/llm_api_map')
exception(f'{e}') exception(f'{e}')
raise e raise e
llm = llms[0] llm = llms[0]

View File

@ -9,6 +9,8 @@ from .utils import (
llm_query_orders, llm_query_orders,
read_webpath, read_webpath,
llm_query_price, llm_query_price,
get_user_tpac,
get_tpac_balance,
get_llm_by_model, get_llm_by_model,
get_llms_by_catelog, get_llms_by_catelog,
get_llms_sort_by_provider, get_llms_sort_by_provider,
@ -16,6 +18,9 @@ from .utils import (
get_llms_by_catelog_to_customer, get_llms_by_catelog_to_customer,
get_llmproviders, get_llmproviders,
get_llm, get_llm,
get_llmage_llm,
get_llm_catelogs,
invalidate_uapi_cache,
) )
from .llmclient import ( from .llmclient import (
@ -32,6 +37,7 @@ from .accounting import (
get_failed_accounting_records, get_failed_accounting_records,
llm_accoung_failed llm_accoung_failed
) )
from .stats import get_llmage_stats
from .asyncinference import ( from .asyncinference import (
get_asynctask_status, get_asynctask_status,
@ -39,6 +45,14 @@ from .asyncinference import (
get_today_asynctask_list get_today_asynctask_list
) )
def _on_hot_reload(data=None):
"""Event handler for hot_reload — wraps invalidate_uapi_cache to accept dispatcher's data arg."""
from appPublic.log import debug
debug(f'[llmage] on_hot_reload called, invalidating uapi cache (data={data})')
invalidate_uapi_cache()
def load_llmage(): def load_llmage():
env = ServerEnv() env = ServerEnv()
env.llm_query_orders = llm_query_orders env.llm_query_orders = llm_query_orders
@ -51,7 +65,12 @@ def load_llmage():
env.get_asynctask_status = get_asynctask_status env.get_asynctask_status = get_asynctask_status
env.query_task_status = query_task_status env.query_task_status = query_task_status
env.get_llm = get_llm env.get_llm = get_llm
env.get_llmage_llm = get_llmage_llm
env.get_llm_catelogs = get_llm_catelogs
env.invalidate_uapi_cache = invalidate_uapi_cache
env.inference = inference env.inference = inference
env.get_user_tpac = get_user_tpac
env.get_tpac_balance = get_tpac_balance
env.inference_generator = inference_generator env.inference_generator = inference_generator
env.get_llms_by_catelog = get_llms_by_catelog env.get_llms_by_catelog = get_llms_by_catelog
env.get_llmcatelogs = get_llmcatelogs env.get_llmcatelogs = get_llmcatelogs
@ -63,6 +82,10 @@ def load_llmage():
env.get_llms_by_catelog_to_customer = get_llms_by_catelog_to_customer env.get_llms_by_catelog_to_customer = get_llms_by_catelog_to_customer
env.backup_accounted_llmusage = backup_accounted_llmusage env.backup_accounted_llmusage = backup_accounted_llmusage
env.get_failed_accounting_records = get_failed_accounting_records env.get_failed_accounting_records = get_failed_accounting_records
env.get_llmage_stats = get_llmage_stats
# Bind hot_reload event — module-level function, ref safe (module keeps it alive)
if hasattr(env, 'event_dispatcher'):
env.event_dispatcher.bind('hot_reload', _on_hot_reload)
rf = RegisterFunction() rf = RegisterFunction()
rf.register('jimeng_auth_headers', jimeng_auth_headers) rf.register('jimeng_auth_headers', jimeng_auth_headers)

View File

@ -116,8 +116,9 @@ async def _inference_generator(request, callerid, callerorgid,
if not params_kw.transno: if not params_kw.transno:
params_kw.transno = getID() params_kw.transno = getID()
llmid = params_kw.llmid llmid = params_kw.llmid
catelogid = params_kw.get('llmcatelogid', None)
f = None f = None
llm = await get_llm(llmid) llm = await get_llm(llmid, catelogid)
if llm is None: if llm is None:
errmsg = f'{{"status": "FAILED", "error":"llmid:{llmid}没找到模型"}}\n' errmsg = f'{{"status": "FAILED", "error":"llmid:{llmid}没找到模型"}}\n'
exception(errmsg) exception(errmsg)

69
llmage/stats.py Normal file
View File

@ -0,0 +1,69 @@
from sqlor.dbpools import get_sor_context
from appPublic.timeUtils import curDateString, timestampstr
from datetime import datetime, timedelta
from appPublic.log import debug, exception
async def get_llmage_stats(request):
"""Get llmage module statistics"""
env = request._run_ns
userorgid = await env.get_userorgid()
today = curDateString()
tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
stats = {
'total_models': 0,
'today_usage_count': 0,
'today_amount': 0,
'catelog_count': 0
}
async with get_sor_context(env, 'llmage') as sor:
# Total enabled models
sql_models = """
SELECT COUNT(DISTINCT id) as cnt FROM llm
WHERE enabled_date <= ${today}$
AND expired_date > ${today}$
"""
recs = await sor.sqlExe(sql_models, {'today': today})
if recs:
stats['total_models'] = int(recs[0].cnt or 0)
# Today's usage count
sql_usage = """
SELECT COUNT(*) as cnt FROM llmusage
WHERE userorgid = ${userorgid}$
AND use_date >= ${today}$
AND use_date < ${tomorrow}$
"""
recs = await sor.sqlExe(sql_usage, {
'userorgid': userorgid,
'today': today,
'tomorrow': tomorrow
})
if recs:
stats['today_usage_count'] = int(recs[0].cnt or 0)
# Today's total amount
sql_amount = """
SELECT COALESCE(SUM(amount), 0) as total FROM llmusage
WHERE userorgid = ${userorgid}$
AND use_date >= ${today}$
AND use_date < ${tomorrow}$
"""
recs = await sor.sqlExe(sql_amount, {
'userorgid': userorgid,
'today': today,
'tomorrow': tomorrow
})
if recs:
stats['today_amount'] = float(recs[0].total or 0)
# Catalog count
sql_catelog = """
SELECT COUNT(*) as cnt FROM llmcatelog
"""
recs = await sor.sqlExe(sql_catelog, {})
if recs:
stats['catelog_count'] = int(recs[0].cnt or 0)
return stats

View File

@ -1,21 +1,68 @@
import json import json
import time
import asyncio import asyncio
import aiofiles import aiofiles
from random import randint from random import randint
from functools import partial from functools import partial
from traceback import format_exc from traceback import format_exc
import time
from sqlor.dbpools import DBPools, get_sor_context from sqlor.dbpools import DBPools, get_sor_context
from appPublic.log import debug, exception, error, critical from appPublic.log import debug, exception, error, critical
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject from appPublic.dictObject import DictObject
from appPublic.timeUtils import curDateString, timestampstr from appPublic.timeUtils import curDateString, timestampstr
from uapi.appapi import UAPI, sor_get_callerid, sor_get_uapi from uapi.appapi import UAPI, sor_get_callerid, sor_get_uapi, get_uapi
from ahserver.serverenv import get_serverenv, ServerEnv from ahserver.serverenv import get_serverenv, ServerEnv
from ahserver.filestorage import FileStorage from ahserver.filestorage import FileStorage
from appPublic.jsonConfig import getConfig from appPublic.jsonConfig import getConfig
from appPublic.streamhttpclient import StreamHttpClient from appPublic.streamhttpclient import StreamHttpClient
# =============================================================
# Process-level cache for uapi/uapiio (static config, rarely changes)
# =============================================================
_UAPI_CACHE_TTL = 300 # 5 minutes
_uapi_cache = {} # key: "upappid:apiname" -> {data, ts}
_uapiio_cache = {} # key: "ioid" -> {data, ts}
async def _get_uapi_cached(upappid, apiname):
"""Get uapi record with process-level cache (uapi config rarely changes)"""
global _uapi_cache
cache_key = f"{upappid}:{apiname}"
cached = _uapi_cache.get(cache_key)
if cached and (time.time() - cached['ts']) < _UAPI_CACHE_TTL:
return cached['data']
uapi_rec = await get_uapi(upappid, apiname)
_uapi_cache[cache_key] = {'data': uapi_rec, 'ts': time.time()}
return uapi_rec
async def _get_uapiio_cached(ioid):
"""Get uapiio record with process-level cache (io config rarely changes)"""
global _uapiio_cache
if ioid is None:
return None
cached = _uapiio_cache.get(ioid)
if cached and (time.time() - cached['ts']) < _UAPI_CACHE_TTL:
return cached['data']
env = ServerEnv()
uapi_dbname = get_serverenv('get_module_dbname')('uapi')
async with DBPools().sqlorContext(uapi_dbname) as sor:
recs = await sor.R('uapiio', {'id': ioid})
result = recs[0] if recs else None
_uapiio_cache[ioid] = {'data': result, 'ts': time.time()}
return result
def invalidate_uapi_cache(upappid=None, apiname=None):
"""Invalidate uapi/uapiio cache entries. Call when uapi config changes."""
global _uapi_cache, _uapiio_cache
if upappid and apiname:
_uapi_cache.pop(f"{upappid}:{apiname}", None)
else:
_uapi_cache.clear()
_uapiio_cache.clear()
async def update_llmusage(ns): async def update_llmusage(ns):
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
@ -46,13 +93,14 @@ async def get_tpac_balance(tpac, userid):
exception(f'{url=}, {userid=}, error:{e}') exception(f'{url=}, {userid=}, error:{e}')
return None return None
async def tpac_accounting(tpac, userid, llmid, amount, usage, luid): async def tpac_accounting(tpac, userid, llmid, amount, usage, luid, model):
url = tpac.tpac_accounting_url url = tpac.tpac_accounting_url
hc = StreamHttpClient() hc = StreamHttpClient()
d = { d = {
'userid': userid, 'userid': userid,
'llmid': llmid, 'llmid': llmid,
'amount': amount, 'amount': amount,
'model': model,
'usage': usage 'usage': usage
} }
status = 'failed' status = 'failed'
@ -154,6 +202,7 @@ async def get_llmproviders():
sql = """select a.providerid, a.iconid, b.orgname sql = """select a.providerid, a.iconid, b.orgname
from llm a, organization b from llm a, organization b
where a.providerid = b.id where a.providerid = b.id
and a.status = 'published'
group by a.providerid, a.iconid, b.orgname""" group by a.providerid, a.iconid, b.orgname"""
return await sor.sqlExe(sql, {}) return await sor.sqlExe(sql, {})
return [] return []
@ -165,14 +214,36 @@ async def get_llms_sort_by_provider():
sql = """select a.*, b.orgname from llm a, organization b sql = """select a.*, b.orgname from llm a, organization b
where a.enabled_date <= ${today}$ where a.enabled_date <= ${today}$
and a.expired_date > ${today}$ and a.expired_date > ${today}$
and a.status = 'published'
and a.providerid = b.id and a.providerid = b.id
order by a.providerid, a.id order by a.providerid, a.id
""" """
recs = await sor.sqlExe(sql, {'today': today}) recs = await sor.sqlExe(sql, {'today': today})
# 批量查询所有模型的 ppid 映射
llm_ids = [r.id for r in recs]
pp_map = {} # llmid -> [ppid, ...]
if llm_ids:
placeholders = ','.join([f"'{lid}'" for lid in llm_ids])
pp_sql = f"select distinct llmid, ppid from llm_api_map where llmid in ({placeholders}) and ppid is not null"
pp_recs = await sor.sqlExe(pp_sql, {})
for pp in pp_recs:
pp_map.setdefault(pp.llmid, []).append(pp.ppid)
d = [] d = []
x = None x = None
oldpid = '-111' oldpid = '-111'
for l in recs: for l in recs:
# 获取定价展示文本
pricing_list = []
for ppid in pp_map.get(l.id, []):
try:
pd = await env.get_pricing_display(ppid)
if pd:
pricing_list.append(pd.get('display_text', ''))
except:
pass
l.pricing_display = pricing_list
if l.providerid != oldpid: if l.providerid != oldpid:
x = { x = {
'id': l.providerid, 'id': l.providerid,
@ -186,6 +257,44 @@ where a.enabled_date <= ${today}$
return d return d
return [] return []
async def get_llmage_llm(llmid=None, catelogid=None):
"""Unified accessor for llm + llm_api_map + llmcatelog.
For non-API-call scenarios only (display, listing, querying, accounting).
Do NOT use for vendor model API calls use get_llm() instead.
- llmid: get specific llm by id (returns single DictObject or None)
- catelogid: filter by catalog (returns list)
- neither: return all with catalog info (returns list)
"""
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
sql = """select a.id, a.name, a.model, a.providerid, a.description,
a.iconid, a.upappid, a.ownerid, a.min_balance, a.status,
m.llmcatelogid, m.apiname, m.query_apiname, m.query_period, m.ppid, m.isdefaultcatelog,
lc.name as catelogname
from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog lc on m.llmcatelogid = lc.id
where 1=1
"""
ns = {}
if llmid:
sql += " and a.id = ${llmid}$"
ns['llmid'] = llmid
if catelogid:
sql += " and m.llmcatelogid = ${catelogid}$"
ns['catelogid'] = catelogid
else:
sql += " and m.isdefaultcatelog = '1'"
elif catelogid:
sql += " and m.llmcatelogid = ${catelogid}$"
ns['catelogid'] = catelogid
sql += " order by m.llmcatelogid, a.id"
recs = await sor.sqlExe(sql, ns)
if llmid:
return recs[0] if recs else None
return recs
async def get_llmcatelogs(): async def get_llmcatelogs():
db = DBPools() db = DBPools()
dbname = get_serverenv('get_module_dbname')('llmage') dbname = get_serverenv('get_module_dbname')('llmage')
@ -211,6 +320,7 @@ m.ppid
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id join llmcatelog b on m.llmcatelogid = b.id
where a.enabled_date <= ${today}$ where a.enabled_date <= ${today}$
and a.status = 'published'
and m.ppid is not null and m.ppid is not null
and a.expired_date > ${today}$ and a.expired_date > ${today}$
""" """
@ -250,6 +360,7 @@ async def get_llms_by_catelog(catelogid=None, orderby='providerid'):
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id join llmcatelog b on m.llmcatelogid = b.id
where a.enabled_date <= ${today}$ where a.enabled_date <= ${today}$
and a.status = 'published'
and a.expired_date > ${today}$""" and a.expired_date > ${today}$"""
params = {'today': today, 'sort': orderby} params = {'today': today, 'sort': orderby}
if catelogid: if catelogid:
@ -259,10 +370,30 @@ async def get_llms_by_catelog(catelogid=None, orderby='providerid'):
sql += " order by m.llmcatelogid, a.id" sql += " order by m.llmcatelogid, a.id"
recs = await sor.sqlExe(sql, params) recs = await sor.sqlExe(sql, params)
# 批量查询所有模型的 ppid 映射(避免 N+1 查询)
llm_ids = [r.id for r in recs]
pp_map = {}
if llm_ids:
placeholders = ','.join([f"'{lid}'" for lid in llm_ids])
pp_sql = f"select distinct llmid, ppid from llm_api_map where llmid in ({placeholders}) and ppid is not null"
pp_recs = await sor.sqlExe(pp_sql, {})
for pp in pp_recs:
pp_map.setdefault(pp.llmid, []).append(pp.ppid)
d = [] d = []
cid = '' cid = ''
x = None x = None
for r in recs: for r in recs:
pricing_list = []
for ppid in pp_map.get(r.id, []):
try:
pd = await env.get_pricing_display(ppid)
if pd:
pricing_list.append(pd.get('display_text', ''))
except:
pass
r.pricing_display = pricing_list
if cid != r.catelog_id: if cid != r.catelog_id:
x = { x = {
'catelogid': r.catelog_id, 'catelogid': r.catelog_id,
@ -276,59 +407,58 @@ async def get_llms_by_catelog(catelogid=None, orderby='providerid'):
return d return d
return [] return []
async def get_llm_catelogs(llmid):
"""Get all catelog entries for a given llmid from llm_api_map + llmcatelog.
Returns list of {catelogid, catelogname, isdefaultcatelog}
"""
if not llmid:
return []
llmage_dbname = get_serverenv('get_module_dbname')('llmage')
async with DBPools().sqlorContext(llmage_dbname) as sor:
sql = """select m.llmcatelogid as catelogid, lc.name as catelogname, m.isdefaultcatelog
from llm_api_map m
join llmcatelog lc on m.llmcatelogid = lc.id
where m.llmid = ${llmid}$
order by m.isdefaultcatelog desc"""
recs = await sor.sqlExe(sql, {'llmid': llmid})
return [dict(catelogid=r.catelogid, catelogname=r.catelogname, isdefault=r.isdefaultcatelog == '1') for r in recs]
async def get_llm(llmid, catelogid=None): async def get_llm(llmid, catelogid=None):
today = curDateString() """Get LLM with full uapi info for vendor API calls.
env = ServerEnv() Refactored to use get_llmage_llm() + cached uapi/uapiio lookups
async with get_sor_context(env, 'llmage') as sor: instead of a 6-table JOIN.
sql = """select a.id,
a.name, Returns DictObject with merged fields:
a.model, From get_llmage_llm: id, name, model, providerid, description,
a.providerid, iconid, upappid, ownerid, min_balance, status, llmcatelogid,
a.description, apiname, query_apiname, query_period, ppid, isdefaultcatelog,
a.iconid, catelogname
a.upappid, From uapi (cached): ioid, stream, callbackurl
a.ownerid, From uapiio (cached): input_fields
a.min_balance, """
m.llmcatelogid, # Step 1: Get base info from get_llmage_llm (3-table JOIN: llm + llm_api_map + llmcatelog)
m.apiname, llm = await get_llmage_llm(llmid, catelogid)
m.query_apiname, if not llm:
m.query_period, debug(f'{llmid=} not found via get_llmage_llm')
m.ppid, return None
e.ioid,
e.stream, # Step 2: Get uapi info (cached, keyed by upappid:apiname)
e.callbackurl, uapi = await _get_uapi_cached(llm.upappid, llm.apiname)
f.input_fields, if not uapi:
lc.name as catelogname debug(f'uapi not found: upappid={llm.upappid}, apiname={llm.apiname}')
from llm a return None
,llm_api_map m
,llmcatelog lc # Step 3: Get uapiio info (cached, keyed by ioid)
,upapp c uapiio = await _get_uapiio_cached(uapi.ioid)
,uapi e
,uapiio f # Merge uapi fields into llm result
where a.id = m.llmid llm.ioid = uapi.ioid
and a.upappid = c.id llm.stream = uapi.stream
and c.id = e.upappid llm.callbackurl = uapi.callbackurl
and m.apiname = e.name llm.input_fields = uapiio.input_fields if uapiio else '{}'
and e.ioid = f.id
and a.id = ${llmid}$ return llm
and a.expired_date > ${today}$
and a.enabled_date <= ${today}$
"""
ns = {'llmid': llmid, 'today': today}
if catelogid:
sql += ' and m.llmcatelogid = ${catelogid}$ '
ns['catelogid'] = catelogid
else:
sql += " and m.isdefaultcatelog = '1'"
recs = await sor.sqlExe(sql, ns.copy())
if len(recs) > 0:
r = recs[0]
return r
else:
debug(f'{llmid=} not found, {ns=}, {sql=}')
return None
exception(f'Error: {format_exc()}')
return None
async def write_llmusage(llmusage): async def write_llmusage(llmusage):
@ -338,7 +468,7 @@ async def write_llmusage(llmusage):
async def llm_query_price(llmid, config_data): async def llm_query_price(llmid, config_data):
env = ServerEnv() env = ServerEnv()
llm = await get_llm(llmid) llm = await get_llmage_llm(llmid)
if llm.ppid is None: if llm.ppid is None:
e = Exception(f'{llm=} ppid is None') e = Exception(f'{llm=} ppid is None')
exception(f'{e}') exception(f'{e}')

View File

@ -72,7 +72,16 @@
"title": "最低余额", "title": "最低余额",
"type": "float", "type": "float",
"length": 20, "length": 20,
"default": 10 "default": 10,
"dec": 2
},
{
"name": "status",
"title": "上架状态",
"type": "str",
"length": 16,
"nullable": "no",
"default": "unpublished"
} }
], ],
"codes": [ "codes": [
@ -99,6 +108,13 @@
"table": "organization", "table": "organization",
"valuefield": "id", "valuefield": "id",
"textfield": "orgname" "textfield": "orgname"
},
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='llm_status'"
} }
] ]
} }

Binary file not shown.

View File

@ -4,81 +4,99 @@
{ {
"name": "llm_api_map", "name": "llm_api_map",
"title": "模型API映射表", "title": "模型API映射表",
"primary": "id", "primary": [
"id"
],
"catelog": "relation" "catelog": "relation"
} }
], ],
"fields": [ "fields": [
{ {
"name": "id", "name": "id",
"type": "varchar(32)", "type": "str",
"not_null": true, "not_null": true,
"title": "主键ID" "title": "主键ID",
"length": 32
}, },
{ {
"name": "llmid", "name": "llmid",
"type": "varchar(32)", "type": "str",
"not_null": true, "not_null": true,
"title": "模型ID" "title": "模型ID",
"length": 32
}, },
{ {
"name": "llmcatelogid", "name": "llmcatelogid",
"type": "varchar(32)", "type": "str",
"not_null": true, "not_null": true,
"title": "模型分类ID" "title": "模型分类ID",
"length": 32
}, },
{ {
"name": "apiname", "name": "apiname",
"type": "varchar(100)", "type": "str",
"not_null": true, "not_null": true,
"title": "接口名称" "title": "接口名称",
"length": 100
}, },
{ {
"name": "query_apiname", "name": "query_apiname",
"type": "varchar(100)", "type": "str",
"title": "任务结果查询接口名称" "title": "任务结果查询接口名称",
"length": 100
}, },
{ {
"name": "query_period", "name": "query_period",
"type": "bigint", "type": "long",
"default": 30, "default": 30,
"title": "任务查询间隔(秒)" "title": "任务查询间隔(秒)"
}, },
{ {
"name": "ppid", "name": "ppid",
"type": "varchar(32)", "type": "str",
"title": "定价ID" "title": "定价ID",
"length": 32
}, },
{ {
"name": "isdefaultcatelog", "name": "isdefaultcatelog",
"type": "varchar(1)", "type": "str",
"not_null": true, "not_null": true,
"title": "缺省分类" "title": "缺省分类",
"length": 1
} }
], ],
"indexes": [ "indexes": [
{ {
"name": "idx_api_map_llmid", "name": "idx_api_map_llmid",
"type": "normal", "type": "normal",
"idxfields": ["llmid"], "idxfields": [
"llmid"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "idx_api_map_catelog", "name": "idx_api_map_catelog",
"type": "normal", "type": "normal",
"idxfields": ["llmcatelogid"], "idxfields": [
"llmcatelogid"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "idx_api_map_apiname", "name": "idx_api_map_apiname",
"type": "normal", "type": "normal",
"idxfields": ["apiname"], "idxfields": [
"apiname"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "uk_llmid_apiname", "name": "uk_llmid_apiname",
"type": "unique", "type": "unique",
"idxfields": ["llmid", "apiname"], "idxfields": [
"llmid",
"apiname"
],
"idxtype": "unique" "idxtype": "unique"
} }
], ],
@ -100,6 +118,13 @@
"table": "pricing_program", "table": "pricing_program",
"valuefield": "id", "valuefield": "id",
"textfield": "name" "textfield": "name"
},
{
"field": "isdefaultcatelog",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='isdefaultcatelog_flg'"
} }
] ]
} }

View File

@ -58,13 +58,15 @@
"name": "responsed_seconds", "name": "responsed_seconds",
"title": "响应时间", "title": "响应时间",
"type": "float", "type": "float",
"length": 18 "length": 18,
"dec": 2
}, },
{ {
"name": "finish_seconds", "name": "finish_seconds",
"title": "结束时间", "title": "结束时间",
"type": "float", "type": "float",
"length": 18 "length": 18,
"dec": 2
}, },
{ {
"name": "status", "name": "status",
@ -82,13 +84,15 @@
"name": "amount", "name": "amount",
"title": "交易金额", "title": "交易金额",
"type": "float", "type": "float",
"length": 18 "length": 18,
"dec": 2
}, },
{ {
"name": "cost", "name": "cost",
"title": "交易成本", "title": "交易成本",
"type": "float", "type": "float",
"length": 18 "length": 18,
"dec": 2
}, },
{ {
"name": "userorgid", "name": "userorgid",
@ -131,6 +135,30 @@
"accounting_status", "accounting_status",
"use_date" "use_date"
] ]
},
{
"name": "idx_llmusage_userid_usetime",
"idxtype": "index",
"idxfields": [
"userid",
"use_time"
]
}
],
"codes": [
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='llmusage_status'"
},
{
"field": "accounting_status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='accounting_status'"
} }
] ]
} }

Binary file not shown.

View File

@ -121,5 +121,32 @@
"idxtype": "index", "idxtype": "index",
"idxfields": ["failed_time"] "idxfields": ["failed_time"]
} }
],
"codes": [
{
"field": "handled",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='handled_flg'"
},
{
"field": "userid",
"table": "users",
"valuefield": "userid",
"textfield": "username"
},
{
"field": "userorgid",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "llmid",
"table": "llm",
"valuefield": "id",
"textfield": "name"
}
] ]
} }

View File

@ -136,6 +136,27 @@
"name": "idx_lh_backup_time", "name": "idx_lh_backup_time",
"idxtype": "index", "idxtype": "index",
"idxfields": ["backup_time"] "idxfields": ["backup_time"]
},
{
"name": "idx_lh_userid_usetime",
"idxtype": "index",
"idxfields": ["userid", "use_time"]
}
],
"codes": [
{
"field": "status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='llmusage_status'"
},
{
"field": "accounting_status",
"table": "appcodes_kv",
"valuefield": "k",
"textfield": "v",
"cond": "parentid='accounting_status'"
} }
] ]
} }

View File

@ -0,0 +1,83 @@
-- ============================================================
-- 修复数字人模型显示错误 — pricing timing缺失
-- 问题: get_ppid_pricing 找不到有效timing记录导致"data not found"
-- 影响:
-- 1. orNSwYIFP0HFv2UnY-9EW (wan2.6-i2v-flash) — 有program无timing
-- 2. 0B6aldoAej1PpZ4ydtrEZ (wan2.2-s2v数字人) — program和timing都缺
-- 生成时间: 2026-06-13
-- 执行用户: sword (bugfix模块) 或 root (mysql直接执行)
-- ============================================================
-- ============================================================
-- 1. 修复 wan2.6-i2v-flash: 设置discount + 创建timing记录
-- ============================================================
-- 1a. 修复 discount (当前为null)
UPDATE pricing_program
SET discount = 1.0
WHERE id = 'orNSwYIFP0HFv2UnY-9EW';
-- 1b. 创建 pricing_program_timing 记录
-- 官方定价: 有声720P=0.3元/秒, 1080P=0.5元/秒; 无声720P=0.15元/秒, 1080P=0.25元/秒
INSERT INTO pricing_program_timing (id, ppid, name, enabled_date, expired_date, pricing_data)
VALUES (
'orNSwYIFP0HFv2UnY-t1',
'orNSwYIFP0HFv2UnY-9EW',
NULL,
'2026-06-13',
'9999-12-31',
'unit_values:\n 秒: 1\nfields:\n price_factors:\n type: string\n role: factor\n label: 计价因子\n unit_prices:\n type: float\n role: factor\n label: 单位定价\n unit:\n type: string\n role: factor\n label: 计价单位\n size:\n type: string\n role: filter\n label: 分辨率\n audio:\n type: string\n role: filter\n label: 音频\npricings:\n- price_factors: duration\n unit_prices: 0.3\n unit: 秒\n filters:\n - size: 720P\n - audio: true\n- price_factors: duration\n unit_prices: 0.5\n unit: 秒\n filters:\n - size: 1080P\n - audio: true\n- price_factors: duration\n unit_prices: 0.15\n unit: 秒\n filters:\n - size: 720P\n - audio: false\n- price_factors: duration\n unit_prices: 0.25\n unit: 秒\n filters:\n - size: 1080P\n - audio: false'
);
-- ============================================================
-- 2. 创建 wan2.2-s2v 数字人定价 (program + timing)
-- ============================================================
-- 2a. 创建 pricing_program
INSERT INTO pricing_program (id, name, ownerid, providerid, pricing_belong, discount, description, pricing_spec)
VALUES (
'0B6aldoAej1PpZ4ydtrEZ',
'通义万象-数字人 wan2.2-s2v',
'0',
'6fadgewjraOyvxC_EkHou',
'provider',
1.0,
'万相数字人视频生成定价,按输出视频秒数计费',
'fields:\n model:\n type: str\n label: 模型\n options:\n - wan2.2-s2v\n size:\n type: str\n label: 分辨率\n options:\n - 480P\n - 720P\n duration:\n type: factor\n label: 时长(秒)'
);
-- 2b. 创建 pricing_program_timing
-- 官方定价: 480P=0.5元/秒, 720P=0.9元/秒
INSERT INTO pricing_program_timing (id, ppid, name, enabled_date, expired_date, pricing_data)
VALUES (
'0B6aldoAej1PpZ4ydtrE-t1',
'0B6aldoAej1PpZ4ydtrEZ',
NULL,
'2026-06-13',
'9999-12-31',
'unit_values:\n 秒: 1\nfields:\n price_factors:\n type: string\n role: factor\n label: 计价因子\n unit_prices:\n type: float\n role: factor\n label: 单位定价\n unit:\n type: string\n role: factor\n label: 计价单位\n size:\n type: string\n role: filter\n label: 分辨率\npricings:\n- price_factors: duration\n unit_prices: 0.5\n unit: 秒\n filters:\n - size: 480P\n- price_factors: duration\n unit_prices: 0.9\n unit: 秒\n filters:\n - size: 720P'
);
-- ============================================================
-- 验证 (执行后运行以下查询确认)
-- ============================================================
-- SELECT pp.id, pp.name, pp.discount, COUNT(ppt.id) as timing_count
-- FROM pricing_program pp
-- LEFT JOIN pricing_program_timing ppt ON pp.id = ppt.ppid
-- WHERE pp.id IN ('orNSwYIFP0HFv2UnY-9EW', '0B6aldoAej1PpZ4ydtrEZ')
-- GROUP BY pp.id, pp.name, pp.discount;
--
-- 预期结果:
-- | id | name | discount | timing_count |
-- |-------------------------|----------------------------|----------|--------------|
-- | orNSwYIFP0HFv2UnY-9EW | wan2.6-i2v-flash | 1.0 | 1 |
-- | 0B6aldoAej1PpZ4ydtrEZ | 通义万象-数字人 wan2.2-s2v | 1.0 | 1 |
-- ============================================================
-- 回滚 (如需回滚)
-- ============================================================
-- DELETE FROM pricing_program_timing WHERE id IN ('orNSwYIFP0HFv2UnY-t1', '0B6aldoAej1PpZ4ydtrE-t1');
-- DELETE FROM pricing_program WHERE id = '0B6aldoAej1PpZ4ydtrEZ';
-- UPDATE pricing_program SET discount = NULL WHERE id = 'orNSwYIFP0HFv2UnY-9EW';

View File

@ -0,0 +1,26 @@
-- ============================================================
-- 修复 MiniMax-M3 定价重复条目
-- 问题步骤11b的CONCAT重复执行导致M3条目重复
-- 解决删除没有prompt_tokens filter的旧M3条目前3条
-- ============================================================
UPDATE `pricing_program_timing`
SET `pricing_data` = REPLACE(`pricing_data`,
'- price_factors: prompt_tokens
unit_prices: 2.1
unit:
filters:
- model: MiniMax-M3
- price_factors: completion_tokens
unit_prices: 8.4
unit:
filters:
- model: MiniMax-M3
- price_factors: cached_tokens
unit_prices: 0.42
unit:
filters:
- model: MiniMax-M3
', '')
WHERE `ppid` = '5jmzupARABxkDFwUraFiQ' AND `enabled_date` = '2026-04-12';

245
scripts/load_path.py Normal file
View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
llmage 模块 RBAC 权限管理脚本
使用方法:
cd ~/repos/sage
./py3/bin/python ~/repos/llmage/scripts/load_path.py
每次代码变更如有新 path 出现需同步更新此脚本
"""
import subprocess
import os
import sys
def find_sage_root():
candidates = [
os.path.expanduser("~/repos/sage"),
os.path.expanduser("~/sage"),
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
]
for c in candidates:
if os.path.isdir(os.path.join(c, "py3")) and os.path.isdir(os.path.join(c, "wwwroot")):
return c
return None
SAGE_ROOT = find_sage_root()
if not SAGE_ROOT:
print("ERROR: Cannot find Sage root directory")
sys.exit(1)
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
SET_PERM_SCRIPT = os.path.join(SAGE_ROOT, "set_role_perm.py")
MOD = "llmage"
# ============================================================
# 权限路径定义 — 每次新增页面或API时同步更新
# ============================================================
# any — 无需登录(菜单、静态资源)
PATHS_ANY = [
f"/{MOD}/menu.ui",
f"/{MOD}/imgs/kdb.svg",
]
# logined — 所有已登录用户
PATHS_LOGINED = [
# 模块入口
f"/{MOD}",
f"/{MOD}/index.ui",
# 顶层 .ui 页面
f"/{MOD}/api_doc.ui",
f"/{MOD}/api_doc.md",
f"/{MOD}/llm_dialog.ui",
f"/{MOD}/llm_launch_check.ui",
f"/{MOD}/check_model_record.dspy",
f"/{MOD}/check_date_status.dspy",
f"/{MOD}/check_upapp.dspy",
f"/{MOD}/check_uapi.dspy",
f"/{MOD}/check_uapiio.dspy",
f"/{MOD}/check_llm_api_map.dspy",
f"/{MOD}/check_pricing_program.dspy",
f"/{MOD}/check_pricing_data.dspy",
f"/{MOD}/show_same_catelog_llm.ui",
f"/{MOD}/show_llms.ui",
f"/{MOD}/show_llms_by_providers.ui",
f"/{MOD}/model_plaza.ui",
f"/{MOD}/failed_accounting.ui",
f"/{MOD}/llmcatelog_list.ui",
# 顶层 .dspy非 api/ 目录)
f"/{MOD}/get_accounting_llmusages.dspy",
f"/{MOD}/get_asynctask_status.dspy",
f"/{MOD}/get_my_asynctasks.dspy",
f"/{MOD}/get_type_llms.dspy",
f"/{MOD}/grap_task_status.dspy",
f"/{MOD}/list_catelog_models.dspy",
f"/{MOD}/list_paging_catelog_llms.dspy",
f"/{MOD}/llmaccounting.dspy",
f"/{MOD}/llmcheck.dspy",
f"/{MOD}/llmcost.dspy",
f"/{MOD}/llminference.dspy",
f"/{MOD}/model_estimate.dspy",
f"/{MOD}/query_orders.dspy",
f"/{MOD}/query_price.dspy",
f"/{MOD}/test_llm_charging.dspy",
f"/{MOD}/vidu_callback.dspy",
f"/{MOD}/vidu_inference.dspy",
# api/ 目录
f"/{MOD}/api/failed_accounting_list.dspy",
f"/{MOD}/api/get_inference_history.dspy",
f"/{MOD}/api/get_apis.dspy",
f"/{MOD}/api/get_catelogs.dspy",
f"/{MOD}/api/get_organizations.dspy",
f"/{MOD}/api/get_ppids.dspy",
f"/{MOD}/api/get_search_apiname.dspy",
f"/{MOD}/api/get_search_providerid.dspy",
f"/{MOD}/api/get_search_upappid.dspy",
f"/{MOD}/api/get_upapps.dspy",
f"/{MOD}/api/llm_launch_check_api.dspy",
f"/{MOD}/api/llm_api_map_create.dspy",
f"/{MOD}/api/llm_api_map_delete.dspy",
f"/{MOD}/api/llm_api_map_list.dspy",
f"/{MOD}/api/llm_api_map_options.dspy",
f"/{MOD}/api/llm_catelog_options.dspy",
f"/{MOD}/api/llm_create.dspy",
f"/{MOD}/api/llm_delete.dspy",
f"/{MOD}/api/llm_status_update.dspy",
f"/{MOD}/api/llm_update.dspy",
f"/{MOD}/api/llmcatelog_create.dspy",
f"/{MOD}/api/llmcatelog_delete.dspy",
f"/{MOD}/api/llmcatelog_list.dspy",
f"/{MOD}/api/llmcatelog_update.dspy",
f"/{MOD}/api/llmusage_accounting_failed_create.dspy",
f"/{MOD}/api/llmusage_accounting_failed_delete.dspy",
f"/{MOD}/api/llmusage_accounting_failed_update.dspy",
f"/{MOD}/api/llmusage_create.dspy",
f"/{MOD}/api/llmusage_delete.dspy",
f"/{MOD}/api/llmusage_history_create.dspy",
f"/{MOD}/api/llmusage_history_delete.dspy",
f"/{MOD}/api/llmusage_history_update.dspy",
f"/{MOD}/api/llmusage_update.dspy",
f"/{MOD}/api/retry_accounting.dspy",
f"/{MOD}/api/uapi_options.dspy",
# CRUD 子目录 — llm/
f"/{MOD}/llm/index.ui",
f"/{MOD}/llm/add_llm.dspy",
f"/{MOD}/llm/delete_llm.dspy",
f"/{MOD}/llm/get_llm.dspy",
f"/{MOD}/llm/update_llm.dspy",
# CRUD 子目录 — llm_api_map/
f"/{MOD}/llm_api_map/index.ui",
f"/{MOD}/llm_api_map/add_llm_api_map.dspy",
f"/{MOD}/llm_api_map/delete_llm_api_map.dspy",
f"/{MOD}/llm_api_map/get_llm_api_map.dspy",
f"/{MOD}/llm_api_map/update_llm_api_map.dspy",
# CRUD 子目录 — llmcatelog_list/ (alias for llmcatelog)
f"/{MOD}/llmcatelog_list/index.ui",
f"/{MOD}/llmcatelog_list/add_llmcatelog.dspy",
f"/{MOD}/llmcatelog_list/delete_llmcatelog.dspy",
f"/{MOD}/llmcatelog_list/get_llmcatelog.dspy",
f"/{MOD}/llmcatelog_list/update_llmcatelog.dspy",
# CRUD 子目录 — llmusage/
f"/{MOD}/llmusage/index.ui",
f"/{MOD}/llmusage/add_llmusage.dspy",
f"/{MOD}/llmusage/delete_llmusage.dspy",
f"/{MOD}/llmusage/get_llmusage.dspy",
f"/{MOD}/llmusage/update_llmusage.dspy",
# CRUD 子目录 — llmusage_accounting_failed/
f"/{MOD}/llmusage_accounting_failed/index.ui",
f"/{MOD}/llmusage_accounting_failed/add_llmusage_accounting_failed.dspy",
f"/{MOD}/llmusage_accounting_failed/delete_llmusage_accounting_failed.dspy",
f"/{MOD}/llmusage_accounting_failed/get_llmusage_accounting_failed.dspy",
f"/{MOD}/llmusage_accounting_failed/recover_usages.dspy",
f"/{MOD}/llmusage_accounting_failed/update_llmusage_accounting_failed.dspy",
# CRUD 子目录 — llmusage_history/
f"/{MOD}/llmusage_history/index.ui",
f"/{MOD}/llmusage_history/add_llmusage_history.dspy",
f"/{MOD}/llmusage_history/delete_llmusage_history.dspy",
f"/{MOD}/llmusage_history/get_llmusage_history.dspy",
f"/{MOD}/llmusage_history/update_llmusage_history.dspy",
# v1 API 目录
f"/{MOD}/v1/chat/completions/index.dspy",
f"/{MOD}/v1/image/generations/index.dspy",
f"/{MOD}/v1/models/catelog.dspy",
f"/{MOD}/v1/models/index.dspy",
f"/{MOD}/v1/tasks/index.dspy",
f"/{MOD}/v1/video/generations/index.dspy",
f"/{MOD}/v1/music/generations/index.dspy",
f"/{MOD}/v1/audio/speech/index.dspy",
f"/{MOD}/v1/audio/transcriptions/index.dspy",
f"/{MOD}/v1/pricing/index.dspy",
# 其他子目录
f"/{MOD}/list_llmcatelogs/index.dspy",
f"/{MOD}/list_llms/index.dspy",
f"/{MOD}/openai/index.dspy",
f"/{MOD}/t2t/index.dspy",
f"/{MOD}/tasks/index.dspy",
f"/{MOD}/upload_asset/index.dspy",
f"/{MOD}/video/index.dspy",
]
# ============================================================
# 客户角色 — v1 API 调用权限
# ============================================================
PATHS_V1_CUSTOMER = [
f"/{MOD}/v1/chat/completions/index.dspy",
f"/{MOD}/v1/video/generations/index.dspy",
f"/{MOD}/v1/image/generations/index.dspy",
f"/{MOD}/v1/music/generations/index.dspy",
f"/{MOD}/v1/audio/speech/index.dspy",
f"/{MOD}/v1/audio/transcriptions/index.dspy",
f"/{MOD}/v1/pricing/index.dspy",
f"/{MOD}/v1/models/index.dspy",
f"/{MOD}/v1/tasks/index.dspy",
]
# ============================================================
# 执行注册
# ============================================================
def run_set_perm(role, path):
cmd = [PYTHON, SET_PERM_SCRIPT, role, path]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def register_role_paths(role, paths):
count = 0
for p in paths:
if run_set_perm(role, p):
count += 1
print(f" {role}: {count}/{len(paths)} paths registered")
return count
def main():
print(f"Sage root: {SAGE_ROOT}")
total = 0
total += register_role_paths("any", PATHS_ANY)
total += register_role_paths("logined", PATHS_LOGINED)
# 客户角色 — v1 API 调用权限
for role in ["customer.admin", "customer.user"]:
total += register_role_paths(role, PATHS_V1_CUSTOMER)
print(f"\nDone. Total {total} permission entries registered.")
print("NOTE: Restart Sage after permission changes to reload RBAC cache.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
llmcatelog ID 迁移脚本
llmcatelog.id llm_api_map.llmcatelogid 从旧ID迁移为有意义的缩写ID
执行顺序
1. 先更新 llm_api_map.llmcatelogid外键表
2. 再更新 llmcatelog.id主表
3. 验证迁移结果
用法
# 预览模式(不执行,只显示将要做的变更)
python migrate_llmcatelog_ids.py --dry-run
# 正式执行
python migrate_llmcatelog_ids.py
# 指定数据库名(默认 llmage
python migrate_llmcatelog_ids.py --dbname my_llmage
"""
import asyncio
import argparse
import sys
import os
# 从脚本位置推断 sage 根目录(脚本在 pkgs/llmage/scripts/ 下)
_script_dir = os.path.dirname(os.path.abspath(__file__))
sage_root = os.path.abspath(os.path.join(_script_dir, '..', '..', '..'))
sys.path.insert(0, sage_root)
sys.path.insert(0, os.path.join(sage_root, 'py3/lib/python3.10/site-packages'))
from appPublic.jsonConfig import getConfig
# 旧ID -> 新ID 映射表
ID_MAP = {
'text2text': 't2t',
'text2image': 't2i',
'-i2ET0YkhfVQdHONfk9pX': 't2v',
'RdsO6pXgXcUTvUj819-7X': 'i2v',
'fHrfsOnAFCz53DAILMO7G': 'r2v',
'text2speech': 'tts',
'audio2text': 'asr',
'image2text': 'vision',
'9_P5y-qiQzQASacTVk2Lq': 'ai_search',
'czKvk-clQTRLS2KVddSWo': 'digital_human',
'HaRXiNCaAACurZsmEqpsU': 'music_gen',
'Rqj-QBj1v4560l-FPCrIU': 'text_cls',
's6-nhQtEvDKxG_qDPWwT7': '3d_gen',
'sRmpG8draTM-tsbO5nMJO': 'video_tool',
't7sUuj8BCnsD762PwMUKM': 'translate',
}
async def migrate(dry_run=False, dbname='llmage'):
from sqlor.dbpools import DBPools
from appPublic.log import debug
config = getConfig(sage_root)
db = DBPools(config.databases)
# 如果传入的 dbname 不在配置中,尝试使用第一个数据库
if dbname not in config.databases:
available = list(config.databases.keys())
print(f"Warning: '{dbname}' not in config.databases, available: {available}")
if available:
dbname = available[0]
print(f"Using '{dbname}' instead")
async with db.sqlorContext(dbname) as sor:
print(f"{'='*60}")
print(f"llmcatelog ID 迁移脚本")
print(f"数据库: {dbname}")
print(f"模式: {'预览(DRY-RUN)' if dry_run else '正式执行'}")
print(f"{'='*60}\n")
# ===== 阶段0: 检查当前数据 =====
print("[阶段0] 检查当前 llmcatelog 数据...")
current = await sor.sqlExe("SELECT id, name FROM llmcatelog ORDER BY name", {})
if not current:
print(" llmcatelog 表为空,无需迁移。")
return
print(f" 当前共 {len(current)} 条记录:\n")
print(f" {'旧ID':<30} {'name':<15} {'新ID':<15} {'状态'}")
print(f" {'-'*30} {'-'*15} {'-'*15} {'-'*10}")
valid_records = []
unmapped = []
for row in current:
old_id = row['id']
name = row['name']
new_id = ID_MAP.get(old_id)
if new_id:
# 检查是否已经迁移过old_id == new_id 的情况不会发生,
# 但如果 id 已经是新值则跳过)
if old_id == new_id:
status = '已迁移'
else:
status = '待迁移'
valid_records.append((old_id, new_id, name))
print(f" {old_id:<30} {name:<15} {new_id:<15} {status}")
else:
status = '无映射!'
unmapped.append((old_id, name))
print(f" {old_id:<30} {name:<15} {'---':<15} {status}")
if unmapped:
print(f"\n ⚠ 警告: {len(unmapped)} 条记录无映射关系,将跳过:")
for uid, uname in unmapped:
print(f" - {uid} ({uname})")
if not valid_records:
print("\n 没有需要迁移的记录。")
return
print(f"\n{len(valid_records)} 条记录需要迁移。\n")
# ===== 阶段1: 检查 llm_api_map 关联 =====
print("[阶段1] 检查 llm_api_map 关联...")
for old_id, new_id, name in valid_records:
maps = await sor.sqlExe(
"SELECT COUNT(*) as cnt FROM llm_api_map WHERE llmcatelogid = ${old_id}$",
{'old_id': old_id}
)
cnt = maps[0]['cnt'] if maps else 0
print(f" {name}({old_id}): {cnt} 条映射")
# ===== 阶段2: 检查新ID是否已被占用 =====
print(f"\n[阶段2] 检查新ID是否已被占用...")
conflict = False
for old_id, new_id, name in valid_records:
check = await sor.sqlExe(
"SELECT id, name FROM llmcatelog WHERE id = ${new_id}$",
{'new_id': new_id}
)
if check:
# 如果新ID已存在且就是当前记录已经迁移过跳过
if check[0]['id'] == old_id:
print(f" {new_id}: 已是当前记录,跳过")
else:
print(f" ✗ 冲突! 新ID '{new_id}' 已被 {check[0]['name']} 使用")
conflict = True
else:
print(f"{new_id}: 可用")
if conflict:
print("\n ✗ 存在ID冲突终止迁移")
return
if dry_run:
print(f"\n{'='*60}")
print("预览模式结束。以上是将会执行的变更。")
print("去掉 --dry-run 参数以正式执行。")
print(f"{'='*60}")
return
# ===== 阶段3: 执行迁移 =====
print(f"\n[阶段3] 开始执行迁移...")
# 3a: 先更新 llm_api_map外键表
print(f"\n --- 3a: 更新 llm_api_map.llmcatelogid ---")
for old_id, new_id, name in valid_records:
try:
await sor.sqlExe(
"UPDATE llm_api_map SET llmcatelogid = ${new_id}$ WHERE llmcatelogid = ${old_id}$",
{'new_id': new_id, 'old_id': old_id}
)
maps = await sor.sqlExe(
"SELECT COUNT(*) as cnt FROM llm_api_map WHERE llmcatelogid = ${new_id}$",
{'new_id': new_id}
)
cnt = maps[0]['cnt'] if maps else 0
print(f"{name}: {old_id} -> {new_id} (关联 {cnt} 条)")
except Exception as e:
print(f"{name}: 更新 llm_api_map 失败: {e}")
print(f" 回滚中...")
raise
# 3b: 再更新 llmcatelog主表
print(f"\n --- 3b: 更新 llmcatelog.id ---")
for old_id, new_id, name in valid_records:
try:
await sor.sqlExe(
"UPDATE llmcatelog SET id = ${new_id}$ WHERE id = ${old_id}$",
{'new_id': new_id, 'old_id': old_id}
)
print(f"{name}: {old_id} -> {new_id}")
except Exception as e:
print(f"{name}: 更新 llmcatelog 失败: {e}")
raise
# ===== 阶段4: 验证 =====
print(f"\n[阶段4] 验证迁移结果...")
# 验证 llmcatelog
catelogs = await sor.sqlExe("SELECT id, name FROM llmcatelog ORDER BY id", {})
print(f"\n llmcatelog ({len(catelogs)} 条):")
for row in catelogs:
print(f" {row['id']:<20} {row['name']}")
# 验证关联完整性
orphans = await sor.sqlExe("""
SELECT m.llmcatelogid, COUNT(*) as cnt
FROM llm_api_map m
LEFT JOIN llmcatelog c ON m.llmcatelogid = c.id
WHERE c.id IS NULL
GROUP BY m.llmcatelogid
""", {})
if orphans:
print(f"\n ✗ 发现孤立关联:")
for o in orphans:
print(f" llmcatelogid={o['llmcatelogid']}: {o['cnt']} 条无对应主记录")
else:
print(f"\n ✓ 所有 llm_api_map 关联完整,无孤立记录")
# 验证映射表
map_stats = await sor.sqlExe("""
SELECT m.llmcatelogid, c.name, COUNT(*) as cnt
FROM llm_api_map m
JOIN llmcatelog c ON m.llmcatelogid = c.id
GROUP BY m.llmcatelogid, c.name
ORDER BY m.llmcatelogid
""", {})
if map_stats:
print(f"\n llm_api_map 关联统计:")
for row in map_stats:
print(f" {row['llmcatelogid']:<20} {row['name']:<15} {row['cnt']} 条映射")
print(f"\n{'='*60}")
print("迁移完成!")
print(f"{'='*60}")
def main():
parser = argparse.ArgumentParser(description='llmcatelog ID 迁移脚本')
parser.add_argument('--dry-run', action='store_true', help='预览模式,不执行实际变更')
parser.add_argument('--dbname', default='llmage', help='数据库名 (默认: llmage)')
args = parser.parse_args()
asyncio.run(migrate(dry_run=args.dry_run, dbname=args.dbname))
if __name__ == '__main__':
main()

393
scripts/minimax_m3_add.sql Normal file
View File

@ -0,0 +1,393 @@
-- ============================================================
-- MiniMax M3 接入 + M2.7-highspeed + 补充全模型定价
-- 生成时间: 2026-06-12
-- 数据来源: token.opencomputing.cn 实时查询 (bugfix/execute_sql)
-- 参考: qwen3.7-max (llm:u1EtkR9xRcmwMvdoCZRC8, ppid:5i1JIpqERgCWqKQ4DCegD)
-- 接口: 使用uapi模块, upappid=minimax, baseurl=https://api.minimaxi.com/v1
-- ============================================================
-- ============================================================
-- 1. 新增 uapi: minimax t2t (纯文本对话, OpenAI兼容)
-- 复用ioid: Is8l4TGkcZcqFSjbbeIK2 (文本会话, 共享)
-- ============================================================
REPLACE INTO `uapi` (`id`, `name`, `need_auth`, `stream`, `path`, `httpmethod`, `chunk_match`, `headers`, `params`, `data`, `response`, `ioid`, `callbackurl`, `upappid`)
VALUES (
'mm_minimax_t2t',
't2t',
'0',
'stream',
'/chat/completions',
'POST',
'data: ',
'{
"Authorization": "Bearer {{apikey}}",
"Content-Type": "application/json"
}',
NULL,
'{
{% if stream %}
"stream_options":{
"include_usage": true
},
{% endif %}
{% if tools %}
"tools": {{json.dumps(tools, ensure_ascii=False)}},
{% endif %}
{% if tool_choice %}
"tool_choice": "{{tool_choice}}",
{% endif %}
{% if messages %}
"messages": {{json.dumps(messages, ensure_ascii=False)}},
{% else %}
"messages": [
{% if sys_prompt %}
{
"role": "system",
"content": {{json.dumps(sys_prompt, ensure_ascii=False)}}
},
{% endif %}
{
"role": "user",
"content": {{json.dumps(prompt, ensure_ascii=False)}}
}
],
{% endif %}
{% if stream %}
"stream":true,
{% endif %}
"model": "{{model}}"
}
',
'{
"id": "{{id}}",
"object": "{{object}}",
"created": {{created}},
"choices": {{json.dumps(choices, ensure_ascii=False)}},
"model": "{{model}}",
{% if object == "chat.completion" %}
"reasoning_content": {{json.dumps(choices[0].message.reasoning_content, ensure_ascii=False)}},
"content":{{json.dumps(choices[0].message.content, ensure_ascii=False)}},
{% elif len(choices)>0 %}
"reasoning_content": {{json.dumps(choices[0].delta.reasoning_content, ensure_ascii=False)}},
"content":{{json.dumps(choices[0].delta.content, ensure_ascii=False)}},
{% endif %}
{% if usage %}
{% set usage1 = usage.update({"model": model}) %}
"finish": "1",
"usage":{{json.dumps(usage)}}
{% else %}
"finish":"0"
{% endif %}
}',
'Is8l4TGkcZcqFSjbbeIK2',
NULL,
'minimax'
);
-- ============================================================
-- 2. 新增 uapi: minimax tm2t (多模态对话, 支持图片/视频/音频)
-- 复用ioid: t-ujII59ku45tIPcdXu4O (文本媒体转文本, 共享)
-- ============================================================
INSERT IGNORE INTO `uapi` (`id`, `name`, `need_auth`, `stream`, `path`, `httpmethod`, `chunk_match`, `headers`, `params`, `data`, `response`, `ioid`, `callbackurl`, `upappid`)
VALUES (
'mm_minimax_tm2t',
'tm2t',
'0',
'stream',
'/chat/completions',
'POST',
'data: ',
'{
"Authorization": "Bearer {{apikey}}",
"Content-Type": "application/json"
}',
NULL,
'{
"model": "{{model}}",
"stream_options":{
"include_usage": true
},
"messages": [
{% if sys_prompt %}
{
"role": "system",
"content": {{json.dumps(sys_prompt, ensure_ascii=False)}}
},
{% endif %}
{
"role": "user",
"content": [
{% if image_file %}
{
"type": "image_url",
"image_url":"{{b64media2url(request, image_file)}}"
},
{% endif %}
{% if video_file %}
{
"type": "video_url",
"video_url":"{{b64media2url(request, video_file)}}"
},
{% endif %}
{% if audio_file %}
{
"type": "audio_url",
"audio_url":"{{b64media2url(request, audio_file)}}"
},
{% endif %}
{
"type": "text",
"text": {{json.dumps(prompt, ensure_ascii=False)}}
}
]
}
],
"stream":true
}',
'{
"model": "{{model}}",
{% if object == "chat.completion" %}
"reasoning_content": {{json.dumps(choices[0].message.reasoning_content, ensure_ascii=False)}},
"content":{{json.dumps(choices[0].message.content, ensure_ascii=False)}},
{% elif len(choices)>0 %}
"reasoning_content": {{json.dumps(choices[0].delta.reasoning_content, ensure_ascii=False)}},
"content":{{json.dumps(choices[0].delta.content, ensure_ascii=False)}},
{% endif %}
{% if usage %}
"finish": "1",
"usage": {{json.dumps(usage)}}
{% else %}
"finish":"0"
{% endif %}
}',
't-ujII59ku45tIPcdXu4O',
NULL,
'minimax'
);
-- ============================================================
-- 3. 新增 llm: MiniMax-M3
-- ============================================================
INSERT IGNORE INTO `llm` (`id`, `name`, `model`, `description`, `iconid`, `upappid`, `providerid`, `ownerid`, `enabled_date`, `expired_date`, `min_balance`, `status`)
VALUES (
'mm3_MiniMax_M3',
'MiniMax M3',
'MiniMax-M3',
'MiniMax M3: 编程及Agent SOTA, 1M超长上下文, 多模态, 交错思维链。≤512K永久五折。',
'minimax',
'minimax',
'ww4e_kfX3Lh65Sdys0Vku',
'0',
'2026-06-12',
'9999-12-31',
10.00,
'published'
);
-- ============================================================
-- 4. 新增 llm: MiniMax-M2.7-highspeed
-- ============================================================
INSERT IGNORE INTO `llm` (`id`, `name`, `model`, `description`, `iconid`, `upappid`, `providerid`, `ownerid`, `enabled_date`, `expired_date`, `min_balance`, `status`)
VALUES (
'mm_m27_highspeed',
'MiniMax M2.7 Highspeed',
'MiniMax-M2.7-highspeed',
'MiniMax M2.7高速版, 更快速度, 适合低延迟场景。输入¥4.2/百万tokens, 输出¥16.8/百万tokens。',
'minimax',
'minimax',
'ww4e_kfX3Lh65Sdys0Vku',
'0',
'2026-06-12',
'9999-12-31',
10.00,
'published'
);
-- ============================================================
-- 5. 新增 llm_api_map: MiniMax-M3 (t2t)
-- apiname='t2t' → 匹配 uapi name='t2t' + upappid='minimax'
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'mm3_map_t2t',
'mm3_MiniMax_M3',
't2t',
't2t',
NULL,
NULL,
'5jmzupARABxkDFwUraFiQ',
'1'
);
-- ============================================================
-- 6. 新增 llm_api_map: MiniMax-M3 (tm2t, 多模态)
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'mm3_map_tm2t',
'mm3_MiniMax_M3',
'tm2t',
'tm2t',
NULL,
NULL,
'5jmzupARABxkDFwUraFiQ',
'0'
);
-- ============================================================
-- 7. 新增 llm_api_map: MiniMax-M2.7-highspeed (t2t)
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'mm_m27hs_map_t2t',
'mm_m27_highspeed',
't2t',
't2t',
NULL,
NULL,
'5jmzupARABxkDFwUraFiQ',
'1'
);
-- ============================================================
-- 8. 补充现有模型 llm_api_map.ppid
-- ============================================================
-- 8a. MiniMax-Hailuo-2.3 (视频i2v) → 0V89
UPDATE `llm_api_map` SET `ppid` = '0V89eilc_UQ2KiZIRJO8M'
WHERE `llmid` = 'AU1f40HV3tqFjxcVWWpyR' AND (`ppid` IS NULL OR `ppid` = '');
-- 8b. Minimax海螺参考生视频 S2V-01 (视频i2v) → 0V89
UPDATE `llm_api_map` SET `ppid` = '0V89eilc_UQ2KiZIRJO8M'
WHERE `llmid` = 'oks-VG9D8p2b0Agvs-LeQ' AND (`ppid` IS NULL OR `ppid` = '');
-- 8c. music-2.0 (音乐) → fQzk
UPDATE `llm_api_map` SET `ppid` = 'fQzkUeS6t6NBz_Fu4Fi77'
WHERE `llmid` = 'ns7egG9aXi91wjI62yKfu' AND (`ppid` IS NULL OR `ppid` = '');
-- 8d. speech-2.6-hd (TTS) → mm_tts_pricing
UPDATE `llm_api_map` SET `ppid` = 'mm_tts_pricing'
WHERE `llmid` = 'q6rdMUsGD1z3S3NyZh_A_' AND (`ppid` IS NULL OR `ppid` = '');
-- 8e. speech-2.6-turbo (TTS) → mm_tts_pricing
UPDATE `llm_api_map` SET `ppid` = 'mm_tts_pricing'
WHERE `llmid` = 'CEYD4YWRxjCj4k_6bpzIM' AND (`ppid` IS NULL OR `ppid` = '');
-- 8f. speech-2.5-hd-preview (TTS) → mm_tts_pricing
UPDATE `llm_api_map` SET `ppid` = 'mm_tts_pricing'
WHERE `llmid` = 'Si2g0XJ9ym3P5jlrdmcfB' AND (`ppid` IS NULL OR `ppid` = '');
-- ============================================================
-- 9. 新增 pricing_program: MiniMax TTS定价 (元/万字符)
-- ============================================================
INSERT IGNORE INTO `pricing_program` (`id`, `name`, `ownerid`, `providerid`, `pricing_belong`, `discount`, `description`, `pricing_spec`)
VALUES (
'mm_tts_pricing',
'MiniMax语音合成定价',
'0',
'ww4e_kfX3Lh65Sdys0Vku',
'provider',
1.000,
'MiniMax speech系列TTS定价按万字符计费',
'fields:\n model:\n type: str\n label: 模型\n formula:\n type: str\n label: 公式\n'
);
-- ============================================================
-- 10. 新增 pricing_program_timing: MiniMax TTS
-- ============================================================
INSERT IGNORE INTO `pricing_program_timing` (`id`, `ppid`, `name`, `enabled_date`, `expired_date`, `pricing_data`)
VALUES (
'mm_tts_timing',
'mm_tts_pricing',
'MiniMax TTS全价',
'2026-06-12',
'9999-12-31',
'unit_values:\n 万字符: 10000\nfields:\n price_factors:\n type: string\n role: factor\n label: 计价因子\n unit_prices:\n type: float\n role: factor\n label: 单位定价\n unit:\n type: string\n role: factor\n label: 计价单位\n model:\n type: string\n role: filter\n label: model\npricings:\n- price_factors: flat\n unit_prices: 3.5\n unit: 万字符\n filters:\n - model: speech-2.6-hd\n- price_factors: flat\n unit_prices: 2.0\n unit: 万字符\n filters:\n - model: speech-2.6-turbo\n- price_factors: flat\n unit_prices: 3.5\n unit: 万字符\n filters:\n - model: speech-2.5-hd-preview\n'
);
-- ============================================================
-- 11a. 更新 5jmzup fields: 添加 prompt_tokens 字段定义
-- 用于分段定价的 range filter需要 value_mode: between
-- ============================================================
UPDATE `pricing_program_timing`
SET `pricing_data` = REPLACE(`pricing_data`,
' model:\n type: string\n role: filter\n label: model',
' model:\n type: string\n role: filter\n label: model\n prompt_tokens:\n type: int\n role: filter\n label: prompt_tokens\n value_mode: between')
WHERE `ppid` = '5jmzupARABxkDFwUraFiQ' AND `enabled_date` = '2026-04-12';
-- ============================================================
-- 11b. 更新 5jmzup timing: 添加 MiniMax-M3 分段定价
-- M3 ≤512K永久五折: 输入2.1/输出8.4/缓存0.42 元/百万tokens
-- M3 512K~1M: 输入4.2/输出16.8/缓存0.84
-- 使用 prompt_tokens range filter 区分两个计价段
-- ============================================================
UPDATE `pricing_program_timing`
SET `pricing_data` = CONCAT(`pricing_data`, '
- price_factors: prompt_tokens
unit_prices: 2.1
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 0 =~ 524288
value_mode: between
- price_factors: completion_tokens
unit_prices: 8.4
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 0 =~ 524288
value_mode: between
- price_factors: cached_tokens
unit_prices: 0.42
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 0 =~ 524288
value_mode: between
- price_factors: prompt_tokens
unit_prices: 4.2
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 524288 =~ 1048576
value_mode: between
- price_factors: completion_tokens
unit_prices: 16.8
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 524288 =~ 1048576
value_mode: between
- price_factors: cached_tokens
unit_prices: 0.84
unit:
filters:
- model: MiniMax-M3
prompt_tokens: 524288 =~ 1048576
value_mode: between
')
WHERE `ppid` = '5jmzupARABxkDFwUraFiQ' AND `enabled_date` = '2026-04-12';
-- ============================================================
-- 12. 更新 pricing_program 5jmzup: 添加M3到模型选项
-- ============================================================
UPDATE `pricing_program`
SET `pricing_spec` = 'fields:\n model:\n type: str\n label: 模型\n options:\n - MiniMax-M3\n - MiniMax-M2.7\n - MiniMax-M2.7-highspeed\n - MiniMax-M2.5\n - MiniMax-M2.5-highspeed\n - M2-her\n formula:\n type: str\n label: 公式\n'
WHERE `id` = '5jmzupARABxkDFwUraFiQ';
-- ============================================================
-- ROLLBACK 语句 (如需回滚)
-- ============================================================
-- DELETE FROM `uapi` WHERE `id` IN ('mm_minimax_t2t', 'mm_minimax_tm2t');
-- DELETE FROM `llm` WHERE `id` IN ('mm3_MiniMax_M3', 'mm_m27_highspeed');
-- DELETE FROM `llm_api_map` WHERE `id` IN ('mm3_map_t2t', 'mm3_map_tm2t', 'mm_m27hs_map_t2t');
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'AU1f40HV3tqFjxcVWWpyR';
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'oks-VG9D8p2b0Agvs-LeQ';
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'ns7egG9aXi91wjI62yKfu';
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'q6rdMUsGD1z3S3NyZh_A_';
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'CEYD4YWRxjCj4k_6bpzIM';
-- UPDATE `llm_api_map` SET `ppid` = NULL WHERE `llmid` = 'Si2g0XJ9ym3P5jlrdmcfB';
-- DELETE FROM `pricing_program` WHERE `id` = 'mm_tts_pricing';
-- DELETE FROM `pricing_program_timing` WHERE `id` = 'mm_tts_timing';
-- -- 5jmzup的pricing_data CONCAT追加需手动编辑YAML移除M3条目

View File

@ -96,6 +96,27 @@ for p in "${LLMUSAGE_PATHS[@]}"; do
done done
done done
echo ""
echo "============================================"
echo " llmage: 客户 v1 API 调用权限"
echo "============================================"
CUSTOMER_ROLES=("customer.admin" "customer.user")
V1_API_PATHS=(
"/llmage/v1/chat/completions/index.dspy"
"/llmage/v1/video/generations/index.dspy"
"/llmage/v1/image/generations/index.dspy"
"/llmage/v1/models/index.dspy"
"/llmage/v1/tasks/index.dspy"
)
for p in "${V1_API_PATHS[@]}"; do
for role in "${CUSTOMER_ROLES[@]}"; do
set_perm "${role}" "${p}"
done
done
echo "" echo ""
echo "============================================" echo "============================================"
echo " 权限配置完成,共设置 ${COUNT} 条权限" echo " 权限配置完成,共设置 ${COUNT} 条权限"

View File

@ -0,0 +1,63 @@
-- ============================================================
--
-- Wan2.7 文生视频 API接口接入
-- 生成时间: 2026-06-12 (重写: 2026-06-13)
-- 模型: wan2.7-t2v-2026-04-25 (文生视频)
-- 支持: 720P/1080P, 2-15秒, 音频, 多镜头叙事
-- ============================================================
-- 前置条件:
-- llm表已有记录: id='IE8Ws20ZSoyAkOryWqhG_', model='wan2.7-t2v-2026-04-25'
-- pricing_program已有记录: id='GFJm2LIQoq2C70fFoY1H3', name='通义万相 wan2.7-t2v'
-- uapi 't2v' (id='It-ShFhCGIhS0ds3C2JJ0') 已有,复用万象通用文生视频接口
-- ============================================================
-- ============================================================
-- 1. 新增 llm_api_map: wan2.7-t2v → t2v接口 + 定价
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'wan27t2v_map_001',
'IE8Ws20ZSoyAkOryWqhG_',
't2v',
't2v',
't2vstatus',
10,
'GFJm2LIQoq2C70fFoY1H3',
'1'
);
-- ============================================================
-- 2. 新增 pricing_program_timing: wan2.7-t2v 定价
-- 官方定价: 720P=0.6元/秒, 1080P=1.0元/秒
-- ============================================================
INSERT INTO `pricing_program_timing` (`id`, `ppid`, `name`, `enabled_date`, `expired_date`, `pricing_data`)
VALUES (
'wan27t2v_timing_001',
'GFJm2LIQoq2C70fFoY1H3',
NULL,
'2026-05-20',
'9999-12-31',
'unit_values:\n 秒: 1\nfields:\n price_factors:\n type: string\n role: factor\n label: 计价因子\n unit_prices:\n type: float\n role: factor\n label: 单位定价\n unit:\n type: string\n role: factor\n label: 计价单位\n SR:\n type: string\n role: filter\n label: SR\npricings:\n- price_factors: duration\n unit_prices: 0.6\n unit: 秒\n filters:\n - SR: 720\n- price_factors: duration\n unit_prices: 1.0\n unit: 秒\n filters:\n - SR: 1080'
);
-- ============================================================
-- 验证 (执行后运行确认)
-- ============================================================
-- SELECT m.id, m.llmid, m.llmcatelogid, m.apiname, m.query_apiname, m.ppid,
-- l.name as model_name, l.model,
-- pp.name as pricing_name,
-- (SELECT COUNT(*) FROM pricing_program_timing WHERE ppid = m.ppid) as timing_count
-- FROM llm_api_map m
-- JOIN llm l ON m.llmid = l.id
-- JOIN pricing_program pp ON m.ppid = pp.id
-- WHERE m.llmid = 'IE8Ws20ZSoyAkOryWqhG_';
--
-- 预期: timing_count = 1
-- ============================================================
-- 回滚
-- ============================================================
-- DELETE FROM llm_api_map WHERE id = 'wan27t2v_map_001';
-- DELETE FROM pricing_program_timing WHERE id = 'wan27t2v_timing_001';

11
sql/add_status_field.sql Normal file
View File

@ -0,0 +1,11 @@
-- llmage: 添加模型上架/下架功能
-- 执行此 SQL 后,所有现有模型默认已上架,不影响线上使用
-- 1. 添加 status 字段
ALTER TABLE llm ADD COLUMN `status` VARCHAR(16) NOT NULL DEFAULT 'unpublished' COMMENT '上架状态: published/unpublished' AFTER `min_balance`;
-- 2. 现有模型全部设为已上架
UPDATE llm SET status = 'published';
-- 3. 添加索引(按状态筛选是高频操作)
ALTER TABLE llm ADD INDEX `idx_status` (`status`);

View File

@ -1,76 +1,101 @@
#!/usr/bin/env python3
import json
import os
result = {'success': False, 'rows': [], 'total': 0, 'page': 1, 'page_size': 50} result = {'success': False, 'rows': [], 'total': 0, 'page': 1, 'page_size': 50}
try: try:
dbname = get_module_dbname('llmage') llmage_db = get_module_dbname('llmage')
user_orgid = await get_userorgid() sage_db = get_module_dbname('sage')
db = DBPools()
# Extract filter parameters from params_kw filters = {}
filters = {} if params_kw.get('userorgid'):
if params_kw.get('userorgid'): filters['userorgid'] = params_kw.get('userorgid')
filters['userorgid'] = params_kw.get('userorgid') if params_kw.get('llmid'):
if params_kw.get('llmid'): filters['llmid'] = params_kw.get('llmid')
filters['llmid'] = params_kw.get('llmid') if params_kw.get('handled') is not None and params_kw.get('handled') != '':
if params_kw.get('handled') is not None: filters['handled'] = params_kw.get('handled')
filters['handled'] = params_kw.get('handled') if params_kw.get('start_date'):
if params_kw.get('start_date'): filters['start_date'] = params_kw.get('start_date')
filters['start_date'] = params_kw.get('start_date') if params_kw.get('end_date'):
if params_kw.get('end_date'): filters['end_date'] = params_kw.get('end_date')
filters['end_date'] = params_kw.get('end_date') if params_kw.get('filter_userid'):
filters['filter_userid'] = params_kw.get('filter_userid')
if params_kw.get('filter_llmid'):
filters['filter_llmid'] = params_kw.get('filter_llmid')
page = int(params_kw.get('page', 1)) page = int(params_kw.get('page', 1))
page_size = int(params_kw.get('page_size', 50)) page_size = int(params_kw.get('page_size', 50))
async with DBPools().sqlorContext(dbname) as sor: async with db.sqlorContext(llmage_db) as sor:
# Build dynamic SQL conditions = []
conditions = [] ns = {}
ns = {}
# Default: show unhandled records if filters.get('userorgid'):
if 'handled' not in filters: conditions.append("f.userorgid=${userorgid}$")
conditions.append("handled='0'") ns['userorgid'] = filters['userorgid']
if filters.get('llmid'):
conditions.append("f.llmid=${llmid}$")
ns['llmid'] = filters['llmid']
if filters.get('handled') is not None:
conditions.append("f.handled=${handled}$")
ns['handled'] = filters['handled']
if filters.get('start_date'):
conditions.append("f.use_date>=${start_date}$")
ns['start_date'] = filters['start_date']
if filters.get('end_date'):
conditions.append("f.use_date<=${end_date}$")
ns['end_date'] = filters['end_date']
if filters.get('filter_userid'):
conditions.append("(u.username LIKE ${filter_userid}$ OR u.name LIKE ${filter_userid}$)")
ns['filter_userid'] = '%' + filters['filter_userid'] + '%'
if filters.get('filter_llmid'):
conditions.append("(f.llmid LIKE ${filter_llmid}$ OR l.name LIKE ${filter_llmid}$)")
ns['filter_llmid'] = '%' + filters['filter_llmid'] + '%'
if filters.get('userorgid'): where = ""
conditions.append("userorgid=${userorgid}$") if conditions:
ns['userorgid'] = filters['userorgid'] where = "WHERE " + " AND ".join(conditions)
if filters.get('llmid'):
conditions.append("llmid=${llmid}$")
ns['llmid'] = filters['llmid']
if filters.get('handled') is not None:
conditions.append("handled=${handled}$")
ns['handled'] = filters['handled']
if filters.get('start_date'):
conditions.append("use_date>=${start_date}$")
ns['start_date'] = filters['start_date']
if filters.get('end_date'):
conditions.append("use_date<=${end_date}$")
ns['end_date'] = filters['end_date']
where = "" # 跨库JOIN获取名称
if conditions: sql = f"""
where = "where " + " and ".join(conditions) SELECT f.*,
u.username as userid_text,
o.orgname as userorgid_text,
l.name as llmid_text
FROM llmusage_accounting_failed f
LEFT JOIN {sage_db}.users u ON f.userid = u.id
LEFT JOIN {sage_db}.organization o ON f.userorgid = o.id
LEFT JOIN {llmage_db}.llm l ON f.llmid = l.id
{where}
ORDER BY f.failed_time DESC
"""
# Count total count_sql = f"""
count_sql = f"select count(*) as cnt from llmusage_accounting_failed {where}" SELECT count(*) as cnt
count_recs = await sor.sqlExe(count_sql, ns) FROM llmusage_accounting_failed f
total = count_recs[0].cnt if count_recs else 0 LEFT JOIN {sage_db}.users u ON f.userid = u.id
LEFT JOIN {sage_db}.organization o ON f.userorgid = o.id
LEFT JOIN {llmage_db}.llm l ON f.llmid = l.id
{where}
"""
count_recs = await sor.sqlExe(count_sql, ns)
total = count_recs[0].cnt if count_recs else 0
# Query with pagination offset = (page - 1) * page_size
offset = (page - 1) * page_size query_sql = sql + f" LIMIT {page_size} OFFSET {offset}"
query_sql = f"""select * from llmusage_accounting_failed {where} recs = await sor.sqlExe(query_sql, ns)
order by failed_time desc limit {page_size} offset {offset}"""
recs = await sor.sqlExe(query_sql, ns)
result['rows'] = [dict(r) for r in (recs or [])] rows = []
result['total'] = total for r in (recs or []):
result['page'] = page d = dict(r)
result['page_size'] = page_size rows.append(d)
result['success'] = True
result['rows'] = rows
result['total'] = total
result['page'] = page
result['page_size'] = page_size
result['success'] = True
except Exception as e: except Exception as e:
result['error'] = str(e) result['error'] = str(e)
debug(f'failed_accounting_list error: {format_exc()}')
return json.dumps(result, ensure_ascii=False, default=str) return json.dumps(result, ensure_ascii=False, default=str)

14
wwwroot/api/get_apis.dspy Normal file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env python3
import json
result = []
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 = [{'value': r['name'], 'text': f"{r['name']} ({r['path']})"} for r in (rows or [])]
except Exception as e:
pass
return json.dumps(result, ensure_ascii=False, default=str)

View File

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

View File

@ -0,0 +1,93 @@
result = {'success': False, 'rows': [], 'total': 0, 'page': 1, 'page_size': 10}
try:
dbname = get_module_dbname('llmage')
userid = await get_user()
page = int(params_kw.get('page', 1))
page_size = int(params_kw.get('pagerows', 10))
llmcatelogid = params_kw.get('llmcatelogid')
async with DBPools().sqlorContext(dbname) as sor:
# Build filter conditions
conditions = ["userid = ${userid}$"]
ns = {'userid': userid}
if llmcatelogid:
conditions.append("llmid in (select llmid from llm_api_map where llmcatelogid = ${llmcatelogid}$)")
ns['llmcatelogid'] = llmcatelogid
where_clause = " and ".join(conditions)
# Count total from both tables (并行两个 count 查询)
sql1 = f"select count(*) as cnt from llmusage where {where_clause}"
sql2 = f"select count(*) as cnt from llmusage_history where {where_clause}"
cnt1_recs = await sor.sqlExe(sql1, ns.copy())
cnt2_recs = await sor.sqlExe(sql2, ns.copy())
total = (cnt1_recs[0].cnt if cnt1_recs else 0) + (cnt2_recs[0].cnt if cnt2_recs else 0)
# 优化点 1: 分别查询两张表, 让各自走 (userid, use_time) 复合索引
# 每表取前 offset+page_size 条 (已按 use_time desc 排好)
offset = (page - 1) * page_size
fetch = offset + page_size
select_cols = ("id, llmid, use_date, use_time, userid, usages, ioinfo, "
"status, taskid, amount, cost, userorgid, accounting_status")
q1 = f"select {select_cols} from llmusage where {where_clause} order by use_time desc limit {fetch}"
q2 = f"select {select_cols} from llmusage_history where {where_clause} order by use_time desc limit {fetch}"
recs1 = await sor.sqlExe(q1, ns)
recs2 = await sor.sqlExe(q2, ns)
# 优化点 2: Python 归并两个已排序序列 (O(n) 比 SQL UNION+sort 快)
merged = []
i = j = 0
rows1 = [dict(r) for r in (recs1 or [])]
rows2 = [dict(r) for r in (recs2 or [])]
while i < len(rows1) and j < len(rows2):
if (rows1[i].get('use_time') or '') >= (rows2[j].get('use_time') or ''):
merged.append(rows1[i]); i += 1
else:
merged.append(rows2[j]); j += 1
merged.extend(rows1[i:])
merged.extend(rows2[j:])
# 应用分页
page_rows = merged[offset:offset + page_size]
# 优化点 3: 并发读取 ioinfo 文件 (不再串行 await)
import aiofiles
from ahserver.filestorage import FileStorage
fs = FileStorage()
async def _load_io(row):
webpath = row.get('ioinfo')
io_content = None
if webpath:
try:
real_path = fs.realPath(webpath)
async with aiofiles.open(real_path, 'rb') as f:
bin_data = await f.read()
io_content = json.loads(bin_data.decode('utf-8'))
except Exception:
io_content = None
row['io_content'] = io_content
if isinstance(row.get('usages'), str):
try:
row['usages'] = json.loads(row['usages'])
except Exception:
pass
return row
rows = []
for r in page_rows:
d = await _load_io(r)
rows.append(d)
result['rows'] = list(rows)
result['total'] = total
result['page'] = page
result['page_size'] = page_size
result['success'] = True
except Exception as e:
exception(f'{e}{format_exc()}')
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,15 @@
result = []
try:
async with get_sor_context(request._run_ns, 'rbac') as sor:
orgs = await sor.sqlExe(
"select id, orgname from organization order by orgname",
{}
)
if orgs:
for r in orgs:
result.append({'providerid': str(r.id), 'providerid_text': r.orgname or ''})
except Exception as e:
debug(f'get_organizations error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

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

View File

@ -0,0 +1,37 @@
llmid = params_kw.get('llmid')
allow_empty = params_kw.get('allow_empty', '')
result = []
if allow_empty:
result = [{'apiname': '', 'apiname_text': '不指定', 'value': '', 'text': '不指定'}]
try:
if not llmid:
return json.dumps(result, ensure_ascii=False)
# Get model's upappid from llmage db
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
llm_recs = await sor.sqlExe(
"select upappid from llm where id = ${llmid}$",
{'llmid': llmid}
)
if not llm_recs or not llm_recs[0].get('upappid'):
return json.dumps(result, ensure_ascii=False)
upappid = llm_recs[0].upappid
# Query uapi table from uapi module's db
async with get_sor_context(request._run_ns, 'uapi') as sor:
apis = await sor.sqlExe(
"select name as apiname, name as apiname_text from uapi where upappid = ${upappid}$ order by name",
{'upappid': upappid}
)
# Add value/text keys for form dropdown compatibility
for a in apis:
a['value'] = a['apiname']
a['text'] = a['apiname_text']
return json.dumps(result + list(apis), ensure_ascii=False)
except Exception as e:
debug(f'get_search_apiname error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,13 @@
result = [{'providerid': '', 'providerid_text': '全部'}]
try:
async with get_sor_context(request._run_ns, 'rbac') as sor:
orgs = await sor.sqlExe(
"select id as providerid, orgname as providerid_text from organization order by orgname",
{}
)
return json.dumps([{'providerid': '', 'providerid_text': '全部'}] + list(orgs), ensure_ascii=False)
except Exception as e:
debug(f'get_search_providerid error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,13 @@
result = [{'upappid': '', 'upappid_text': '全部'}]
try:
async with get_sor_context(request._run_ns, 'uapi') as sor:
apps = await sor.sqlExe(
"select id as upappid, name as upappid_text from upapp order by name",
{}
)
return json.dumps([{'upappid': '', 'upappid_text': '全部'}] + list(apps), ensure_ascii=False)
except Exception as e:
debug(f'get_search_upappid error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,16 @@
result = []
try:
async with get_sor_context(request._run_ns, 'uapi') as sor:
user_orgid = await get_userorgid()
apps = await sor.sqlExe(
"select id, name from upapp order by name",
{}
)
if apps:
for r in apps:
result.append({'upappid': str(r.id), 'upappid_text': r.name or ''})
except Exception as e:
debug(f'get_upapps error: {e}')
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,21 @@
#!/usr/bin/env python3
import json
from appPublic.uniqueID import getID
result = {'success': False, 'message': ''}
try:
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
data = params_kw.copy()
data.pop('page', None)
data.pop('rows', None)
data.pop('data_filter', None)
data['id'] = getID()
await sor.C('llm', data)
result['success'] = True
result['message'] = '创建成功'
except Exception as e:
result['message'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'message': ''}
try:
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
data = params_kw.copy()
data.pop('page', None)
data.pop('rows', None)
data.pop('data_filter', None)
record_id = data.get('id')
if not record_id:
result['message'] = '缺少id'
else:
await sor.D('llm', {'id': record_id})
result['success'] = True
result['message'] = '删除成功'
except Exception as e:
result['message'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -0,0 +1,88 @@
llmid = params_kw.get('llmid', '')
action = params_kw.get('action', 'check')
if not llmid:
return json.dumps({'error': 'missing llmid'}, ensure_ascii=False)
if action == 'inference':
# 验证推理配置是否完整
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe(
"select * from llm where id=${llmid}$", {'llmid': llmid})
if not recs:
return '❌ 模型记录不存在'
llm = recs[0]
# 检查 API 映射
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$",
{'llmid': llmid})
if not maps:
return '❌ 无 API 映射配置'
# 检查 upapp 和 uapi
uapi_recs = await sor.sqlExe("""
select a.*, e.ioid, e.stream, e.name as api_name
from llm a
join llm_api_map m on a.id = m.llmid
join upapp c on a.upappid = c.id
join uapi e on c.id = e.upappid and m.apiname = e.name
where a.id=${llmid}$""", {'llmid': llmid})
if not uapi_recs:
return '❌ uapi 配置不完整,无法调用'
uapi = uapi_recs[0]
# 检查 ioid
io_recs = await sor.sqlExe(
"select * from uapiio where id=${ioid}$", {'ioid': uapi.ioid})
if not io_recs:
return '❌ IO 定义不存在'
return f'✅ 推理配置验证通过\n模型: {llm.name}\nAPI: {uapi.api_name}\nIO: {uapi.ioid}\nStream: {uapi.stream}'
elif action == 'check_charging':
# 验证计费配置是否完整
usages_str = params_kw.get('usages', '{}')
try:
usages = json.loads(usages_str) if isinstance(usages_str, str) else usages_str
except:
usages = {}
async with get_sor_context(request._run_ns, 'llmage') as sor:
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$",
{'llmid': llmid})
if not maps:
return '❌ 无 API 映射'
ppids = [m.ppid for m in maps if m.ppid]
if not ppids:
return '❌ 无定价项目(ppid)'
ppid = ppids[0]
# 检查 pricing_program
async with get_sor_context(request._run_ns, 'pricing') as psor:
pregs = await psor.sqlExe(
"select * from pricing_program where id=${ppid}$", {'ppid': ppid})
if not pregs:
return f'❌ 定价项目不存在 (ppid={ppid})'
pp = pregs[0]
# 检查 pricing_program_timing
try:
timings = await psor.sqlExe(
"select * from pricing_program_timing where ppid=${ppid}$",
{'ppid': ppid})
if timings:
return f'✅ 计费配置验证通过\n定价项目: {pp.name}\n定价数据: {len(timings)}条记录\n测试用量: {json.dumps(usages)}'
else:
return f'⚠️ 定价项目存在但无定价数据\n定价项目: {pp.name}'
except Exception as e:
return f'⚠️ 定价数据查询失败: {e}'
return '无效的操作'

View File

@ -0,0 +1,24 @@
result = {'success': False, 'message': ''}
action = params_kw.action
try:
dbname = get_module_dbname('llmage')
record_id = params_kw.get('id')
if not record_id:
result['message'] = '缺少id'
elif action not in ('published', 'unpublished'):
result['message'] = '无效的状态值'
else:
async with DBPools().sqlorContext(dbname) as sor:
await sor.U('llm', {'id': record_id, 'status': action})
result['success'] = True
result['message'] = '上架成功' if action == 'published' else '下架成功'
except Exception as e:
result['message'] = str(e)
return {
"widgettype": "Text",
"options": {
"otext": result['message'],
"i18n": True
}
}

View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import json
result = {'success': False, 'message': ''}
try:
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
data = params_kw.copy()
data.pop('page', None)
data.pop('rows', None)
data.pop('data_filter', None)
record_id = data.pop('id', None)
if not record_id:
result['message'] = '缺少id'
else:
await sor.U('llm', data, {'id': record_id})
result['success'] = True
result['message'] = '更新成功'
except Exception as e:
result['message'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,7 +1,3 @@
#!/usr/bin/env python3
import json
from datetime import datetime
result = {'success': False, 'message': ''} result = {'success': False, 'message': ''}
try: try:
@ -11,14 +7,12 @@ try:
result['message'] = '缺少llmusageid参数' result['message'] = '缺少llmusageid参数'
else: else:
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
# 1. 重置 llmusage 记账状态为 created让后台循环重新处理
await sor.U('llmusage', { await sor.U('llmusage', {
'id': luid, 'id': luid,
'accounting_status': 'created' 'accounting_status': 'created'
}) })
# 2. 更新失败记录:标记已处理,增加重试次数 now = curDateString() + ' ' + timestampstr().split(' ')[1] if ' ' not in curDateString() else curDateString()
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.execute(""" await sor.execute("""
UPDATE llmusage_accounting_failed UPDATE llmusage_accounting_failed
SET handled = '1', SET handled = '1',
@ -33,4 +27,4 @@ try:
except Exception as e: except Exception as e:
result['message'] = str(e) result['message'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str) return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,28 @@
record_id = params_kw.get('id', '')
reason = '未找到记录'
if record_id:
dbname = get_module_dbname('llmage')
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.R('llmusage_accounting_failed', {'id': record_id})
if rows:
reason = rows[0].get('failed_reason', '') or '(空)'
return {
"widgettype": "VScrollPanel",
"options": {
"width": "100%",
"height": "100%",
"css": "card",
"padding": "12px"
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"text": reason,
"i18n": False
}
}
]
}

View File

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

1
wwwroot/api_doc.md Symbolic link
View File

@ -0,0 +1 @@
../docs/API.md

41
wwwroot/api_doc.ui Normal file
View File

@ -0,0 +1,41 @@
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"padding": "0"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"width": "100%",
"alignItems": "center",
"marginBottom": "16px"
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"text": "大模型 API 文档"
}
}
]
},
{
"widgettype": "VScrollPanel",
"options": {
"css": "filler"
},
"subwidgets": [
{
"widgettype": "MarkdownViewer",
"options": {
"md_url": "{{entire_url('/llmage/api_doc.md')}}",
"width": "100%"
}
}
]
}
]
}

View File

@ -0,0 +1,31 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 日期与状态: 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe(
"select * from llm where id=${llmid}$", {'llmid': llmid})
if not recs:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 日期与状态: 模型不存在", "i18n": False}
}, ensure_ascii=False)
llm = recs[0]
date_ok = bool(llm.enabled_date and llm.expired_date)
status_ok = llm.status == 'published'
if date_ok and status_ok:
text = f"✅ 日期与状态: 启用:{llm.enabled_date} 失效:{llm.expired_date} 状态:{llm.status}"
else:
text = f"❌ 日期与状态: 启用:{llm.enabled_date} 失效:{llm.expired_date} 状态:{llm.status}"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -0,0 +1,22 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 能力映射(llm_api_map): 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$", {'llmid': llmid})
if maps:
ppids = [m.ppid for m in maps if m.ppid]
text = f"✅ 能力映射(llm_api_map): {len(maps)}条记录, {len(ppids)}个有定价"
else:
text = "❌ 能力映射(llm_api_map): 无映射记录"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -0,0 +1,22 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 模型记录: 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe(
"select * from llm where id=${llmid}$", {'llmid': llmid})
if recs:
llm = recs[0]
text = f"✅ 模型记录: {llm.name} ({llm.model})"
else:
text = f"❌ 模型记录: llm id={llmid} 不存在"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -0,0 +1,42 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 定价数据: 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$", {'llmid': llmid})
ppids = [m.ppid for m in maps if m.ppid] if maps else []
if not ppids:
text = "❌ 定价数据: 无定价项目"
else:
ppid = ppids[0]
try:
async with get_sor_context(request._run_ns, 'pricing') as psor:
pregs = await psor.sqlExe(
"select * from pricing_program where id=${ppid}$", {'ppid': ppid})
if not pregs:
text = "❌ 定价数据: 依赖定价项目未通过"
else:
# 检查 pricing_program_timing 表
try:
timings = await psor.sqlExe(
"select count(*) as cnt from pricing_program_timing where ppid=${ppid}$", {'ppid': ppid})
cnt = timings[0].cnt if timings else 0
if cnt > 0:
text = f"✅ 定价数据(pricing_program_timing): {cnt}条记录"
else:
text = "❌ 定价数据: pricing_program_timing 无记录"
except Exception as e:
text = f"❌ 定价数据: {e}"
except Exception as e:
text = f"❌ 定价数据: {e}"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -0,0 +1,33 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 定价项目(pricing_program): 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$", {'llmid': llmid})
ppids = [m.ppid for m in maps if m.ppid] if maps else []
if not ppids:
text = "❌ 定价项目(pricing_program): llm_api_map中无ppid"
else:
ppid = ppids[0]
async with get_sor_context(request._run_ns, 'pricing') as psor:
pregs = await psor.sqlExe(
"select * from pricing_program where id=${ppid}$", {'ppid': ppid})
if pregs:
p = pregs[0]
display_name = getattr(p, 'display_text', '') or getattr(p, 'name', '')
text = f"✅ 定价项目(pricing_program): {display_name}"
if hasattr(p, 'id'):
text += f" (id={p.id})"
else:
text = f"❌ 定价项目(pricing_program): ppid={ppid} 未找到"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

30
wwwroot/check_uapi.dspy Normal file
View File

@ -0,0 +1,30 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ API映射(uapi): 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe("""
select a.*, e.ioid, e.stream
from llm a
join llm_api_map m on a.id = m.llmid
join upapp c on a.upappid = c.id
join uapi e on c.id = e.upappid and m.apiname = e.name
where a.id=${llmid}$""", {'llmid': llmid})
if recs:
text = f"✅ API映射(uapi): ioid={recs[0].ioid}, stream={recs[0].stream}"
else:
# Get apiname from llm
async with get_sor_context(request._run_ns, 'llmage') as sor:
llm_recs = await sor.sqlExe("select apiname from llm where id=${llmid}$", {'llmid': llmid})
apiname = llm_recs[0].apiname if llm_recs else 'N/A'
text = f"❌ API映射(uapi): apiname={apiname} 在upapp中未找到"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

33
wwwroot/check_uapiio.dspy Normal file
View File

@ -0,0 +1,33 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ IO定义(uapiio): 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
# First get ioid from uapi
recs = await sor.sqlExe("""
select e.ioid
from llm a
join llm_api_map m on a.id = m.llmid
join upapp c on a.upappid = c.id
join uapi e on c.id = e.upappid and m.apiname = e.name
where a.id=${llmid}$""", {'llmid': llmid})
if not recs:
text = "❌ IO定义(uapiio): 依赖 uapi 未通过"
else:
ioid = recs[0].ioid
recs2 = await sor.sqlExe(
"select * from uapiio where id=${ioid}$", {'ioid': ioid})
if recs2:
text = f"✅ IO定义(uapiio): uapiio id={ioid}"
else:
text = f"❌ IO定义(uapiio): ioid={ioid} 未找到"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

27
wwwroot/check_upapp.dspy Normal file
View File

@ -0,0 +1,27 @@
llmid = params_kw.get('llmid', '')
if not llmid:
return json.dumps({
"widgettype": "Text",
"options": {"text": "❌ 上位系统(upapp): 缺少llmid参数", "i18n": False}
}, ensure_ascii=False)
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe(
"select a.* from llm a, upapp b where a.id=${llmid}$ and a.upappid=b.id",
{'llmid': llmid})
if recs:
llm = recs[0]
text = f"✅ 上位系统(upapp): upappid={llm.upappid}"
else:
# Get llm info to show upappid
llm_recs = await sor.sqlExe(
"select upappid from llm where id=${llmid}$", {'llmid': llmid})
upappid = llm_recs[0].upappid if llm_recs else '未知'
text = f"❌ 上位系统(upapp): upappid={upappid} 未找到关联"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -3,134 +3,170 @@
"options": { "options": {
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
"padding": "16px", "padding": "8px",
"spacing": 12 "gap": "8px"
}, },
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "Title2", "widgettype": "InlineForm",
"id": "filter_form",
"options": { "options": {
"text": "记账失败记录", "css": "card",
"halign": "left" "padding": "8px",
} "submit_label": "查询",
}, "submit_css": "primary",
{ "fields": [
"widgettype": "HBox", {
"options": { "name": "start_date",
"width": "100%", "label": "开始日期",
"spacing": 12, "uitype": "date",
"alignItems": "flex-end" "cwidth": 10
},
{
"name": "end_date",
"label": "结束日期",
"uitype": "date",
"cwidth": 10
},
{
"name": "handled",
"label": "处理状态",
"uitype": "code",
"cwidth": 8,
"data": [
{"value": "", "text": "全部"},
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
}
]
}, },
"subwidgets": [ "binds": [
{ {
"widgettype": "VBox", "wid": "self",
"options": {"spacing": 4}, "event": "submit",
"subwidgets": [ "actiontype": "script",
{"widgettype": "Text", "options": {"text": "开始日期", "fontSize": "12px"}}, "target": "failed_table",
{"widgettype": "UiDate", "id": "start_date", "options": {"width": "150px"}} "script": "var tbl = bricks.getWidgetById('failed_table', bricks.app.root); if(tbl) await tbl.render(params);"
]
},
{
"widgettype": "VBox",
"options": {"spacing": 4},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "结束日期", "fontSize": "12px"}},
{"widgettype": "UiDate", "id": "end_date", "options": {"width": "150px"}}
]
},
{
"widgettype": "VBox",
"options": {"spacing": 4},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "处理状态", "fontSize": "12px"}},
{
"widgettype": "Combobox",
"id": "handled_filter",
"options": {
"width": "120px",
"data": [
{"value": "", "text": "全部"},
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
}
}
]
},
{
"widgettype": "VBox",
"options": {"spacing": 4},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "", "fontSize": "12px"}},
{
"widgettype": "Button",
"id": "search_btn",
"options": {
"label": "查询",
"bgcolor": "#1976d2",
"color": "#ffffff",
"width": "80px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "failed_table",
"script": "var sd = this.root.getElementById('start_date'); var ed = this.root.getElementById('end_date'); var hf = this.root.getElementById('handled_filter'); var params = {handled: hf.value}; if(sd.value) params.start_date = sd.value; if(ed.value) params.end_date = ed.value; this.root.getElementById('failed_table').load(params);"
}]
}
]
},
{
"widgettype": "VBox",
"options": {"spacing": 4},
"subwidgets": [
{"widgettype": "Text", "options": {"text": "", "fontSize": "12px"}},
{
"widgettype": "Button",
"id": "retry_btn",
"options": {
"label": "重试",
"bgcolor": "#4caf50",
"color": "#ffffff",
"width": "80px"
},
"binds": [{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "var dv = this.root.getElementById('failed_table'); var row = dv.selected_row || (dv.selected_rows && dv.selected_rows[0]); if(!row || !row.llmusageid) { alert('请先选中一条记录'); return; } var url = bricks.build_url ? bricks.build_url('/llmage/api/retry_accounting.dspy') : '/llmage/api/retry_accounting.dspy'; fetch(url + '?id=' + row.llmusageid).then(function(r){return r.json();}).then(function(d){ if(d.success) { alert(d.message); dv.load({}); } else { alert('失败: ' + d.message); } }).catch(function(e){ alert('请求异常: ' + e); });"
}]
}
]
} }
] ]
}, },
{ {
"widgettype": "DataViewer", "widgettype": "Tabular",
"id": "failed_table", "id": "failed_table",
"options": { "options": {
"url": "{{entire_url('/llmage/api/failed_accounting_list.dspy')}}", "width": "100%",
"title": "失败记录列表", "height": "100%",
"pageSize": 20, "css": "card",
"fields": [ "toolbar": {
{"name": "id", "title": "ID", "hidden": true}, "tools": [
{"name": "llmusageid", "title": "使用记录ID", "width": "120px"}, {
{"name": "llmid", "title": "模型ID", "width": "120px"}, "name": "show_reason",
{"name": "userid", "title": "用户ID", "width": "120px"}, "label": "原因",
{"name": "userorgid", "title": "机构ID", "width": "120px"}, "selected_row": true
{"name": "use_date", "title": "使用日期", "width": "110px"}, },
{"name": "use_time", "title": "使用时间", "width": "160px"}, {
{"name": "amount", "title": "金额", "width": "80px"}, "name": "retry_accounting",
{"name": "cost", "title": "成本", "width": "80px"}, "label": "重试记账",
{"name": "failed_reason", "title": "失败原因", "width": "30%"}, "selected_row": true
{"name": "failed_time", "title": "失败时间", "width": "160px"}, }
{"name": "retry_count", "title": "重试次数", "width": "80px"}, ]
{"name": "handled", "title": "状态", "width": "80px", },
"formatter": "function(v){return v==='1'?'已处理':'未处理';}"} "data_url": "{{entire_url('/llmage/api/failed_accounting_list.dspy')}}",
] "data_method": "GET",
} "page_rows": 20,
"row_options": {
"browserfields": {
"exclouded": ["id", "failed_reason"],
"alters": {
"handled": {
"uitype": "code",
"data": [
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
},
"userid": {
"uitype": "code",
"valueField": "userid",
"textField": "userid_text",
"params": {
"dbname": "sage",
"table": "users",
"tblvalue": "userid",
"tbltext": "username",
"valueField": "userid",
"textField": "userid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"userorgid": {
"uitype": "code",
"valueField": "userorgid",
"textField": "userorgid_text",
"params": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "userorgid",
"textField": "userorgid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
},
"llmid": {
"uitype": "code",
"valueField": "llmid",
"textField": "llmid_text",
"params": {
"dbname": "llmage",
"table": "llm",
"tblvalue": "id",
"tbltext": "name",
"valueField": "llmid",
"textField": "llmid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
}
}
},
"fields": [
{"name": "llmusageid", "title": "使用记录ID", "type": "str", "length": 32, "cwidth": 12, "uitype": "str", "label": "使用记录ID"},
{"name": "llmid", "title": "模型", "type": "str", "length": 32, "cwidth": 12, "uitype": "str", "label": "模型"},
{"name": "userid", "title": "用户", "type": "str", "length": 32, "cwidth": 10, "uitype": "str", "label": "用户"},
{"name": "userorgid", "title": "机构", "type": "str", "length": 32, "cwidth": 10, "uitype": "str", "label": "机构"},
{"name": "use_time", "title": "使用时间", "type": "timestamp", "cwidth": 14, "uitype": "str", "label": "使用时间"},
{"name": "amount", "title": "金额", "type": "double", "length": 18, "dec": 5, "cwidth": 8, "uitype": "float", "label": "金额"},
{"name": "failed_reason", "title": "失败原因", "type": "text", "cwidth": 20, "uitype": "text", "label": "失败原因"},
{"name": "failed_time", "title": "失败时间", "type": "timestamp", "cwidth": 14, "uitype": "str", "label": "失败时间"},
{"name": "retry_count", "title": "重试", "type": "int", "cwidth": 4, "uitype": "int", "label": "重试"},
{"name": "handled", "title": "状态", "type": "str", "length": 1, "cwidth": 6, "uitype": "code", "label": "状态"}
]
}
},
"binds": [
{
"wid": "self",
"event": "show_reason",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "失败原因",
"cwidth": 30,
"cheight": 20
},
"options": {
"url": "{{entire_url('/llmage/api/show_failed_reason.dspy')}}?id=${id}$"
}
},
{
"wid": "self",
"event": "retry_accounting",
"actiontype": "script",
"target": "self",
"script": "var dv = bricks.getWidgetById('failed_table', bricks.app.root); if(!dv || !dv.select_row || !dv.select_row.user_data) { alert('请先选中一条记录'); return; } var row = dv.select_row.user_data; if(!row.llmusageid) { alert('记录缺少llmusageid'); return; } var resp = await fetch('{{entire_url('/llmage/api/retry_accounting.dspy')}}?id=' + row.llmusageid); var d = await resp.json(); if(d.success) { alert(d.message); await dv.render({}); } else { alert('失败: ' + d.message); }"
}
]
} }
] ]
} }

View File

@ -1,9 +1,23 @@
userid = await get_user() userid = await get_user()
llmcatelogid = params_kw.get('llmcatelogid', 't2t')
tasks = await get_today_asynctask_list(userid) tasks = await get_today_asynctask_list(userid)
for t in tasks: async with get_sor_context(request._run_ns, 'llmage') as sor:
bin = await read_webpath(t.ioinfo) for t in tasks:
t.ioinfo = json.loads(bin.decode('utf-8')) bin = await read_webpath(t.ioinfo)
t.ioinfo = json.loads(bin.decode('utf-8'))
# 查询 llmcatelogid
catid = None
if hasattr(t, 'llmid') and t.llmid:
sql = '''select m.llmcatelogid from llm_api_map m where m.llmid = ${llmid}$ limit 1'''
recs = await sor.sqlExe(sql, {'llmid': t.llmid})
if recs:
catid = recs[0].llmcatelogid
t.llmcatelogid = catid
# 按 llmcatelogid 过滤
tasks = [t for t in tasks if t.llmcatelogid == llmcatelogid]
return { return {
'status': 'ok', 'status': 'ok',

View File

@ -1,32 +1,36 @@
lt = params_kw.llmcatelogid or 't2v'
lt = '文生视频' debug(f'{lt=}')
if params_kw.type in ['文生视频', '参考生视频', '图生视频']: try:
lt = params_kw.type async with get_sor_context(request._run_ns, 'llmage') as sor:
async with get_sor_context(request._run_ns, 'llmage') as sor: sql = '''select distinct a.*, e.input_fields from llm a
sql = '''select distinct a.*, e.input_fields from llm a join llm_api_map m on a.id = m.llmid
join llm_api_map m on a.id = m.llmid join llmcatelog b on m.llmcatelogid = b.id
join llmcatelog b on m.llmcatelogid = b.id join uapi d on d.upappid = a.upappid and m.apiname = d.name
join upapp c on a.upappid = c.id join uapiio e on d.ioid = e.id
join uapi d on c.apisetid = d.apisetid and a.apiname = d.name where (b.id=${lt}$ OR b.name=${lt}$)
join uapiio e on d.ioid = e.id and a.enabled_date <= ${biz_date}$
where b.name=${lt}$ and ${biz_date}$ < a.expired_date
and a.enabled_date <= ${biz_date}$ and a.status = 'published'
and ${biz_date}$ < a.expired_date and m.ppid is not NULL'''
and ppid is not NULL''' biz_date = await get_business_date(sor)
biz_date = await get_business_date(sor) recs = await sor.sqlExe(sql, {
recs = await sor.sqlExe(sql, { 'biz_date': biz_date,
'biz_date': biz_date, 'lt': lt
'lt': lt })
}) for r in recs:
for r in recs: r.input_fields = json.loads(r.input_fields)
r.input_fields = json.loads(r.input_fields) return {
'status': 'ok',
'data': recs
}
return { return {
'status': 'ok', 'status': 'error',
'data': recs 'data':{
'message': 'server error'
}
} }
return { except Exception as e:
'status': 'error', debug(f'{lt=},{e},{format_exc()}')
'data':{
'message': 'server error'
}
}

View File

@ -17,9 +17,7 @@
{ {
"widgettype": "Title2", "widgettype": "Title2",
"options": { "options": {
"text": "LLM 模型管理", "text": "LLM 模型管理"
"color": "#F1F5F9",
"fontWeight": "700"
} }
}, },
{ {
@ -28,181 +26,176 @@
{ {
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "模型配置、目录分类与调用监控", "text": "模型类型、模型配置与记账失败记录",
"fontSize": "14px", "cfontsize": 1.2
"color": "#64748B"
} }
} }
] ]
}, },
{ {
"widgettype": "ResponsableBox", "widgettype": "VBox",
"options": { "options": {
"gap": "16px", "css": "filler",
"minWidth": "250px", "spacing": 16
"marginBottom": "24px"
}, },
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "VBox", "widgettype": "ResponsableBox",
"options": { "options": {
"bgcolor": "#1E293B", "gap": "16px",
"padding": "24px", "minWidth": "250px"
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
}, },
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/llmcatelog_list.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "Svg", "widgettype": "VBox",
"options": { "options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#90caf9\" stroke-width=\"1.5\"><path d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\"/></svg>", "css": "card",
"width": "36px", "cwidth": 23,
"height": "36px", "padding": "16px",
"marginBottom": "16px" "cursor": "pointer",
} "borderRadius": "8px"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/llmcatelog_list.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\"><path d=\"M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z\"/></svg>",
"width": "28px",
"height": "28px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "模型类型管理",
"marginTop": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理模型的分类和类型",
"cfontsize": 1.2
}
}
]
}, },
{ {
"widgettype": "Title4", "widgettype": "VBox",
"options": { "options": {
"text": "模型类型管理", "css": "card",
"color": "#F1F5F9", "cwidth": 23,
"fontWeight": "600", "padding": "16px",
"marginBottom": "8px" "cursor": "pointer",
} "borderRadius": "8px"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/llm')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"2\"><path d=\"M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15.75c-2.062 0-4.024-.614-5.67-1.757l-1.57-.393m15.04 0L12 21 5.25 13.893\"/></svg>",
"width": "28px",
"height": "28px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "模型管理",
"marginTop": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理 LLM 模型配置",
"cfontsize": 1.2
}
}
]
}, },
{ {
"widgettype": "Text", "widgettype": "VBox",
"options": { "options": {
"text": "管理模型的分类目录和类型定义", "css": "card",
"fontSize": "14px", "cwidth": 23,
"color": "#94A3B8" "padding": "16px",
} "cursor": "pointer",
"borderRadius": "8px"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/failed_accounting.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#EF4444\" stroke-width=\"2\"><path d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\"/></svg>",
"width": "28px",
"height": "28px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "记账失败记录",
"marginTop": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查看和检索记账失败的记录",
"cfontsize": 1.2
}
}
]
} }
] ]
}, },
{ {
"widgettype": "VBox", "widgettype": "VScrollPanel",
"id": "llmage_content",
"options": { "options": {
"bgcolor": "#1E293B", "css": "filler",
"padding": "24px", "width": "100%",
"borderRadius": "12px", "height": "100%"
"border": "1px solid #334155", }
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/llm')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#4caf50\" stroke-width=\"1.5\"><path d=\"M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15.75c-2.062 0-4.024-.614-5.67-1.757l-1.57-.393m15.04 0L12 21 5.25 13.893\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "模型配置",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "管理 LLM 模型的API配置与供应商映射",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
},
{
"widgettype": "VBox",
"options": {
"bgcolor": "#1E293B",
"padding": "24px",
"borderRadius": "12px",
"border": "1px solid #334155",
"cursor": "pointer"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urlwidget",
"target": "app.llmage_content",
"options": {
"url": "{{entire_url('/llmage/failed_accounting.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"36\" height=\"36\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#EF4444\" stroke-width=\"1.5\"><path d=\"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z\"/></svg>",
"width": "36px",
"height": "36px",
"marginBottom": "16px"
}
},
{
"widgettype": "Title4",
"options": {
"text": "记账失败记录",
"color": "#F1F5F9",
"fontWeight": "600",
"marginBottom": "8px"
}
},
{
"widgettype": "Text",
"options": {
"text": "查看和检索调用计费失败记录",
"fontSize": "14px",
"color": "#94A3B8"
}
}
]
} }
] ]
},
{
"widgettype": "VBox",
"id": "llmage_content",
"options": {
"width": "100%",
"flex": "1",
"marginTop": "20px"
}
} }
] ]
} }

View File

@ -3,7 +3,7 @@ db = DBPools()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
sql = """select distinct a.* from llm a sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
where m.llmcatelogid = ${llmcatelogid}$ and a.id != ${llmid}$""" where m.llmcatelogid = ${llmcatelogid}$ and a.id != ${llmid}$ and a.status = 'published'"""
ns = params_kw.copy() ns = params_kw.copy()
recs = await sor.sqlExe(sql, ns) recs = await sor.sqlExe(sql, ns)
for r in recs.get('rows', []): for r in recs.get('rows', []):

View File

@ -6,6 +6,17 @@ page = int(params_kw.get('page', 1))
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
db = DBPools() db = DBPools()
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:
# If llmcatelogid not provided, derive it from llmid via llm_api_map
llmcatelogid = params_kw.get('llmcatelogid')
if not llmcatelogid:
llmid = params_kw.get('llmid')
if llmid:
recs = await sor.sqlExe("select llmcatelogid from llm_api_map where llmid=${llmid}$ limit 1", {'llmid': llmid})
if recs:
llmcatelogid = recs[0].llmcatelogid
if not llmcatelogid:
return {}
params_kw.llmcatelogid = llmcatelogid
sql = """select x.*, sql = """select x.*,
z.input_fields, z.input_fields,
y.system_message, y.system_message,
@ -17,18 +28,23 @@ from llm a
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id join llmcatelog b on m.llmcatelogid = b.id
join upapp c on a.upappid = c.id join upapp c on a.upappid = c.id
join uapi e on c.apisetid = e.apisetid and a.apiname = e.name join uapi e on c.id = e.upappid and m.apiname = e.name
where a.status = 'published'
and m.llmcatelogid = ${llmcatelogid}$
) x left join historyformat y on x.hfid = y.id ) x left join historyformat y on x.hfid = y.id
left join uapiio z on x.ioid = z.id left join uapiio z on x.ioid = z.id
where m.llmcatelogid = ${llmcatelogid}$ where 1=1
and x.id != ${llmid}$
""" """
llmid = params_kw.get('llmid')
if llmid:
sql += " and x.id != ${llmid}$"
ns = params_kw.copy() ns = params_kw.copy()
ns.page = page ns.page = page
ns.pagerows = pagerows ns.pagerows = pagerows
recs = await sor.sqlPaging(sql, ns) recs = await sor.sqlPaging(sql, ns)
for r in recs.get('rows', []): for r in recs.get('rows', []):
r.llmid = r.id r.llmid = r.id
r.llmcatelogid = llmcatelogid
r.modelname = r.name r.modelname = r.name
r.description = ''.join(''.join(r.description.split('\n')).split('\r')) r.description = ''.join(''.join(r.description.split('\n')).split('\r'))
r.response_mode = r.stream r.response_mode = r.stream

View File

@ -53,23 +53,22 @@
"name": "llmcatelogid", "name": "llmcatelogid",
"label": "选择分类", "label": "选择分类",
"uitype": "code", "uitype": "code",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}", "dataurl": "{{entire_url('./api/get_catelogs.dspy')}}",
"data_field": "catelogs",
"placeholder": "请选择分类" "placeholder": "请选择分类"
}, },
{ {
"name": "apiname", "name": "apiname",
"label": "API接口", "label": "API接口",
"uitype": "code", "uitype": "code",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}", "dataurl": "{{entire_url('./api/get_search_apiname.dspy')}}",
"data_field": "apis",
"placeholder": "请选择API接口" "placeholder": "请选择API接口"
}, },
{ {
"name": "query_apiname", "name": "query_apiname",
"label": "查询API", "label": "查询API",
"uitype": "str", "uitype": "code",
"placeholder": "异步查询API名多个用逗号分隔" "dataurl": "{{entire_url('./api/get_search_apiname.dspy?allow_empty=1')}}",
"placeholder": "不指定或选择查询API"
}, },
{ {
"name": "query_period", "name": "query_period",
@ -81,8 +80,7 @@
"name": "ppid", "name": "ppid",
"label": "计费项目", "label": "计费项目",
"uitype": "code", "uitype": "code",
"dataurl": "{{entire_url('./api/llm_api_map_options.dspy')}}", "dataurl": "{{entire_url('./api/get_ppids.dspy')}}",
"data_field": "ppids",
"placeholder": "请选择计费项目" "placeholder": "请选择计费项目"
} }
], ],
@ -100,7 +98,11 @@
"event": "click", "event": "click",
"actiontype": "urlwidget", "actiontype": "urlwidget",
"target": "PopupWindow", "target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"}, "popup_options": {
"archor": "cc",
"width": "30%",
"height": "20%"
},
"options": { "options": {
"url": "{{entire_url('./api/llm_api_map_create.dspy')}}", "url": "{{entire_url('./api/llm_api_map_create.dspy')}}",
"params": { "params": {
@ -124,7 +126,8 @@
"options": { "options": {
"width": "calc(100% - 40px)", "width": "calc(100% - 40px)",
"margin": "0 20px", "margin": "0 20px",
"spacing": 12 "spacing": 12,
"cheight": 30
}, },
"subwidgets": [ "subwidgets": [
{ {
@ -146,19 +149,46 @@
"page_rows": 20, "page_rows": 20,
"row_options": { "row_options": {
"fields": [ "fields": [
{"name": "llm_name", "title": "模型名称", "width": 180}, {
{"name": "catelog_name", "title": "分类", "width": 120}, "name": "llm_name",
{"name": "apiname", "title": "API接口", "width": 150}, "title": "模型名称",
{"name": "query_apiname", "title": "查询API", "width": 180}, "width": 180
{"name": "query_period", "title": "间隔(秒)", "width": 80}, },
{"name": "ppid_name", "title": "计费项目", "width": 150}, {
"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", "name": "actions",
"title": "操作", "title": "操作",
"width": 100, "width": 100,
"uitype": "button", "uitype": "button",
"data": [ "data": [
{"text": "删除", "event": "delete_map"} {
"text": "删除",
"event": "delete_map"
}
] ]
} }
] ]
@ -170,7 +200,11 @@
"event": "delete_map", "event": "delete_map",
"actiontype": "urlwidget", "actiontype": "urlwidget",
"target": "PopupWindow", "target": "PopupWindow",
"popup_options": {"archor": "cc", "width": "30%", "height": "20%"}, "popup_options": {
"archor": "cc",
"width": "30%",
"height": "20%"
},
"options": { "options": {
"url": "{{entire_url('./api/llm_api_map_delete.dspy')}}", "url": "{{entire_url('./api/llm_api_map_delete.dspy')}}",
"params": { "params": {

View File

@ -3,10 +3,82 @@
{% set userorgid = get_userorgid() %} {% set userorgid = get_userorgid() %}
{% if params_kw.id %} {% if params_kw.id %}
{% if checkCustomerBalance(params_kw.id, userid, userorgid) %} {% if checkCustomerBalance(params_kw.id, userid, userorgid) %}
{% set llm = get_llm(params_kw.id) %} {% set catelogs = get_llm_catelogs(params_kw.id) %}
{% set oops=debug(json.dumps(llm, ensure_ascii=Fasle)) %} {% set ns = namespace(active_catelogid=params_kw.get('catelogid', '')) %}
{% if not ns.active_catelogid %}
{% for c in catelogs %}{% if c.isdefault and not ns.active_catelogid %}{% set ns.active_catelogid = c.catelogid %}{% endif %}{% endfor %}
{% endif %}
{% if not ns.active_catelogid and catelogs %}{% set ns.active_catelogid = catelogs[0].catelogid %}{% endif %}
{% set llm = get_llm(params_kw.id, ns.active_catelogid) %}
{% set kdbs = get_user_kdbs(request) %} {% set kdbs = get_user_kdbs(request) %}
{% if llm %} {% if llm %}
{% if len(catelogs) > 1 %}
{
"widgettype":"VBox",
"options":{
"width":"100%",
"height":"100%"
},
"subwidgets":[
{
"widgettype":"HBox",
"options":{
"cheight":3,
"css":"card",
"padding":"8px"
},
"subwidgets":[
{% for c in catelogs %}
{
"widgettype":"Button",
"options":{
"label":"{{c.catelogname}}",
"actiontype":"link",
{% if c.catelogid == ns.active_catelogid %}
"css":"primary",
{% endif %}
"url":"{{entire_url('/llmage/llm_dialog.ui')}}?id={{params_kw.id}}&catelogid={{c.catelogid}}"
}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
},
{
"widgettype":"LlmIO",
"options":{
"width":"100%",
"height":"100%",
"title":"{{llm.name}}",
{% if len(kdbs) > 0 %}
"enabled_kdb": true,
"kdb_setting":{},
"get_kdb_url": "{{entire_url('/rag/get_my_kdbs.dspy')}}",
{% endif %}
"list_models_url":"{{entire_url('list_paging_catelog_llms.dspy')}}?llmcatelogid={{ns.active_catelogid}}",
"estimate_url":"{{entire_url('model_estimate.dspy')}}",
"input_fields":{{llm.input_fields}},
"models":[
{
"llmid":"{{llm.id}}",
"llmcatelogid":"{{ns.active_catelogid}}",
"response_mode": "{{llm.stream}}",
"icon":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}",
"url":"{{entire_url('/llmage/llminference.dspy')}}",
{% if llm.stream == 'stream' %}
"stream": true,
{% endif %}
{% if llm.stream =='async' %}
"query_url": "{{entire_url('/llmage/tasks')}}",
{% endif %}
"model":"{{llm.model}}",
"modelname":"{{llm.name}}"
}
]
}
}
]
}
{% else %}
{ {
"widgettype":"LlmIO", "widgettype":"LlmIO",
"options":{ "options":{
@ -18,12 +90,15 @@
"kdb_setting":{}, "kdb_setting":{},
"get_kdb_url": "{{entire_url('/rag/get_my_kdbs.dspy')}}", "get_kdb_url": "{{entire_url('/rag/get_my_kdbs.dspy')}}",
{% endif %} {% endif %}
"list_models_url":"{{entire_url('list_paging_catelog_llms.dspy')}}", "list_models_url":"{{entire_url('list_paging_catelog_llms.dspy')}}{% if ns.active_catelogid %}?llmcatelogid={{ns.active_catelogid}}{% endif %}",
"estimate_url":"{{entire_url('model_estimate.dspy')}}", "estimate_url":"{{entire_url('model_estimate.dspy')}}",
"input_fields":{{llm.input_fields}}, "input_fields":{{llm.input_fields}},
"models":[ "models":[
{ {
"llmid":"{{llm.id}}", "llmid":"{{llm.id}}",
{% if ns.active_catelogid %}
"llmcatelogid":"{{ns.active_catelogid}}",
{% endif %}
"response_mode": "{{llm.stream}}", "response_mode": "{{llm.stream}}",
"icon":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}", "icon":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}",
"url":"{{entire_url('/llmage/llminference.dspy')}}", "url":"{{entire_url('/llmage/llminference.dspy')}}",
@ -39,6 +114,7 @@
] ]
} }
} }
{% endif %}
{% else %} {% else %}
{ {
"widgettype":"Text", "widgettype":"Text",

156
wwwroot/llm_launch_check.ui Normal file
View File

@ -0,0 +1,156 @@
{% if params_kw.id %}
{% set llmid = params_kw.id %}
{
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%",
"spacing": 10
},
"subwidgets": [
{
"widgettype": "Title",
"options": {
"text": "模型上线检查",
"level": 2
}
},
{
"widgettype": "VScrollPanel",
"id": "check_scroll",
"options": {
"css": "filler",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "VBox",
"id": "checks_list",
"options": {
"spacing": 5
},
"subwidgets": [
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_model_record.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_date_status.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_upapp.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_uapi.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_uapiio.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_llm_api_map.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_pricing_program.dspy?llmid={{llmid}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "/llmage/check_pricing_data.dspy?llmid={{llmid}}"
}
}
]
},
{
"widgettype": "Text",
"id": "test_result",
"options": {
"text": "",
"i18n": false
}
},
{
"widgettype": "Text",
"id": "charge_result",
"options": {
"text": "",
"i18n": false
}
}
]
},
{
"widgettype": "HBox",
"options": {
"spacing": 10,
"alignItems": "center"
},
"subwidgets": [
{
"widgettype": "Button",
"id": "test_btn",
"options": {
"label": "体验一次",
"i18n": false
}
},
{
"widgettype": "Button",
"id": "charge_btn",
"options": {
"label": "检查计费",
"i18n": false
}
}
]
}
],
"binds": [
{
"wid": "test_btn",
"event": "click",
"actiontype": "urldata",
"target": "test_result",
"options": {
"url": "/llmage/api/llm_launch_check_api.dspy?llmid={{llmid}}&action=inference"
}
},
{
"wid": "charge_btn",
"event": "click",
"actiontype": "urldata",
"target": "charge_result",
"options": {
"url": "/llmage/api/llm_launch_check_api.dspy?llmid={{llmid}}&action=check_charging&usages={\"prompt_tokens\":1000,\"completion_tokens\":500}"
}
}
]
}
{% else %}
{
"widgettype": "Text",
"options": {
"text": "缺少模型ID参数",
"i18n": true
}
}
{% endif %}

View File

@ -2,7 +2,7 @@ llmid = params_kw.llmid
today= params_kw.today today= params_kw.today
msgs = [] msgs = []
async with get_sor_context(request._run_ns, 'llmage') as sor: async with get_sor_context(request._run_ns, 'llmage') as sor:
sql = "select * from llm where id=${llmid}$ and enabled_date <= ${today}$ and expired_date > ${today}$" sql = "select * from llm where id=${llmid}$ and enabled_date <= ${today}$ and expired_date > ${today}$ and status = 'published'"
ns = {'llmid': llmid, 'today': today} ns = {'llmid': llmid, 'today': today}
recs = await sor.sqlExe(sql, ns.copy()) recs = await sor.sqlExe(sql, ns.copy())
if recs: if recs:
@ -24,8 +24,9 @@ where a.id=${llmid}$
sql = """select a.*, e.ioid, e.stream sql = """select a.*, e.ioid, e.stream
from llm a from llm a
join llm_api_map m on a.id = m.llmid
join upapp c on a.upappid = c.id join upapp c on a.upappid = c.id
join uapi e on c.apisetid = e.apisetid and a.apiname = e.name join uapi e on c.id = e.upappid and m.apiname = e.name
where a.id=${llmid}$ where a.id=${llmid}$
and a.expired_date > ${today}$ and a.expired_date > ${today}$
and a.enabled_date <= ${today}$""" and a.enabled_date <= ${today}$"""
@ -38,8 +39,9 @@ where a.id=${llmid}$
sql = """select a.*, e.ioid, e.stream sql = """select a.*, e.ioid, e.stream
from llm a from llm a
join llm_api_map m on a.id = m.llmid
join upapp c on a.upappid = c.id join upapp c on a.upappid = c.id
join uapi e on c.apisetid = e.apisetid and a.apiname = e.name join uapi e on c.id = e.upappid and m.apiname = e.name
join uapiio b on e.ioid = b.id join uapiio b on e.ioid = b.id
where a.id=${llmid}$ where a.id=${llmid}$
and a.expired_date > ${today}$ and a.expired_date > ${today}$

View File

@ -1,4 +1,4 @@
debug(f'{params_kw=}') debug_params('params_kw', params_kw)
ns = params_kw.copy() ns = params_kw.copy()
if not ns.page: if not ns.page:
ns.page = 1 ns.page = 1

View File

@ -1,4 +1,4 @@
debug(f'{params_kw=}') debug_params('params_kw', params_kw)
if params_kw.off_peak: if params_kw.off_peak:
off_peak = params_kw.off_peak off_peak = params_kw.off_peak
if off_peak in [True, "Y" "y", 1, "1"]: if off_peak in [True, "Y" "y", 1, "1"]:
@ -11,6 +11,8 @@ userorgid = await get_userorgid()
if userid is None: if userid is None:
return UiError(title='llm inference', message='Please login first') return UiError(title='llm inference', message='Please login first')
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid) f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
return UiError(title='llm inference', message='余额不足或模型未配置定价')
kdbids = params_kw.kdbids kdbids = params_kw.kdbids
if kdbids: if kdbids:
data = { data = {
@ -25,7 +27,7 @@ if kdbids:
ret = await rfexe('fusedsearch', request, params) ret = await rfexe('fusedsearch', request, params)
data.update(ret) data.update(ret)
params_kw.prompt = await tmpl_engine.renders(tmpl, data) params_kw.prompt = await tmpl_engine.renders(tmpl, data)
debug(f'{params=}rag return {data}, {params_kw.prompt=}') debug_params('rag', {'query': params.get('query',''), 'prompt_len': len(str(params_kw.prompt))})
env = DictObject(**globals()) env = DictObject(**globals())
return await inference(request, env=env) return await inference(request, env=env)

View File

@ -0,0 +1,161 @@
ns = params_kw.copy()
debug(f'get_llmusage_accounting_failed.dspy:{ns=}')
if not ns.get('page'):
ns['page'] = 1
if not ns.get('sort'):
ns['sort'] = 'failed_time desc'
# InlineForm filter conditions
extra_conds = []
if ns.get('handled') and ns['handled'] != '':
extra_conds.append("handled = ${filter_handled}$")
ns['filter_handled'] = ns['handled']
if ns.get('filter_llmid') and ns['filter_llmid'] != '':
extra_conds.append("llmid like ${filter_llmid}$")
ns['filter_llmid'] = f"%{ns['filter_llmid']}%"
if ns.get('filter_userid') and ns['filter_userid'] != '':
extra_conds.append("userid like ${filter_userid}$")
ns['filter_userid'] = f"%{ns['filter_userid']}%"
if ns.get('use_date') and ns['use_date'] != '':
extra_conds.append("use_date = ${filter_use_date}$")
ns['filter_use_date'] = ns['use_date']
if ns.get('failed_reason') and ns['failed_reason'] != '':
extra_conds.append("failed_reason like ${filter_failed_reason}$")
ns['filter_failed_reason'] = f"%{ns['failed_reason']}%"
extra_filterstr = ''
if extra_conds:
extra_filterstr = ' and ' + ' and '.join(extra_conds)
sql = f'''select * from llmusage_accounting_failed where 1=1 {extra_filterstr} [[filterstr]]'''
filterjson = params_kw.get('data_filter')
if filterjson and isinstance(filterjson, str):
try:
filterjson = json.loads(filterjson)
except (json.JSONDecodeError, TypeError):
filterjson = None
fields_str=r'''[
{
"name": "id",
"title": "id",
"type": "str",
"length": 32
},
{
"name": "llmusageid",
"title": "使用记录id",
"type": "str",
"length": 32
},
{
"name": "llmid",
"title": "模型id",
"type": "str",
"length": 32
},
{
"name": "userid",
"title": "用户id",
"type": "str",
"length": 32
},
{
"name": "userorgid",
"title": "用户机构id",
"type": "str",
"length": 32
},
{
"name": "use_date",
"title": "使用日期",
"type": "date"
},
{
"name": "use_time",
"title": "使用时间",
"type": "timestamp"
},
{
"name": "amount",
"title": "交易金额",
"type": "double",
"length": 18,
"dec": 5
},
{
"name": "cost",
"title": "交易成本",
"type": "double",
"length": 18,
"dec": 5
},
{
"name": "failed_reason",
"title": "失败原因",
"type": "text"
},
{
"name": "failed_time",
"title": "失败时间",
"type": "timestamp"
},
{
"name": "retry_count",
"title": "重试次数",
"type": "int"
},
{
"name": "handled",
"title": "是否已处理",
"type": "str",
"length": 1,
"default": "0"
},
{
"name": "handled_time",
"title": "处理时间",
"type": "timestamp"
},
{
"name": "handled_note",
"title": "处理备注",
"type": "text"
}
]'''
ori_fields = json.loads(fields_str)
if not filterjson:
fields = [ f['name'] for f in ori_fields ]
filterjson = default_filterjson(fields, ns)
filterdic = ns.copy()
filterdic['filterstr'] = ''
filterdic['userorgid'] = '${userorgid}$'
filterdic['userid'] = '${userid}$'
if filterjson:
dbf = DBFilter(filterjson)
conds = dbf.gen(ns)
if conds:
ns.update(dbf.consts)
conds = f' and {conds}'
filterdic['filterstr'] = conds
ac = ArgsConvert('[[', ']]')
vars = ac.findAllVariables(sql)
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
filterdic.update(NameSpace)
sql = ac.convert(sql, filterdic)
debug(f'{sql=}')
db = DBPools()
dbname = get_module_dbname('llmage')
async with db.sqlorContext(dbname) as sor:
r = await sor.sqlPaging(sql, ns)
return r
return {
"total":0,
"rows":[]
}

View File

@ -0,0 +1,307 @@
{
"widgettype": "VBox",
"options": {
"height": "100%",
"width": "100%",
"padding": "8px",
"gap": "8px"
},
"subwidgets": [
{
"widgettype": "InlineForm",
"id": "filter_form",
"options": {
"css": "card",
"padding": "8px",
"submit_label": "查询",
"submit_css": "primary",
"fields": [
{
"name": "handled",
"label": "处理状态",
"uitype": "code",
"cwidth": 10,
"codes": [
{"value": "", "text": "全部"},
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
},
{
"name": "filter_llmid",
"label": "模型",
"uitype": "str",
"placeholder": "模型ID或名称",
"cwidth": 12
},
{
"name": "filter_userid",
"label": "用户",
"uitype": "str",
"placeholder": "用户ID",
"cwidth": 12
},
{
"name": "failed_reason",
"label": "失败原因",
"uitype": "str",
"placeholder": "关键词",
"cwidth": 15
}
]
},
"binds": [
{
"wid": "self",
"event": "submit",
"actiontype": "method",
"target": "llmusage_accounting_failed_tbl",
"method": "render"
}
]
},
{
"widgettype": "HBox",
"options": {
"css": "card", "padding": "4px 8px", "cheight": 3
},
"subwidgets": [
{
"widgettype": "Button",
"id": "btn_recover_usages",
"options": {
"label": "从IO文件恢复Usages",
"css": "primary"
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "urldata",
"target": "msg_area",
"options": {
"url": "{{entire_url('./recover_usages.dspy')}}"
},
"mode": "replace"
},
{
"wid": "self",
"event": "click",
"actiontype": "method",
"target": "llmusage_accounting_failed_tbl",
"method": "render"
}
]
},
{
"widgettype": "VBox",
"id": "msg_area",
"options": {
"width": "100%", "css": "filler"
}
}
]
},
{
"id": "llmusage_accounting_failed_tbl",
"widgettype": "Tabular",
"options": {
"width": "100%",
"height": "100%",
"title": "记账失败记录",
"css": "card",
"editable": {
"new_data_url": "{{entire_url('add_llmusage_accounting_failed.dspy')}}",
"delete_data_url": "{{entire_url('delete_llmusage_accounting_failed.dspy')}}",
"update_data_url": "{{entire_url('update_llmusage_accounting_failed.dspy')}}"
},
"data_url": "{{entire_url('./get_llmusage_accounting_failed.dspy')}}",
"data_method": "GET",
"data_params": {{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
"row_options": {
"browserfields": {
"exclouded": ["id"],
"alters": {
"handled": {
"uitype": "code",
"data": [
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
}
}
},
"editexclouded": ["id", "llmusageid", "failed_time"],
"fields": [
{
"name": "id",
"title": "id",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "id"
},
{
"name": "llmusageid",
"title": "使用记录id",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "str",
"datatype": "str",
"label": "使用记录id"
},
{
"name": "llmid",
"title": "模型",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "code",
"valueField": "llmid",
"textField": "llmid_text",
"params": {
"dbname": "llmage",
"table": "llm",
"tblvalue": "id",
"tbltext": "name",
"valueField": "llmid",
"textField": "llmid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"datatype": "str",
"label": "模型"
},
{
"name": "userid",
"title": "用户",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "code",
"valueField": "userid",
"textField": "userid_text",
"params": {
"dbname": "sage",
"table": "users",
"tblvalue": "userid",
"tbltext": "username",
"valueField": "userid",
"textField": "userid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"datatype": "str",
"label": "用户"
},
{
"name": "userorgid",
"title": "机构",
"type": "str",
"length": 32,
"cwidth": 18,
"uitype": "code",
"valueField": "userorgid",
"textField": "userorgid_text",
"params": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname",
"valueField": "userorgid",
"textField": "userorgid_text"
},
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}",
"datatype": "str",
"label": "机构"
},
{
"name": "use_time",
"title": "使用时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "使用时间"
},
{
"name": "amount",
"title": "交易金额",
"type": "double",
"length": 18,
"dec": 5,
"cwidth": 18,
"uitype": "float",
"datatype": "double",
"label": "交易金额"
},
{
"name": "failed_reason",
"title": "失败原因",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "失败原因"
},
{
"name": "failed_time",
"title": "失败时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "失败时间"
},
{
"name": "retry_count",
"title": "重试次数",
"type": "int",
"length": 0,
"uitype": "int",
"datatype": "int",
"label": "重试次数"
},
{
"name": "handled",
"title": "是否已处理",
"type": "str",
"length": 1,
"default": "0",
"cwidth": 4,
"uitype": "code",
"datatype": "str",
"label": "是否已处理",
"data": [
{"value": "0", "text": "未处理"},
{"value": "1", "text": "已处理"}
]
},
{
"name": "handled_time",
"title": "处理时间",
"type": "timestamp",
"length": 0,
"uitype": "str",
"datatype": "timestamp",
"label": "处理时间"
},
{
"name": "handled_note",
"title": "处理备注",
"type": "text",
"length": 0,
"uitype": "text",
"datatype": "text",
"label": "处理备注"
}
]
},
"page_rows": 160,
"cache_limit": 5
},
"binds": []
}
]
}

View File

@ -0,0 +1,136 @@
ns = params_kw.copy()
limit = int(ns.get('limit') or 200)
single_id = ns.get('id') or None
from ahserver.filestorage import FileStorage
import os
db = DBPools()
dbname = get_module_dbname('llmage')
recovered = 0
failed = 0
skipped = 0
errors = []
try:
async with db.sqlorContext(dbname) as sor:
if single_id:
sql = """select a.id, a.llmid, a.ioinfo, a.status, b.model
from llmusage a
left join llm b on a.llmid = b.id
where a.id = ${id}$"""
params = {'id': single_id}
else:
sql = """select a.id, a.llmid, a.ioinfo, a.status, b.model
from llmusage a
left join llm b on a.llmid = b.id
where a.usages is null
and a.status = 'SUCCEEDED'
order by a.use_date desc"""
params = {'page': 1, 'rows': limit}
recs = await sor.sqlExe(sql, params)
if isinstance(recs, dict):
rows = recs.get('rows', [])
else:
rows = recs if recs else []
if not rows:
return {
"widgettype": "Message",
"options": {
"title": "恢复Usages",
"cwidth": 20,
"cheight": 5,
"timeout": 5,
"message": "没有找到需要恢复的记录"
}
}
fs = FileStorage()
for r in rows:
rid = r.id if hasattr(r, 'id') else r.get('id', '')
model = r.model if hasattr(r, 'model') else r.get('model', '')
ioinfo = r.ioinfo if hasattr(r, 'ioinfo') else r.get('ioinfo', None)
if not ioinfo:
skipped += 1
continue
try:
# ioinfo 可能是 JSON 内容,也可能是文件路径
io_data = None
if ioinfo.startswith('{') or ioinfo.startswith('"'):
# 直接是 JSON 内容
io_data = json.loads(ioinfo)
else:
# 文件路径
real_path = fs.realPath(ioinfo)
if not os.path.isfile(real_path):
errors.append(f'{rid}: 文件不存在')
failed += 1
continue
with open(real_path, 'r', encoding='utf-8') as f:
io_data = json.load(f)
outputs = io_data.get('output', [])
if not outputs:
errors.append(f'{rid}: output为空')
failed += 1
continue
# 从最后一条output开始倒序找usage
usage = None
for out in reversed(outputs):
if isinstance(out, dict) and out.get('usage'):
usage = out['usage']
break
if not usage:
errors.append(f'{rid}: output中未找到usage')
failed += 1
continue
usages_str = json.dumps(usage, ensure_ascii=False)
await sor.U('llmusage', {
'id': rid,
'usages': usages_str
})
recovered += 1
except Exception as e:
debug(f'recover_usages error for {rid}: {e}')
errors.append(f'{rid}: {e}')
failed += 1
except Exception as e:
exception(f'recover_usages error: {e}')
return {
"widgettype": "Error",
"options": {
"title": "恢复Usages失败",
"cwidth": 20,
"cheight": 5,
"timeout": 5,
"message": str(e)
}
}
total = recovered + failed + skipped
msg = f"处理 {total} 条: 恢复成功 {recovered}, 失败 {failed}, 跳过 {skipped}"
if errors:
msg += f"\n失败详情(前5条): {'; '.join(errors[:5])}"
return {
"widgettype": "Message",
"options": {
"title": "恢复Usages完成",
"cwidth": 30,
"cheight": 6,
"timeout": 8,
"message": msg
}
}

View File

@ -1,5 +1,5 @@
debug(f'model_estimate.dspy:{params_kw=}') debug_params('model_estimate', params_kw)
db = DBPools() db = DBPools()
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
async with db.sqlorContext(dbname) as sor: async with db.sqlorContext(dbname) as sor:

128
wwwroot/model_plaza.css Normal file
View File

@ -0,0 +1,128 @@
/* Model Plaza — 模型广场 */
/* Hide provider view initially */
#plaza_view_provider {
display: none;
}
.plaza-header {
padding: 8px 16px 4px 16px;
}
/* View switcher buttons */
.plaza-view-switcher {
gap: 8px;
padding: 8px 0 4px 0;
}
.plaza-view-btn {
border-radius: 6px !important;
transition: all 0.2s ease;
}
.plaza-view-btn.plaza-view-active {
border: 2px solid var(--sage-brand, #6366f1) !important;
}
/* Left sidebar */
.plaza-sidebar {
border-right: 1px solid var(--sage-border, #e2e8f0);
padding: 4px;
}
.plaza-nav-btn {
margin-bottom: 2px;
text-align: left;
transition: background-color 0.15s ease;
}
.plaza-nav-btn:hover {
background-color: var(--sage-hover, rgba(99, 102, 241, 0.08));
}
/* Dark mode overrides for sidebar */
[data-theme="dark"] .plaza-sidebar {
border-right: 1px solid #334155;
background-color: #1E293B;
}
[data-theme="dark"] .plaza-nav-btn {
background-color: transparent;
color: #CBD5E1;
border: none;
}
[data-theme="dark"] .plaza-nav-btn:hover {
background-color: #334155;
color: #F1F5F9;
}
/* Card hover effects for model cards */
.plaza-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
border-radius: 8px !important;
}
.plaza-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
/* Category section headers */
.plaza-section-title {
padding: 12px 0 4px 4px;
position: relative;
}
.plaza-section-title::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 70%;
border-radius: 2px;
background: var(--sage-brand, #6366f1);
}
/* Smooth card grid */
.plaza-grid {
gap: 12px !important;
padding: 4px 8px;
}
/* Model icon area */
.plaza-card .model-icon-row {
gap: 8px;
}
/* Description text */
.plaza-card .model-desc {
line-height: 1.5;
opacity: 0.85;
}
/* Pricing display area */
.pricing-box {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--sage-border, #e2e8f0);
gap: 6px;
}
.pricing-text {
font-size: 0.85em;
line-height: 1.4;
opacity: 0.75;
white-space: pre-wrap;
}
[data-theme="dark"] .pricing-box {
border-top-color: #475569;
}
[data-theme="dark"] .pricing-text {
color: #94A3B8;
}

122
wwwroot/model_plaza.ui Normal file
View File

@ -0,0 +1,122 @@
{% set catelogs = get_llmcatelogs() %}
{% set providers = get_llms_sort_by_provider() %}
{
"widgettype": "VBox",
"options": {
"css": "filler",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "VBox",
"options": {
"css": "plaza-header",
"width": "100%"
},
"subwidgets": [
{
"widgettype": "Title2",
"options": {
"otext": "模型广场",
"i18n": true,
"halign": "left"
}
},
{
"widgettype": "Text",
"options": {
"otext": "探索和使用各类AI模型",
"i18n": true,
"halign": "left",
"wrap": true
}
},
{
"widgettype": "HBox",
"options": {
"css": "plaza-view-switcher",
"cheight": 3
},
"subwidgets": [
{
"widgettype": "Button",
"id": "btn_by_catelog",
"options": {
"label": "按分类",
"css": "plaza-view-btn plaza-view-active",
"cwidth": 15
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "document.getElementById('plaza_view_provider').style.display='none'; document.getElementById('plaza_view_catelog').style.display='flex'; var a=bricks.getWidgetById('btn_by_catelog',bricks.app); var b=bricks.getWidgetById('btn_by_provider',bricks.app); if(a)a.dom_element.classList.add('plaza-view-active'); if(b)b.dom_element.classList.remove('plaza-view-active');"
}
]
},
{
"widgettype": "Button",
"id": "btn_by_provider",
"options": {
"label": "按供应商",
"css": "plaza-view-btn",
"cwidth": 15
},
"binds": [
{
"wid": "self",
"event": "click",
"actiontype": "script",
"target": "self",
"script": "document.getElementById('plaza_view_catelog').style.display='none'; document.getElementById('plaza_view_provider').style.display='flex'; var a=bricks.getWidgetById('btn_by_catelog',bricks.app); var b=bricks.getWidgetById('btn_by_provider',bricks.app); if(a)a.dom_element.classList.remove('plaza-view-active'); if(b)b.dom_element.classList.add('plaza-view-active');"
}
]
}
]
}
]
},
{
"widgettype": "VBox",
"id": "plaza_view_catelog",
"options": {
"css": "filler",
"width": "100%",
"height": "100%"
},
"subwidgets": [
{
"widgettype": "urlwidget",
"options": {
"css": "filler",
"width": "100%",
"height": "100%",
"url": "{{entire_url('show_llms.ui')}}"
}
}
]
},
{
"widgettype": "VBox",
"id": "plaza_view_provider",
"options": {
"css": "filler",
"width": "100%",
"height": "100%"
},
"subwidgets": [
{
"widgettype": "urlwidget",
"options": {
"css": "filler",
"width": "100%",
"height": "100%",
"url": "{{entire_url('show_llms_by_providers.ui')}}"
}
}
]
}
]
}

View File

@ -1,106 +1,84 @@
{% set userorgid = get_userorgid() %} {% set catelogs = get_llmcatelogs() %}
{ {
"widgettype":"VScrollPanel", "widgettype":"HBox",
"options":{ "options":{
"css":"filler",
"width":"100%", "width":"100%",
"height":"100%" "height":"100%"
}, },
"subwidgets":[ "subwidgets":[
{% for cate in get_llms_by_catelog() %}
{ {
"widgettype": "VBox", "widgettype":"VScrollPanel",
"options":{ "options":{
"width":"100%" "cwidth":18,
"height":"100%",
"css":"plaza-sidebar"
}, },
"subwidgets":[ "subwidgets":[
{ {
"widgettype":"Title3", "widgettype":"Button",
"options":{ "options":{
"wrap":true, "label":"全部",
"halign": "left", "css":"plaza-nav-btn",
"i18n": true,
"otext":"{{cate.catelogname}}"
}
},
{
"widgettype":"DynamicColumn",
"options":{
"css":"filler",
"width":"100%" "width":"100%"
}, },
"subwidgets":[ "binds":[
{% for llm in cate.llms %} {
{ "wid":"self",
"widgettype":"VScrollPanel", "event":"click",
"actiontype":"urlwidget",
"target":"app.plaza_cards_panel",
"mode":"replace",
"options":{ "options":{
"css":"card", "url":"{{entire_url('show_llms_cards.ui')}}"
"bgcolor": "#def0f0", }
"cwidth":20,
"cheight":12
},
"subwidgets":[
{
"widgettype":"HBox",
"options":{
"cheight":2
},
"subwidgets":[
{
"widgettype":"Svg",
"options":{
"rate":1.5,
"url":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}"
}
},
{
"widgettype":"Title6",
"options":{
"text":"{{llm.name}}"
}
}
]
},
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(llm.description, ensure_ascii=False)}},
"wrap":true,
"halign":"left"
}
}
],
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"{{llm.name}}",
{% if int(params_kw._is_mobile) %}
"width": "100%",
"height": "100%"
{% else %}
"width": "40%",
"height":"85%"
{% endif %}
},
"options":{
"params":{
"id":"{{llm.id}}"
},
"url":"{{entire_url('./llm_dialog.ui')}}"
}
}
]
} }
{% if not loop.last %}, {% endif %}
{% endfor %}
] ]
}{% for cat in catelogs %},
{
"widgettype":"Button",
"options":{
"label":"{{cat.name}}",
"css":"plaza-nav-btn",
"width":"100%"
},
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"app.plaza_cards_panel",
"mode":"replace",
"options":{
"url":"{{entire_url('show_llms_cards.ui')}}",
"params":{
"catelogid":"{{cat.id}}"
}
}
}
]
}{% endfor %}
]
},
{
"widgettype":"VBox",
"id":"plaza_cards_panel",
"options":{
"css":"filler",
"cwidth":82,
"height":"100%"
},
"subwidgets":[
{
"widgettype":"urlwidget",
"options":{
"css":"filler",
"width":"100%",
"height":"100%",
"url":"{{entire_url('show_llms_cards.ui')}}"
}
} }
] ]
} }
{% if not loop.last %}, {% endif %}
{% endfor %}
] ]
} }

View File

@ -1,105 +1,84 @@
{% set userorgid = get_userorgid() %} {% set providers = get_llms_sort_by_provider() %}
{ {
"widgettype":"VScrollPanel", "widgettype":"HBox",
"options":{ "options":{
"css":"filler",
"width":"100%", "width":"100%",
"height":"100%" "height":"100%"
}, },
"subwidgets":[ "subwidgets":[
{% for p in get_llms_sort_by_provider() %}
{ {
"widgettype": "VBox", "widgettype":"VScrollPanel",
"options":{ "options":{
"width":"100%" "cwidth":18,
"height":"100%",
"css":"plaza-sidebar"
}, },
"subwidgets":[ "subwidgets":[
{ {
"widgettype":"Title3", "widgettype":"Button",
"options":{ "options":{
"wrap":true, "label":"全部",
"halign": "left", "css":"plaza-nav-btn",
"text":"{{p.orgname}}"
}
},
{
"widgettype":"DynamicColumn",
"options":{
"css":"filler",
"width":"100%" "width":"100%"
}, },
"subwidgets":[ "binds":[
{% for llm in p.llms %} {
{ "wid":"self",
"widgettype":"VScrollPanel", "event":"click",
"actiontype":"urlwidget",
"target":"app.plaza_provider_panel",
"mode":"replace",
"options":{ "options":{
"css":"card", "url":"{{entire_url('show_llms_cards_by_provider.ui')}}"
"bgcolor": "#def0f0", }
"cwidth":20,
"cheight":12
},
"subwidgets":[
{
"widgettype":"HBox",
"options":{
"cheight":2
},
"subwidgets":[
{
"widgettype":"Svg",
"options":{
"rate":1.5,
"url":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}"
}
},
{
"widgettype":"Title6",
"options":{
"text":"{{llm.name}}"
}
}
]
},
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(llm.description, ensure_ascii=False)}},
"wrap":true,
"halign":"left"
}
}
],
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"{{llm.name}}",
{% if int(params_kw._is_mobile) %}
"width": "100%",
"height": "100%"
{% else %}
"width": "40%",
"height":"85%"
{% endif %}
},
"options":{
"params":{
"id":"{{llm.id}}"
},
"url":"{{entire_url('./llm_dialog.ui')}}"
}
}
]
} }
{% if not loop.last %}, {% endif %}
{% endfor %}
] ]
}{% for p in providers %},
{
"widgettype":"Button",
"options":{
"label":"{{p.orgname}}",
"css":"plaza-nav-btn",
"width":"100%"
},
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"app.plaza_provider_panel",
"mode":"replace",
"options":{
"url":"{{entire_url('show_llms_cards_by_provider.ui')}}",
"params":{
"providerid":"{{p.id}}"
}
}
}
]
}{% endfor %}
]
},
{
"widgettype":"VBox",
"id":"plaza_provider_panel",
"options":{
"css":"filler",
"cwidth":82,
"height":"100%"
},
"subwidgets":[
{
"widgettype":"urlwidget",
"options":{
"css":"filler",
"width":"100%",
"height":"100%",
"url":"{{entire_url('show_llms_cards_by_provider.ui')}}"
}
} }
] ]
} }
{% if not loop.last %}, {% endif %}
{% endfor %}
] ]
} }

112
wwwroot/show_llms_cards.ui Normal file
View File

@ -0,0 +1,112 @@
{% set catelogid = params_kw.get('catelogid', None) %}
{% set data = get_llms_by_catelog(catelogid=catelogid) %}
{% set ns = namespace(first=true) %}
{
"widgettype":"VScrollPanel",
"options":{
"css":"filler",
"width":"100%",
"height":"100%"
},
"subwidgets":[
{
"widgettype":"DynamicColumn",
"options":{
"css":"plaza-grid",
"width":"100%",
"col_cwidth":25,
"col_cgap":1
},
"subwidgets":[
{% for cate in data %}
{% for llm in cate.llms %}
{% if not ns.first %},{% endif %}
{
"widgettype":"VScrollPanel",
"options":{
"css":"card plaza-card",
"cwidth":25,
"cheight":16
},
"subwidgets":[
{
"widgettype":"HBox",
"options":{
"cheight":2
},
"subwidgets":[
{
"widgettype":"Svg",
"options":{
"rate":1.5,
"url":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}"
}
},
{
"widgettype":"Title6",
"options":{
"text":"{{llm.name}}"
}
}
]
},
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(llm.description, ensure_ascii=False)}},
"wrap":true,
"halign":"left"
}
},
{
"widgettype":"Filler",
"options":{
"css":"pricing-box"
},
"subwidgets":[
{% for pricing_text in llm.pricing_display %}
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(pricing_text, ensure_ascii=False)}},
"wrap":true,
"halign":"left",
"css":"pricing-text"
}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}
],
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"{{llm.name}}",
{% if int(params_kw._is_mobile) %}
"width": "100%",
"height": "100%"
{% else %}
"width": "40%",
"height":"85%"
{% endif %}
},
"options":{
"params":{
"id":"{{llm.id}}"
},
"url":"{{entire_url('./llm_dialog.ui')}}"
}
}
]
}
{% set ns.first = false %}
{% endfor %}
{% endfor %}
]
}
]
}

View File

@ -0,0 +1,112 @@
{% set providerid = params_kw.get('providerid', '') %}
{% set data = get_llms_sort_by_provider() %}
{% set ns = namespace(first=true) %}
{
"widgettype":"VScrollPanel",
"options":{
"css":"filler",
"width":"100%",
"height":"100%"
},
"subwidgets":[
{
"widgettype":"DynamicColumn",
"options":{
"css":"plaza-grid",
"width":"100%"
},
"subwidgets":[
{% for p in data %}
{% if not providerid or p.id|string == providerid|string %}
{% for llm in p.llms %}
{% if not ns.first %},{% endif %}
{
"widgettype":"VScrollPanel",
"options":{
"css":"card plaza-card",
"cwidth":25,
"cheight":16
},
"subwidgets":[
{
"widgettype":"HBox",
"options":{
"cheight":2
},
"subwidgets":[
{
"widgettype":"Svg",
"options":{
"rate":1.5,
"url":"{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}"
}
},
{
"widgettype":"Title6",
"options":{
"text":"{{llm.name}}"
}
}
]
},
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(llm.description or '', ensure_ascii=False)}},
"wrap":true,
"halign":"left"
}
},
{
"widgettype":"Filler",
"options":{
"css":"pricing-box"
},
"subwidgets":[
{% for pricing_text in llm.pricing_display %}
{
"widgettype":"Text",
"options":{
"text":{{json.dumps(pricing_text, ensure_ascii=False)}},
"wrap":true,
"halign":"left",
"css":"pricing-text"
}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
}
],
"binds":[
{
"wid":"self",
"event":"click",
"actiontype":"urlwidget",
"target":"PopupWindow",
"popup_options":{
"title":"{{llm.name}}",
{% if int(params_kw._is_mobile) %}
"width": "100%",
"height": "100%"
{% else %}
"width": "40%",
"height":"85%"
{% endif %}
},
"options":{
"params":{
"id":"{{llm.id}}"
},
"url":"{{entire_url('./llm_dialog.ui')}}"
}
}
]
}
{% set ns.first = false %}
{% endfor %}
{% endif %}
{% endfor %}
]
}
]
}

View File

@ -10,7 +10,6 @@
"widgettype":"VScrollPanel", "widgettype":"VScrollPanel",
"options":{ "options":{
"css":"card", "css":"card",
"bgcolor": "#def0f0",
"cwidth":20, "cwidth":20,
"cheight":12 "cheight":12
}, },

View File

@ -0,0 +1,49 @@
{% set stats = get_llmage_stats(request) %}
{
"widgettype": "VBox",
"options": {
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#8B5CF6\" stroke-width=\"2\"><path d=\"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.catelog_count}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "模型分类",
"fontSize": "14px",
"marginTop": "4px"
}
}
]
}

View File

@ -0,0 +1,49 @@
{% set stats = get_llmage_stats(request) %}
{
"widgettype": "VBox",
"options": {
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#F59E0B\" stroke-width=\"2\"><path d=\"M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "¥{{'%.2f' % stats.today_amount}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "今日消费",
"fontSize": "14px",
"marginTop": "4px"
}
}
]
}

View File

@ -0,0 +1,49 @@
{% set stats = get_llmage_stats(request) %}
{
"widgettype": "VBox",
"options": {
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#22C55E\" stroke-width=\"2\"><path d=\"M7.5 21L3 16.5m0 0L7.5 12M12 9v7.5m0 0l4.5-4.5M12 9l4.5 4.5m0 0L12 16.5\"/><path d=\"M21 12h-4.5M12 3v4.5m0 0L7.5 12\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.today_usage_count}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "今日调用",
"fontSize": "14px",
"marginTop": "4px"
}
}
]
}

View File

@ -0,0 +1,49 @@
{% set stats = get_llmage_stats(request) %}
{
"widgettype": "VBox",
"options": {
"padding": "20px",
"borderRadius": "12px",
"flex": "1",
"minHeight": "110px"
},
"subwidgets": [
{
"widgettype": "HBox",
"options": {
"alignItems": "center",
"marginBottom": "12px"
},
"subwidgets": [
{
"widgettype": "Svg",
"options": {
"svg": "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#3B82F6\" stroke-width=\"2\"><path d=\"M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z\"/></svg>",
"width": "24px",
"height": "24px"
}
},
{
"widgettype": "Filler"
}
]
},
{
"widgettype": "Text",
"options": {
"text": "{{stats.total_models}}",
"fontSize": "32px",
"fontWeight": "700",
"lineHeight": "1.1"
}
},
{
"widgettype": "Text",
"options": {
"text": "可用模型数",
"fontSize": "14px",
"marginTop": "4px"
}
}
]
}

View File

@ -1,5 +1,5 @@
debug(f'{params_kw=}') debug_params('params_kw', params_kw)
lctype='文生文' lctype='t2t'
if params_kw.off_peak: if params_kw.off_peak:
off_peak = params_kw.off_peak off_peak = params_kw.off_peak
if off_peak in [True, "Y" "y", 1, "1"]: if off_peak in [True, "Y" "y", 1, "1"]:
@ -20,8 +20,9 @@ async with get_sor_context(env, 'llmage') as sor:
sql = """select distinct a.* from llm a sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id join llmcatelog b on m.llmcatelogid = b.id
where b.name = ${lctype}$ where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$""" and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, { recs = await sor.sqlExe(sql, {
'lctype': lctype, 'lctype': lctype,
'model': params_kw.model or 'qwen3-max' 'model': params_kw.model or 'qwen3-max'

View File

@ -0,0 +1,74 @@
# OpenAI-compatible Text-to-Speech API
# POST /v1/audio/speech
# Required params: model, catelogid, prompt (text to synthesize)
# Optional params: speaker (voice_id), speed, emotion
#
# Example request:
# {
# "model": "speech-2.6-turbo",
# "catelogid": "tts",
# "prompt": "你好,欢迎使用语音合成服务",
# "speaker": "female-tianmei",
# "speed": 1.0,
# "emotion": "happy"
# }
#
# Response (stream, hex audio chunks):
# {
# "status": "SUCCEEDED",
# "audio": "base64_encoded_audio_data"
# }
userid = await get_user()
userorgid = await get_userorgid()
if userid is None:
debug('need login')
return openai_403()
# Validate required parameters
if not params_kw.model:
d = return_error('Missing required parameter: model')
return json_response(d, status=400)
if not params_kw.catelogid:
d = return_error('Missing required parameter: catelogid')
return json_response(d, status=400)
if not params_kw.prompt:
d = return_error('Missing required parameter: prompt (text to synthesize)')
return json_response(d, status=400)
lctype = params_kw.catelogid
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
# Look up llm by model name and catalog type through llm_api_map
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id
where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model
})
if len(recs) == 0:
debug(f'{params_kw.model=} not found for catalog {lctype}')
return openai_400()
params_kw.llmid = recs[0].id
debug(f'{params_kw.llmid=}')
# Check balance
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
debug(f'{userid=} balance not enough')
return openai_429()
# Generate task ID and attach to params
if not params_kw.transno:
params_kw.transno = getID()
# Call inference (TTS can be stream or sync depending on model)
return await inference(request, env=env)

View File

@ -0,0 +1,71 @@
# OpenAI-compatible Audio Transcription API (ASR)
# POST /v1/audio/transcriptions
# Required params: model, catelogid, audio_file (audio URL or base64)
# Optional params: language
#
# Example request:
# {
# "model": "qwen3-asr-flash",
# "catelogid": "asr",
# "audio_file": "https://example.com/audio.wav"
# }
#
# Response:
# {
# "text": "识别出的文本内容",
# "usage": { "duration_seconds": 5.2 }
# }
userid = await get_user()
userorgid = await get_userorgid()
if userid is None:
debug('need login')
return openai_403()
# Validate required parameters
if not params_kw.model:
d = return_error('Missing required parameter: model')
return json_response(d, status=400)
if not params_kw.catelogid:
d = return_error('Missing required parameter: catelogid')
return json_response(d, status=400)
if not params_kw.audio_file:
d = return_error('Missing required parameter: audio_file')
return json_response(d, status=400)
lctype = params_kw.catelogid
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
# Look up llm by model name and catalog type through llm_api_map
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id
where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model
})
if len(recs) == 0:
debug(f'{params_kw.model=} not found for catalog {lctype}')
return openai_400()
params_kw.llmid = recs[0].id
debug(f'{params_kw.llmid=}')
# Check balance
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
debug(f'{userid=} balance not enough')
return openai_429()
# Generate task ID and attach to params
if not params_kw.transno:
params_kw.transno = getID()
# Call inference (ASR is synchronous)
return await inference(request, env=env)

View File

@ -9,8 +9,8 @@ async def gen():
async for l in f(): async for l in f():
yield l yield l
debug(f'{params_kw=}') debug_params('params_kw', params_kw)
lctype='文生文' catelogid = params_kw.catelogid or 't2t'
if params_kw.off_peak: if params_kw.off_peak:
off_peak = params_kw.off_peak off_peak = params_kw.off_peak
if off_peak in [True, "Y" "y", 1, "1"]: if off_peak in [True, "Y" "y", 1, "1"]:
@ -25,7 +25,7 @@ if userid is None:
return openai_403() return openai_403()
if not params_kw.prompt and not params_kw.messages: if not params_kw.prompt and not params_kw.messages:
debug(f'not params_kw.prompt and not params_kw.messages,{params_kw=}') debug(f'missing prompt and messages, model={params_kw.model}')
d = return_error('Missing need data(prompt or messages)') d = return_error('Missing need data(prompt or messages)')
return json_response(d, status=400) return json_response(d, status=400)
env = request._run_ns env = request._run_ns
@ -33,10 +33,11 @@ async with get_sor_context(env, 'llmage') as sor:
sql = """select distinct a.* from llm a sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id join llmcatelog b on m.llmcatelogid = b.id
where b.name = ${lctype}$ where (b.id = ${catelogid}$ OR b.name = ${catelogid}$)
and a.model=${model}$""" and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, { recs = await sor.sqlExe(sql, {
'lctype': lctype, 'catelogid': catelogid,
'model': params_kw.model or 'qwen3-max' 'model': params_kw.model or 'qwen3-max'
}) })
if len(recs) == 0: if len(recs) == 0:

View File

@ -0,0 +1,80 @@
# OpenAI-compatible Image Generation API
# POST /v1/image/generations
# Required params: model, catelogid
# Optional params: prompt, image_url, n, size, style, quality, etc.
#
# Example request:
# {
# "model": "jimeng-4.0",
# "catelogid": "t2i",
# "prompt": "A beautiful sunset over the ocean",
# "size": "1024x1024",
# "n": 1
# }
#
# Response format depends on the upstream model (sync returns image data, async returns task info)
import json
import time
from functools import partial
from appPublic.log import debug
from appPublic.dictObject import DictObject
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import get_sor_context
debug_params('params_kw', params_kw)
userid = await get_user()
userorgid = await get_userorgid()
if userid is None:
debug('need login')
return openai_403()
# Validate required parameters
if not params_kw.model:
d = return_error('Missing required parameter: model')
return json_response(d, status=400)
if not params_kw.catelogid:
d = return_error('Missing required parameter: catelogid')
return json_response(d, status=400)
if not params_kw.prompt:
d = return_error('Missing required parameter: prompt')
return json_response(d, status=400)
lctype = params_kw.catelogid
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
# Look up llm by model name and catalog type through llm_api_map
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id
where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model
})
if len(recs) == 0:
debug(f'{params_kw.model=} not found for catalog {lctype}')
return openai_400()
params_kw.llmid = recs[0].id
debug(f'{params_kw.llmid=}')
# Check balance
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
debug(f'{userid=} balance not enough')
return openai_429()
# Generate task ID and attach to params
if not params_kw.transno:
params_kw.transno = getID()
# Call inference (image generation can be sync or async depending on model config)
return await inference(request, env=env)

View File

@ -0,0 +1,23 @@
# GET /v1/models/catelog
# List published models by catalog, optionally exclude one model
# Params: catelogid (required), exclude_id (optional)
catelogid = params_kw.catelogid
if not catelogid:
return json.dumps({'error': 'catelogid is required'})
exclude_id = params_kw.exclude_id or ''
dbname = get_module_dbname('llmage')
db = DBPools()
async with db.sqlorContext(dbname) as sor:
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
where m.llmcatelogid = ${catelogid}$ and a.status = 'published'
"""
ns = {'catelogid': catelogid}
if exclude_id:
sql += " and a.id != ${exclude_id}$"
ns['exclude_id'] = exclude_id
recs = await sor.sqlExe(sql, ns)
for r in recs.get('rows', []):
r.description = json.dumps(r.description)
return recs
return []

View File

@ -6,9 +6,9 @@ def get_time_in_seconds(datestr):
timestamp = dt_obj.timestamp() timestamp = dt_obj.timestamp()
return timestamp return timestamp
lctype=params_kw.lctype catelogid = params_kw.catelogid
orderby=params_kw.orderby or 'model' orderby = params_kw.orderby or 'model'
rets = await get_llms_by_catelog_to_customer(catelogid=lctype, orderby=orderby) rets = await get_llms_by_catelog_to_customer(catelogid=catelogid, orderby=orderby)
ret = { ret = {
"object": "list", "object": "list",
"data": [] "data": []

View File

@ -0,0 +1,80 @@
# OpenAI-compatible Music Generation API
# POST /v1/music/generations
# Required params: model, catelogid, prompt, lyrics
# Optional params: output_format, audio_setting
#
# Example request:
# {
# "model": "music-2.6",
# "catelogid": "music_gen",
# "prompt": "Pop music, happy, suitable for a sunny day",
# "lyrics": "[Intro]\n\n[Verse]\nWalking down the street\nFeeling the beat\n\n[Chorus]\nDancing in the sun\nHaving so much fun"
# }
#
# Response (sync for MiniMax):
# {
# "id": "luid_xxx",
# "object": "music.generation",
# "model": "music-2.6",
# "status": "SUCCEEDED",
# "audio": "https://...",
# "created": 1234567890
# }
userid = await get_user()
userorgid = await get_userorgid()
if userid is None:
debug('need login')
return openai_403()
# Validate required parameters
if not params_kw.model:
d = return_error('Missing required parameter: model')
return json_response(d, status=400)
if not params_kw.catelogid:
d = return_error('Missing required parameter: catelogid')
return json_response(d, status=400)
if not params_kw.prompt:
d = return_error('Missing required parameter: prompt')
return json_response(d, status=400)
if not params_kw.lyrics:
d = return_error('Missing required parameter: lyrics')
return json_response(d, status=400)
lctype = params_kw.catelogid
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
# Look up llm by model name and catalog type through llm_api_map
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id
where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model
})
if len(recs) == 0:
debug(f'{params_kw.model=} not found for catalog {lctype}')
return openai_400()
params_kw.llmid = recs[0].id
debug(f'{params_kw.llmid=}')
# Check balance
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
debug(f'{userid=} balance not enough')
return openai_429()
# Generate task ID and attach to params
if not params_kw.transno:
params_kw.transno = getID()
# Call inference (music generation via MiniMax is synchronous)
return await inference(request, env=env)

View File

@ -0,0 +1,34 @@
# GET /llmage/v1/pricing
# Get model pricing display information
# Required params: model (model name, e.g. qwen3.7-max)
# Optional params: catelogid (default: t2t)
#
# Example: /llmage/v1/pricing?model=qwen3.7-max
# Returns: { "status": "ok", "data": { "display_text": "...", ... } }
model = params_kw.model
if not model:
return json.dumps({"status": "error", "message": "model parameter required"}, ensure_ascii=False)
catelogid = params_kw.catelogid or 't2t'
env = request._run_ns
try:
async with get_sor_context(env, 'llmage') as sor:
sql = """select m.ppid from llm a
join llm_api_map m on a.id = m.llmid
where a.model = ${model}$
and a.status = 'published'
and m.ppid is not null
and m.isdefaultcatelog = '1'
"""
recs = await sor.sqlExe(sql, {'model': model})
if len(recs) == 0:
return json.dumps({"status": "error", "message": f"model '{model}' not found or has no pricing"}, ensure_ascii=False)
ppid = recs[0].ppid
result = await env.get_pricing_display(ppid)
return json.dumps({"status": "ok", "data": result}, ensure_ascii=False, default=str)
except Exception as e:
exception(f'get pricing for {model} failed: {e}\n{format_exc()}')
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

View File

@ -0,0 +1,88 @@
# OpenAI-compatible Video Generation API
# POST /v1/video/generations
# Required params: model, catelogid
# Optional params: prompt, image_url, duration, resolution, n, etc.
#
# Example request:
# {
# "model": "keling-2.1",
# "catelogid": "t2v",
# "prompt": "A beautiful sunset over the ocean",
# "duration": "5s",
# "resolution": "1080p"
# }
#
# Response (async task):
# {
# "id": "vid_xxx",
# "object": "video.generation",
# "model": "keling-2.1",
# "status": "submitted",
# "taskid": "task_xxx",
# "created": 1234567890
# }
import json
import time
from functools import partial
from appPublic.log import debug
from appPublic.dictObject import DictObject
from appPublic.uniqueID import getID
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import get_sor_context
debug_params('params_kw', params_kw)
userid = await get_user()
userorgid = await get_userorgid()
if userid is None:
debug('need login')
return openai_403()
# Validate required parameters
if not params_kw.model:
d = return_error('Missing required parameter: model')
return json_response(d, status=400)
if not params_kw.catelogid:
d = return_error('Missing required parameter: catelogid')
return json_response(d, status=400)
if not params_kw.prompt:
d = return_error('Missing required parameter: prompt')
return json_response(d, status=400)
lctype = params_kw.catelogid
env = request._run_ns
async with get_sor_context(env, 'llmage') as sor:
# Look up llm by model name and catalog type through llm_api_map
sql = """select distinct a.* from llm a
join llm_api_map m on a.id = m.llmid
join llmcatelog b on m.llmcatelogid = b.id
where (b.id = ${lctype}$ OR b.name = ${lctype}$)
and a.model=${model}$
and a.status = 'published'"""
recs = await sor.sqlExe(sql, {
'lctype': lctype,
'model': params_kw.model
})
if len(recs) == 0:
debug(f'{params_kw.model=} not found for catalog {lctype}')
return openai_400()
params_kw.llmid = recs[0].id
debug(f'{params_kw.llmid=}')
# Check balance
f = await checkCustomerBalance(params_kw.llmid, userid, userorgid)
if not f:
debug(f'{userid=} balance not enough')
return openai_429()
# Generate task ID and attach to params
if not params_kw.transno:
params_kw.transno = getID()
# Call inference (video/image generation is typically async via callback)
return await inference(request, env=env)

View File

@ -1,4 +1,4 @@
debug(f'{params_kw=}') debug_params('params_kw', params_kw)
if params_kw.off_peak: if params_kw.off_peak:
off_peak = params_kw.off_peak off_peak = params_kw.off_peak
if off_peak in [True, "Y" "y", 1, "1"]: if off_peak in [True, "Y" "y", 1, "1"]: