Compare commits

..

No commits in common. "main" and "feat/dataviz-llmage" have entirely different histories.

154 changed files with 987 additions and 9521 deletions

15
.gitignore vendored
View File

@ -1,15 +0,0 @@
__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/
# Generated CRUD files (managed by xls2ddl/build.sh, not tracked in git)
wwwroot/llm_metrics/
wwwroot/user_llm_policy/
.swp

View File

@ -278,74 +278,6 @@ 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所有已登录用户仅返回当前登录用户自己的记录。
--- ---
## 前端页面 ## 前端页面

View File

@ -1,851 +0,0 @@
# 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_file` | 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`,传入图片 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_file` | 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`。后台定时任务会定期执行计费流程。
# KTV Pipeline API视频制作
Base url: `https://token.opencomputing.cn/llmage/v1`
供应商: 开元云(北京)科技, 14个原子化GPU端点覆盖KTV全流程。
## 调用方式
所有模型通过 `POST /v1/pipeline/submit` 调用,`catelogid` 固定为 `ktv_pipeline`(无需传参)。
### 通用格式
```json
// 请求
{"model": "ky-xxx", ...服务特定参数...}
// 同步响应
{"taskid": "luid_xxx", "taskstatus": "SUCCEEDED", "usage": {...}}
// 异步提交响应
{"taskid": "luid_xxx", "taskstatus": "PENDING"}
// 异步结果通过 GET /v1/tasks?taskid=xxx 查询
```
## 模型列表
| model | 功能 | 模式 | 计费 |
|-------|------|------|------|
| `ky-asr-transcribe` | 语音转文字+时间戳 | 同步 | 0.01元/秒 |
| `ky-demucs-separate` | 人声/伴奏分离 | 同步 | 0.02元/秒 |
| `ky-face-detect` | 人脸检测 | 同步 | 0.10元/次 |
| `ky-face-recognize` | 人脸识别 | 同步 | 0.10元/次 |
| `ky-face-compare` | 人脸比对 | 同步 | 0.10元/次 |
| `ky-subtitle-render` | 歌词→ASS字幕渲染 | 同步 | 0.01元/条 |
| `ky-merge-video` | 视频+音频+字幕合并 | 同步 | 0.01元/秒 |
| `ky-songrate-evaluate` | AI歌曲质量评分 | 同步 | 0.005元/秒 |
| `ky-synth-generate` | AI歌声合成 | **异步** | 0.20元/秒,任务状态通过 `GET /v1/tasks?taskid=xxx` 查询 |
| `ky-realesrgan-upscale` | 图像/视频超分辨率 | **异步** | 0.50元/张,任务状态通过 `GET /v1/tasks?taskid=xxx` 查询 |
| `ky-rvc-convert` | 声音克隆/变声 | 同步 | 0.15元/秒 |
| `ky-video-eval-evaluate` | 视频质量评估 | 同步 | 0.05元/秒 |
## 模型发现
```bash
curl 'https://token.opencomputing.cn/llmage/v1/models?catelogid=ktv_pipeline' \
-H 'Authorization: Bearer ***'
```
详细参数说明和 curl 示例见 [dashboard_for_sage 文档](https://token.opencomputing.cn/dashboard_for_sage/api_doc.md)。

View File

@ -1,118 +0,0 @@
# 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定价 |

View File

@ -1,127 +0,0 @@
模型管理: 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

View File

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

View File

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

View File

@ -1,140 +0,0 @@
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映射已存在
说明: 说明
账户余额不够: 账户余额不够
选择分类: 选择分类
重试: 重试
重试次数: 重试次数
重试记账: 重试记账
金额: 金额
错误: 错误
间隔(秒): 间隔(秒)

View File

@ -1,47 +0,0 @@
{
"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,59 +4,21 @@
"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":"launch_check", "name":"test",
"label":"上线检查", "label":"体验",
"selected_row":true
},
{
"name":"publish",
"label":"上架",
"selected_row":true
},
{
"name":"unpublish",
"label":"下架",
"selected_row":true "selected_row":true
} }
] ]
@ -64,66 +26,25 @@
"binds":[ "binds":[
{ {
"wid":"self", "wid":"self",
"event":"launch_check", "event":"test",
"actiontype":"urlwidget", "actiontype":"urlwidget",
"target":"PopupWindow", "target":"PopupWindow",
"popup_options":{ "popup_options":{
"title":"上线检查", "title":"model Test",
"cwidth":25, "cwidth":22,
"cheight":20 "height":"75%"
}, },
"options":{ "options":{
"url":"{{entire_url('./llm_launch_check.ui')}}", "url":"{{entire_url('./llm_dialog.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,18 +5,6 @@
"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

@ -1,21 +0,0 @@
{
"tblname": "llm_metrics",
"title": "模型使用指标",
"params": {
"sortby": "use_time",
"logined_userorgid": "userorgid",
"browserfields": {
"exclouded": ["id", "taskid"],
"cwidth": {}
},
"editexclouded": ["id", "amount", "responsed_seconds", "finish_seconds", "status", "taskid"],
"data_filter": {
"AND": [
{"field": "userorgid", "op": "=", "var": "userorgid"},
{"field": "use_date", "op": "between", "var": "use_date"}
]
},
"filter_labels": {"use_date": "使用日期"},
"filter_title": "查询条件"
}
}

View File

@ -10,9 +10,9 @@
"delete_data_url": "{{entire_url('../api/llmcatelog_delete.dspy')}}" "delete_data_url": "{{entire_url('../api/llmcatelog_delete.dspy')}}"
}, },
"browserfields": { "browserfields": {
"exclouded": [], "exclouded": ["id"],
"alters": {} "alters": {}
}, },
"editexclouded": [] "editexclouded": ["id"]
} }
} }

View File

@ -3,70 +3,15 @@
"title": "模型使用", "title": "模型使用",
"params": { "params": {
"sortby": "use_time desc", "sortby": "use_time desc",
"toolbar": {
"tools": [
{
"name": "show_usages",
"label": "使用信息",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/database.svg')}}"
},
{
"name": "show_ioinfo",
"label": "交互内容",
"selected_row": true,
"icon": "{{entire_url('/bricks/imgs/chat-user.svg')}}"
}
]
},
"binds": [
{
"wid": "self",
"event": "show_usages",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "使用信息",
"width": "70%",
"height": "70%"
},
"options": {
"url": "{{entire_url('../llmusage_usages_display.dspy')}}?id=${id}$"
}
},
{
"wid": "self",
"event": "show_ioinfo",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "交互内容",
"width": "70%",
"height": "70%"
},
"options": {
"url": "{{entire_url('../llmusage_ioinfo_display.dspy')}}?id=${id}$"
}
}
],
"browserfields": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id",
"usages",
"ioinfo"
],
"alters": {} "alters": {}
}, },
"editexclouded": [ "editexclouded": ["id"],
"id",
"usages",
"ioinfo"
],
"editable": { "editable": {
"get_data_url": "{{entire_url('../api/llmusage_list.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,63 +3,23 @@
"title": "记账失败记录", "title": "记账失败记录",
"params": { "params": {
"sortby": "failed_time desc", "sortby": "failed_time desc",
"toolbar": {
"tools": [
{
"name": "show_reason",
"label": "原因",
"selected_row": true
}
]
},
"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": { "browserfields": {
"exclouded": [ "exclouded": ["id"],
"id",
"failed_reason"
],
"alters": { "alters": {
"llmid": { "handled": {
"valueField": "llmid", "uitype": "code",
"textField": "llmid_text", "data": [
"uitype": "code" {"value": "0", "text": "未处理"},
}, {"value": "1", "text": "已处理"}
"userid": { ]
"valueField": "userid",
"textField": "userid_text",
"uitype": "code"
},
"userorgid": {
"valueField": "userorgid",
"textField": "userorgid_text",
"uitype": "code"
} }
} }
}, },
"editexclouded": [ "editexclouded": ["id", "llmusageid", "failed_time"],
"id",
"llmusageid",
"failed_time"
],
"editable": { "editable": {
"new_data_url": "{{entire_url('../api/llmusage_accounting_failed_create.dspy')}}", "new_data_url": "{{entire_url('../api/llmusage_accounting_failed_create.dspy')}}",
"update_data_url": "{{entire_url('../api/llmusage_accounting_failed_update.dspy')}}", "update_data_url": "{{entire_url('../api/llmusage_accounting_failed_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/llmusage_accounting_failed_delete.dspy')}}" "delete_data_url": "{{entire_url('../api/llmusage_accounting_failed_delete.dspy')}}"
} }
} }
} }

View File

@ -1,18 +0,0 @@
{
"tblname": "user_llm_policy",
"title": "用户模型策略",
"params": {
"sortby": "userid",
"logined_userorgid": "orgid",
"browserfields": {
"exclouded": ["id", "orgid", "created_at", "updated_at"],
"cwidth": {}
},
"editexclouded": ["id", "orgid", "created_at", "updated_at", "created_by"],
"data_filter": {
"AND": [
{"field": "orgid", "op": "=", "var": "orgid"}
]
}
}
}

View File

@ -2,7 +2,7 @@ import asyncio
import json import json
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from appPublic.log import exception, debug, info from appPublic.log import exception, debug
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject from appPublic.dictObject import DictObject
from sqlor.dbpools import get_sor_context from sqlor.dbpools import get_sor_context
@ -21,43 +21,19 @@ async def llm_charging(ppid, llmusage):
e = Exception(f'{ppid=}, {usages=}{llmusage.id=} env.buffered_charging() return None') e = Exception(f'{ppid=}, {usages=}{llmusage.id=} env.buffered_charging() return None')
exception(f'{e}') exception(f'{e}')
raise e raise e
return None
amount = 0 amount = 0
cost = 0
for p in prices: for p in prices:
amount += p.amount amount += p.amount
if p.cost:
cost += p.cost
discount = await env.get_customer_discount(llmusage.ownerid, discount = await env.get_customer_discount(llmusage.ownerid,
llmusage.userorgid) llmusage.userorgid)
# Get pricing program currency (provider's settlement currency)
cost_currency = 'CNY'
try:
pp = await env.get_ppid_pricing(ppid)
if pp and hasattr(pp, 'pp') and hasattr(pp.pp, 'currency'):
cost_currency = pp.pp.currency or 'CNY'
except Exception:
pass
# Get user's billing currency
user_currency = 'CNY'
try:
user_currency = await env.get_user_currency(llmusage.userorgid)
except Exception:
pass
# Convert cost to user currency at real-time rate
cost_in_user_currency = amount
if cost_currency != user_currency:
rate = await env.get_exchange_rate(cost_currency, user_currency, 'buy_rate')
cost_in_user_currency = round(amount * rate, 2)
# Convert both to base currency (CNY) for reporting
amount_base = await env.convert_to_base(cost_in_user_currency, user_currency, 'sell_rate')
return DictObject(**{ return DictObject(**{
'original_amount': amount, 'original_amount': amount,
'amount': cost_in_user_currency * discount, 'amount': amount * discount,
'cost_currency': cost_currency, 'cost': cost
'amount_currency': user_currency,
'amount_base': amount_base * discount,
'cost_base': await env.convert_to_base(amount, cost_currency, 'buy_rate'),
}) })
async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None): async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None):
@ -65,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_llmage_llm(llmid) llm = await get_llm(llmid)
if llm.ownerid == userorgid: if llm.ownerid == userorgid:
debug(f'self orgid user') debug(f'self orgid user')
return True return True
@ -81,53 +57,44 @@ async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None):
bal = 0 if balance is None else balance bal = 0 if balance is None else balance
if llm.min_balance is None: if llm.min_balance is None:
llm.min_balance = 0.00 llm.min_balance = 0.00
if not llm.ppid: ret = llm.ppid and llm.min_balance < bal
debug(f'{llm.ppid=} is empty, model unavailable') debug(f'{llm.ppid=}, {llm.min_balance=}, {bal=}')
return False
try:
await env.get_ppid_pricing(llm.ppid)
except Exception as e:
debug(f'{llm.ppid=} has no pricing data for today: {e}')
return False
ret = llm.min_balance < bal
debug(f'{llm.ppid=}, {llm.min_balance=}, {bal=}, pricing OK')
return ret return ret
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
amount_currency = getattr(llmusage, 'amount_currency', 'CNY')
cost_currency = getattr(llmusage, 'cost_currency', 'CNY')
amount_base = getattr(llmusage, 'amount_base', trans_amount)
cost_base = getattr(llmusage, 'cost_base', trans_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()
@ -148,8 +115,7 @@ async def llm_accounting(llmusage):
"orderid": orderid, "orderid": orderid,
"productid": llmid, "productid": llmid,
"product_cnt": 1, "product_cnt": 1,
"trans_amount": trans_amount, "trans_amount": trans_amount
"currency": amount_currency,
} }
await sor.C('biz_orderdetail', orderdetail) await sor.C('biz_orderdetail', orderdetail)
ais = [] ais = []
@ -163,12 +129,8 @@ async def llm_accounting(llmusage):
ai0.timestamp = timestamp ai0.timestamp = timestamp
ai0.productid = llmid ai0.productid = llmid
ai0.transamt = trans_amount ai0.transamt = trans_amount
ai0.currency = amount_currency
ai0.base_amount = amount_base
ai0.variable = { ai0.variable = {
"交易金额": trans_amount, "交易金额": trans_amount,
"交易币种": amount_currency,
"折本位币": amount_base,
"交易手续费": 0 "交易手续费": 0
} }
ais.append(ai0) ais.append(ai0)
@ -182,8 +144,6 @@ async def llm_accounting(llmusage):
ai1.providerid = providerid ai1.providerid = providerid
ai1.productid = llmid ai1.productid = llmid
ai1.transamt = trans_cost ai1.transamt = trans_cost
ai1.currency = cost_currency
ai1.base_amount = cost_base
ai1.variable = { ai1.variable = {
"采购成本": trans_cost "采购成本": trans_cost
} }
@ -195,13 +155,6 @@ async def llm_accounting(llmusage):
'accounting_status': 'accounted' 'accounting_status': 'accounted'
} }
await sor.U('llmusage', ns) await sor.U('llmusage', ns)
# Finalize Redis balance reservation (non-critical)
try:
from .balance import finalize_balance
await finalize_balance(env, llmusage.id, trans_amount,
llmid=llmusage.llmid)
except Exception as e:
exception(f'finalize_balance failed (non-critical): {e}')
async def get_accounting_llmusages(luid=None): async def get_accounting_llmusages(luid=None):
env = ServerEnv() env = ServerEnv()
@ -210,7 +163,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.*, b.model, c.ppid sql = """select a.*, 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
@ -223,6 +176,7 @@ where a.llmid = b.id
sql += " and a.id=${luid}$" sql += " and a.id=${luid}$"
ns['luid'] = luid ns['luid'] = luid
recs = await sor.sqlExe(sql, ns) recs = await sor.sqlExe(sql, ns)
# debug(f'{sql=}, {ns=}, {len(recs)=}')
for r in recs: for r in recs:
if r.usages is None: if r.usages is None:
try: try:
@ -231,25 +185,24 @@ where a.llmid = b.id
continue continue
r.usages = output.get('usage') r.usages = output.get('usage')
if r.usages is None: if r.usages is None:
debug(f'{r.usages=} is None, accounting failed') debug(f'{r.usages=} is None, accoiunting failed')
await llm_accoung_failed(r.id, reason='usages is None') await llm_accoung_failed(r.id, reason='usages is None')
continue continue
d = None d = None
try: try:
debug(f'{r.ppid=}, {r.usages=} {r.id=}') debug(f'{r.ppid=}, {r.usages=} {r.id=}')
d = await llm_charging(r.ppid, r) d = await llm_charging(r.ppid, r)
except Exception as e: except Exception as e:
exception(f'{r.ppid=}, {r.usages=} llm_charging() failed,{e}') exception(f'{r.ppid=}, {r.usages=} llm_charging() failed,{e}')
await llm_accoung_failed(r.id, reason=f'llm_charging failed: {e}') await llm_accoung_failed(r.id, reason=f'llm_charging failed: {e}')
continue continue
r.amount = d.amount r.amount = d.amount
r.cost = d.cost
ns = { ns = {
'id': r.id, 'id': r.id,
'amount': r.amount, 'amount': r.amount,
'amount_currency': getattr(d, 'amount_currency', 'CNY'), 'cost': r.cost,
'amount_base': getattr(d, 'amount_base', r.amount),
'cost_currency': getattr(d, 'cost_currency', 'CNY'),
'cost_base': getattr(d, 'cost_base', 0),
'usage': json.dumps(r.usage, ensure_ascii=False, indent=4) 'usage': json.dumps(r.usage, ensure_ascii=False, indent=4)
} }
await sor.U('llmusage', ns) await sor.U('llmusage', ns)
@ -277,6 +230,7 @@ async def llm_accoung_failed(luid, reason=None):
'use_date': r.use_date, 'use_date': r.use_date,
'use_time': r.use_time, 'use_time': r.use_time,
'amount': r.amount, 'amount': r.amount,
'cost': r.cost,
'failed_reason': reason or 'accounting failed', 'failed_reason': reason or 'accounting failed',
'failed_time': env.timestampstr(), 'failed_time': env.timestampstr(),
'retry_count': 0, 'retry_count': 0,
@ -302,8 +256,8 @@ WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$"""
# Step 1: INSERT INTO history SELECT from main table # Step 1: INSERT INTO history SELECT from main table
insert_sql = """INSERT INTO llmusage_history insert_sql = """INSERT INTO llmusage_history
(id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, userorgid, ownerid, accounting_status, tenantid, backup_time) (id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, cost, userorgid, ownerid, accounting_status, backup_time)
SELECT id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, userorgid, ownerid, accounting_status, tenantid, ${ts}$ SELECT id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, cost, userorgid, ownerid, accounting_status, ${ts}$
FROM llmusage FROM llmusage
WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$""" WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$"""
await sor.execute(insert_sql, {'cutoff_date': cutoff_date, 'ts': ts}) await sor.execute(insert_sql, {'cutoff_date': cutoff_date, 'ts': ts})
@ -368,33 +322,23 @@ order by failed_time desc limit {page_size} offset {offset}"""
async def backend_accounting(): async def backend_accounting():
env = ServerEnv() env = ServerEnv()
info(f"backend accounting started ...") debug(f'backend accounting started ...')
last_backup_date = None last_backup_date = None
while True: while True:
try: try:
lus = await get_accounting_llmusages() lus = await get_accounting_llmusages()
info(f"accounting loop: got {len(lus)} records")
except Exception as e: except Exception as e:
exception(f"get_accounting_llmusages failed: {e}") exception(f'{e}')
lus = [] lus = []
for lu in lus: for lu in lus:
try: try:
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, lu.model) await tpac_accounting(tpac, lu.userid, lu.llmid, lu.amount, lu.usages, lu.id)
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)
# 记账成功,清理对应的失败记录
try:
async with get_sor_context(env, 'llmage') as sor:
await sor.execute(
"DELETE FROM llmusage_accounting_failed WHERE llmusageid=${luid}$",
{'luid': lu.id}
)
except Exception as e2:
debug(f'清理失败记录异常(不影响记账): {e2}')
except Exception as e: except Exception as e:
exception(f'{e}, {lu.id=}') exception(f'{e}, {lu.id=}')
await llm_accoung_failed(lu.id, reason=str(e)) await llm_accoung_failed(lu.id, reason=str(e))

View File

@ -13,12 +13,8 @@ from appPublic.base64_to_file import base64_to_file, getFilenameFromBase64
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 .accounting import llm_accounting, llm_charging from .accounting import llm_accounting, llm_charging
from .balance import refund_balance
from .utils import * from .utils import *
# Global set to keep references to background tasks
_background_tasks = set()
async def get_today_asynctask_list(userid): async def get_today_asynctask_list(userid):
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
@ -43,9 +39,7 @@ async def get_asynctask_status(request, taskid):
t = timestampAdd(r.use_time, 600) t = timestampAdd(r.use_time, 600)
now = time.time() now = time.time()
if r.status not in ['UNKNOWN', 'FAILED', 'SUCCEEDED'] and now > t: if r.status not in ['UNKNOWN', 'FAILED', 'SUCCEEDED'] and now > t:
task = asyncio.create_task(query_task_status(request, r.id)) asyncio.create_task(query_task_status(request, r.id))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return output return output
return { return {
'taskid': taskid, 'taskid': taskid,
@ -68,7 +62,7 @@ async def async_uapi_request(request, llm,
uapi = env.UpAppApi(request) uapi = env.UpAppApi(request)
userid = await env.uapi_data.get_calluserid(llm.upappid, orgid=llm.ownerid) userid = await env.uapi_data.get_calluserid(llm.upappid, orgid=llm.ownerid)
b = None b = None
luid = params_kw.get('_luid') or getID() luid = getID()
try: try:
start_timestamp = time.time() start_timestamp = time.time()
if llm.callbackurl: if llm.callbackurl:
@ -78,17 +72,10 @@ async def async_uapi_request(request, llm,
try: try:
b = await uapi.call(llm.upappid, llm.apiname, userid, params=params_kw) b = await uapi.call(llm.upappid, llm.apiname, userid, params=params_kw)
except Exception as e: except Exception as e:
# Refund balance reservation on submission failure
try:
if luid:
await refund_balance(ServerEnv(), luid)
except Exception:
pass
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}')
estr = json.dumps(ed, ensure_ascii=False) yield f'{ed}\n'
yield f'{estr}\n'
return return
if isinstance(b, bytes): if isinstance(b, bytes):
b = b.decode('utf-8') b = b.decode('utf-8')
@ -114,56 +101,25 @@ async def async_uapi_request(request, llm,
llmusage.finish_seconds = finish_seconds llmusage.finish_seconds = finish_seconds
llmusage.status = d.status llmusage.status = d.status
llmusage.userorgid = callerorgid llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created' llmusage.accounting_status = 'created'
b = json.dumps(d, ensure_ascii=False) b = json.dumps(d, ensure_ascii=False)
yield b yield b
# await write_llmusage(llmusage) await write_llmusage(llmusage)
# if llm.callbackurl: # if llm.callbackurl:
# return # return
if d.status == 'FAILED': if d.status == 'FAILED':
e = Exception(f'resp={d} FFAILED') e = Exception(f'resp={d} FFAILED')
return return
task = asyncio.create_task(query_task_status(request, luid)) asyncio.create_task(query_task_status(request, luid))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
except Exception as e: except Exception as e:
# Refund balance reservation on outer failure
try:
if luid:
await refund_balance(ServerEnv(), luid)
except Exception:
pass
ed = {"error": f"ERROR:{e}", "status": "FAILED"} ed = {"error": f"ERROR:{e}", "status": "FAILED"}
s = json.dumps(ed, ensure_ascii=False) s = json.dumps(ed, ensure_ascii=False)
s = ''.join(s.split('\n')) s = ''.join(s.split('\n'))
exception(s) exception(s)
yield f'{s}\n' yield f'{s}\n'
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = callerid
ioinfo = {
"input": params_kw,
'output': [ed]
}
webpath = await write_llmio(llmusage.id, ioinfo)
llmusage.ioinfo = webpath
llmusage.taskid = d.taskid
llmusage.transno = params_kw.transno
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = 'FAILED'
llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
return return
finally:
await write_llmusage(llmusage)
async def modify_llmusage(ns): async def modify_llmusage(ns):
env = ServerEnv() env = ServerEnv()
@ -185,15 +141,9 @@ async def get_llm_llmusage(luid):
return return
if llmusage.status == 'FAILED': if llmusage.status == 'FAILED':
return return
# Use JOIN to get query_apiname/query_period from llm_api_map llms = await sor.R('llm', {'id': llmusage.llmid})
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/llm_api_map') e = Exception(f'{llmusage.llmid=} not found in llm')
exception(f'{e}') exception(f'{e}')
raise e raise e
llm = llms[0] llm = llms[0]
@ -208,128 +158,46 @@ async def query_task_status(request, luid, onetime=False):
upappid = llm.upappid upappid = llm.upappid
apinames = llm.query_apiname.split(',') apinames = llm.query_apiname.split(',')
try: for apiname in apinames:
for apiname in apinames: while True:
while True: lastoutout = await get_lastoutput(llmusage.ioinfo)
lastoutout = await get_lastoutput(llmusage.ioinfo) if lastoutout['status'] in ['UNKNOWN', 'FAILED', 'SUCCEEDED']:
if lastoutout.get('status', '') in ['UNKNOWN', 'FAILED', 'SUCCEEDED']: critical(f"{lastoutout['status']=}")
critical(f"{lastoutout.get('status', '')=}") return
return ns = {'taskid': taskid}
ns = {'taskid': taskid} new_output = b = d = None
new_output = b = d = None try:
try: b = await uapi.call(upappid, apiname, userid, params=ns)
b = await uapi.call(upappid, apiname, userid, params=ns) if isinstance(b, bytes):
if isinstance(b, bytes): b = b.decode('utf-8')
b = b.decode('utf-8') new_output = json.loads(b)
new_output = json.loads(b) except Exception as e:
except Exception as e: exception(f'{e}, {b=}')
exception(f'{e}, {b=}') new_output = {
new_output = { 'status': 'FAILED',
'status': 'FAILED', 'error': f'{b},{e}'
'error': f'{b},{e}' }
} if not new_output.get('status'):
if not new_output.get('status'): e = Exception(f"{new_output=} {upappid=}, {apiname=} has not status field")
e = Exception(f"{new_output=} {upappid=}, {apiname=} has not status field") critical(f'{e}')
critical(f'{e}') raise e
raise e if lastoutout['status'] != new_output.get('status'):
if lastoutout.get('status', '') != new_output.get('status'): llmusage.status = new_output['status']
llmusage.status = new_output['status'] ns = {
ns = { 'id': llmusage.id,
'id': llmusage.id, 'status': llmusage.status
'status': llmusage.status }
} if 'usage' in new_output.keys():
if 'usage' in new_output.keys(): ns['usages'] = json.dumps(new_output['usage'])
ns['usages'] = json.dumps(new_output['usage']) await append_new_llmoutput(llmusage.ioinfo, new_output)
await append_new_llmoutput(llmusage.ioinfo, new_output) await modify_llmusage(ns)
await modify_llmusage(ns) if llmusage.status in ['UNKNOWN', 'FAILED', 'SUCCEEDED']:
if llmusage.status == 'FAILED': critical(f'finished .. {llmusage.status=}')
# Async task failed — refund the balance reservation return
try:
await refund_balance(ServerEnv(), luid)
except Exception:
pass
if llmusage.status in ['UNKNOWN', 'FAILED', 'SUCCEEDED']:
critical(f'finished .. {llmusage.status=}')
return
if onetime: if onetime:
critical(f'onetime is true, returned') critical(f'onetime is true, returned')
return return
await asyncio.sleep(llm.query_period or 30) await asyncio.sleep(llm.query_period or 30)
critical(f'{llm.query_period=} seconds will retry, {new_output["status"]=}') critical(f'{llm.query_period=} seconds will retry, {new_output["status"]=}')
except asyncio.CancelledError:
critical(f'query_task_status cancelled for {luid=}')
raise
except Exception as e:
exception(f'query_task_status error for {luid=}: {e}')
raise
async def async_uapi_request_product(llm, api_userid, user_id, user_org_id, params_kw, luid):
"""Product interface version of async task submission. Returns dict with task info."""
env = ServerEnv()
from uapi.appapi import UAPI
uapi = UAPI(llm.upappid, llm.apiname)
b = None
try:
start_timestamp = time.time()
if llm.callbackurl:
params_kw.callbackurl = llm.callbackurl
b = await uapi.call(llm.upappid, llm.apiname, api_userid, params=params_kw)
if isinstance(b, bytes):
b = b.decode('utf-8')
debug(f'async task submitted: {b}')
d = DictObject(**json.loads(b))
responsed_seconds = time.time() - start_timestamp
finish_seconds = responsed_seconds
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = user_id
ioinfo = {"input": dict(params_kw), "output": [d]}
webpath = await write_llmio(luid, ioinfo)
llmusage.ioinfo = webpath
llmusage.taskid = d.taskid
llmusage.transno = params_kw.get('transno', luid)
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = d.status
llmusage.userorgid = user_org_id
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created'
await write_llmusage(llmusage)
if d.status == 'FAILED':
return {
'success': False,
'message': f'Task submission failed: {d}',
'task_id': luid,
'status': 'FAILED',
}
# Task submitted successfully — return task info
# Background polling is handled by existing query_task_status or callback
return {
'success': True,
'result': {'taskid': d.taskid, 'status': d.status},
'usage_data': {},
'resource_ref_id': llm.id,
'task_id': luid,
'external_task_id': d.taskid,
'status': d.status,
}
except Exception as e:
exception(f'async_uapi_request_product error: {e}')
return {
'success': False,
'message': str(e),
'task_id': luid,
'status': 'FAILED',
}

View File

@ -1,357 +0,0 @@
"""
Redis atomic balance reservation for llmage.
Prevents concurrent overspend: pre-deduct before inference, settle after accounting.
Uses a module-level redis.asyncio singleton so the atomic reserve works in
EVERY process (web workers + backend_accounting) without needing env.redis
to be injected. This fixes the previous fatal issue where env.redis was never
set, so reserve always silently fell back to the non-atomic DB check.
Key patterns:
balance:{userorgid} current pre-deducted balance (int, cents)
reserve:{luid} {userorgid}|{llmid}|{max_cost} (TTL)
model:max_cost:{llmid} historical max actual customer charge (int, cents)
Reserve TTL: 600s for fast (stream/sync) calls, 3600s for async tasks that
may run for minutes before finalize/refund.
"""
import asyncio
from appPublic.log import debug, exception
# ── Module-level async Redis singleton ───────────────────────
_redis = None
_redis_lock = asyncio.Lock()
def _redis_url():
try:
from appPublic.jsonConfig import getConfig
config = getConfig()
url = getattr(config.website, 'session_redis', None)
if url:
u = getattr(url, 'url', None)
if u:
return u
except Exception:
pass
return "redis://127.0.0.1:6379"
async def _get_redis():
"""Return a shared redis.asyncio client (lazy singleton)."""
global _redis
if _redis is not None:
return _redis
async with _redis_lock:
if _redis is None:
import redis.asyncio as aioredis
_redis = await aioredis.from_url(
_redis_url(), decode_responses=True)
return _redis
# ── Lua scripts ──────────────────────────────────────────────
RESERVE_LUA = """
local bal_key = KEYS[1]
local cost_key = KEYS[2]
local reserve_key = KEYS[3]
local max_cost = tonumber(ARGV[1])
local db_balance = tonumber(ARGV[2]) -- 0 means no DB fallback
local reserve_val = ARGV[3]
local ttl = tonumber(ARGV[4])
-- Get or init max_cost: use stored if higher, else set from arg
local stored_max = redis.call('GET', cost_key)
if stored_max then
max_cost = math.max(max_cost, tonumber(stored_max))
elseif max_cost > 0 then
redis.call('SET', cost_key, max_cost)
end
if max_cost <= 0 then
return {0, 'max_cost is zero'}
end
-- Load or init balance
local balance = redis.call('GET', bal_key)
if not balance then
if db_balance > 0 then
balance = db_balance
redis.call('SET', bal_key, db_balance)
else
return {0, 'balance not initialized'}
end
end
balance = tonumber(balance)
-- Check and deduct
if balance < max_cost then
return {0, 'insufficient balance'}
end
redis.call('DECRBY', bal_key, max_cost)
redis.call('SETEX', reserve_key, ttl, reserve_val)
return {1, max_cost}
"""
FINALIZE_LUA = """
local cost_key = KEYS[1]
local reserve_key = KEYS[2]
local actual_cost = tonumber(ARGV[1])
-- Atomically pop reserve (GET+DEL is atomic inside a Lua script; GETDEL needs Redis 6.2+)
local reserve_val = redis.call('GET', reserve_key)
if not reserve_val then
return {0, 'no reserve'}
end
redis.call('DEL', reserve_key)
-- Parse: userorgid|llmid|max_cost
local i = 1
local userorgid, llmid, max_cost
for part in string.gmatch(reserve_val, '([^|]+)') do
if i == 1 then userorgid = part
elseif i == 2 then llmid = part
elseif i == 3 then max_cost = tonumber(part) end
i = i + 1
end
-- Update max_cost if actual exceeds
if actual_cost > max_cost then
redis.call('SET', cost_key, actual_cost)
max_cost = actual_cost
end
-- Adjust balance
local bal_key = 'balance:' .. userorgid
local diff = max_cost - actual_cost
if diff > 0 then
redis.call('INCRBY', bal_key, diff)
elseif diff < 0 then
redis.call('DECRBY', bal_key, -diff)
end
return {1, userorgid, max_cost, actual_cost, diff}
"""
REFUND_LUA = """
local reserve_key = KEYS[1]
local reserve_val = redis.call('GET', reserve_key)
if not reserve_val then
return {0, 'no reserve'}
end
redis.call('DEL', reserve_key)
local i = 1
local userorgid, llmid, max_cost
for part in string.gmatch(reserve_val, '([^|]+)') do
if i == 1 then userorgid = part
elseif i == 2 then llmid = part
elseif i == 3 then max_cost = tonumber(part) end
i = i + 1
end
if max_cost > 0 then
redis.call('INCRBY', 'balance:' .. userorgid, max_cost)
end
return {1, max_cost, userorgid}
"""
def _cents(f):
return int(round(float(f) * 100))
def _from_cents(c):
return round(int(c) / 100, 2)
async def _update_max_cost(llmid, actual_cost):
"""Bump model:max_cost if actual exceeds stored value; persist to DB.
Keeps the pre-deduct baseline growing even when the original reserve was
skipped (no_history cold start) otherwise reserve would never activate.
"""
try:
redis = await _get_redis()
cost_key = f'model:max_cost:{llmid}'
cost_cents = _cents(actual_cost)
stored = await redis.get(cost_key)
if stored and int(stored) >= cost_cents:
return
await redis.set(cost_key, cost_cents)
from .utils import update_model_max_cost
await update_model_max_cost(llmid, actual_cost)
except Exception as e:
debug(f'_update_max_cost failed: {e}')
async def reserve_balance(env, llmid, userorgid, luid, ttl=600, userid=None):
"""Atomically check balance and deduct max_cost via Redis Lua.
Policy (centralized so every entry behaves the same):
- Self-owned org (llm.ownerid == userorgid): skip reserve.
- tpac user (external balance system): skip reserve.
- No pricing (ppid empty): skip reserve (availability handled elsewhere).
Returns:
{'ok': True, 'max_cost': X} reserved X
{'ok': True, 'max_cost': 0, 'skip': ...} reserve skipped by policy
{'ok': True, 'max_cost': 0, 'no_history': True} no max_cost data yet
{'ok': False, 'reason': ...} insufficient balance
{'ok': True, 'max_cost': 0, 'no_redis': True} Redis down, DB fallback
"""
try:
# ── Policy checks (need llm info) ──
try:
from .utils import get_llmage_llm, get_user_tpac
llm = await get_llmage_llm(llmid)
if llm and llm.ownerid == userorgid:
return {'ok': True, 'max_cost': 0, 'skip': 'self_org'}
if not llm or not llm.ppid:
return {'ok': True, 'max_cost': 0, 'skip': 'no_ppid'}
# tpac user: balance lives in external system, skip redis reserve
if userid:
try:
tpac = await get_user_tpac(userid)
if tpac:
return {'ok': True, 'max_cost': 0, 'skip': 'tpac'}
except Exception as e:
debug(f'reserve_balance: tpac check failed: {e}')
except Exception as e:
debug(f'reserve_balance: policy check failed: {e}')
redis = await _get_redis()
bal_key = f'balance:{userorgid}'
cost_key = f'model:max_cost:{llmid}'
reserve_key = f'reserve:{luid}'
# Load max_cost from Redis if cached, else from DB history
max_cost = 0
stored = await redis.get(cost_key)
if stored:
max_cost = int(stored)
else:
try:
from .utils import get_model_max_cost
mc = await get_model_max_cost(llmid)
if mc and mc > 0:
max_cost = _cents(mc)
except Exception as e:
debug(f'reserve_balance: get_model_max_cost failed: {e}')
if max_cost <= 0:
return {'ok': True, 'max_cost': 0, 'no_history': True}
# Load balance from DB if not cached in Redis
db_balance = 0
stored_bal = await redis.get(bal_key)
if not stored_bal:
try:
from accounting.getaccount import getCustomerBalance
from sqlor.dbpools import get_sor_context
async with get_sor_context(env, 'accounting') as sor:
bal = await getCustomerBalance(sor, userorgid)
if bal is not None:
db_balance = _cents(float(bal))
except Exception as e:
debug(f'reserve_balance: getCustomerBalance failed: {e}')
reserve_val = f'{userorgid}|{llmid}|{max_cost}'
result = await redis.eval(RESERVE_LUA, 3,
bal_key, cost_key, reserve_key,
str(max_cost), str(db_balance), reserve_val, str(ttl))
if result[0] == 0:
return {'ok': False, 'reason': result[1]}
return {'ok': True, 'max_cost': _from_cents(result[1])}
except Exception as e:
# Redis down or unreachable — fall back to DB check, don't block
debug(f'reserve_balance: Redis error, falling back to DB: {e}')
return {'ok': True, 'max_cost': 0, 'no_redis': True}
async def finalize_balance(env, luid, actual_cost, llmid=None):
"""After accounting: adjust balance, update max_cost.
llmid: needed for cold-start max_cost updates when no reserve existed
(reserve skipped due to no_history) keeps the pre-deduct baseline
growing so future calls can reserve.
"""
try:
redis = await _get_redis()
reserve_key = f'reserve:{luid}'
reserve_val = await redis.get(reserve_key)
if not reserve_val:
debug(f'finalize_balance: no reserve for luid={luid}')
if llmid:
await _update_max_cost(llmid, actual_cost)
return
# Parse to get llmid (reserve_val: userorgid|llmid|max_cost)
parts = reserve_val.split('|')
if len(parts) < 3:
return
llmid = parts[1]
cost_key = f'model:max_cost:{llmid}'
result = await redis.eval(FINALIZE_LUA, 2,
cost_key, reserve_key, str(_cents(actual_cost)))
if result[0] == 0:
debug(f'finalize_balance: {result[1]}')
return
max_c = _from_cents(result[2])
diff = _from_cents(result[4])
debug(f'finalize_balance: luid={luid} max={max_c} actual={actual_cost} diff={diff}')
# Persist max_cost to DB if actual exceeded previous max
if actual_cost > max_c:
await _update_max_cost(llmid, actual_cost)
except Exception as e:
exception(f'finalize_balance error: {e}')
async def refund_balance(env, luid):
"""Full refund on API failure."""
try:
redis = await _get_redis()
reserve_key = f'reserve:{luid}'
result = await redis.eval(REFUND_LUA, 1, reserve_key)
if result[0] == 1:
debug(f'refund_balance: luid={luid} refund={_from_cents(result[1])}')
else:
debug(f'refund_balance: luid={luid} skipped ({result[1]})')
except Exception as e:
exception(f'refund_balance error: {e}')
async def extend_reserve(env, luid, ttl):
"""Extend TTL of an existing reserve.
Used when an entry pre-reserved with the short (600s) TTL but the
request is dispatched to async mode, where the task may run longer.
"""
try:
redis = await _get_redis()
await redis.expire(f'reserve:{luid}', ttl)
except Exception as e:
debug(f'extend_reserve failed: {e}')
async def invalidate_balance_cache(userorgid):
"""Delete the cached Redis balance so the next reserve reloads from DB.
Must be called after recharge / recharge reversal, otherwise the
pre-deduct baseline stays stale and valid requests get rejected.
"""
try:
redis = await _get_redis()
await redis.delete(f'balance:{userorgid}')
debug(f'invalidate_balance_cache: balance:{userorgid} deleted')
except Exception as e:
debug(f'invalidate_balance_cache failed: {e}')

View File

@ -1,37 +1,21 @@
import asyncio import asyncio
from appPublic.registerfunction import RegisterFunction from appPublic.registerfunction import RegisterFunction
from sqlor.dbpools import DBPools, get_sor_context from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from appPublic.log import debug from appPublic.log import debug
from appPublic.share_cache import cache_start_listener
from .keling import keling_token from .keling import keling_token
from .jimeng import jimeng_auth_headers from .jimeng import jimeng_auth_headers
from .utils import ( 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,
get_llmcatelogs, get_llmcatelogs,
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,
get_llmusage_by_id,
read_ioinfo_content,
_warm_llmid_cache,
get_llmid_cached,
invalidate_llmid_cache,
get_plaza_models,
# Redis balance reservation
reserve_balance,
finalize_balance,
refund_balance,
) )
from .llmclient import ( from .llmclient import (
@ -56,88 +40,6 @@ from .asyncinference import (
get_today_asynctask_list get_today_asynctask_list
) )
from .product_interface import (
get_product_display,
check_product_availability,
check_product_consumable,
execute_product_service,
execute_product_service_stream,
calculate_product_cost,
)
async def load_product_category_product(parent_category_id):
"""Return llmage catalogs and published models as standardized import data.
Called by product_management.import_categories_and_products().
Only reads source data and returns it in the standard format.
Does NOT write to product_management tables that's product_management's job.
Returns:
{
'success': True,
'categories': [{'source_id', 'name', 'description', 'product_type', 'product_type_title', 'sort_order'}, ...],
'products': [{'source_category_id', 'resource_ref_id', 'product_code', 'product_name', 'product_type', 'brief_intro', 'sort_order'}, ...]
}
"""
env = ServerEnv()
# Read source data from llmage
async with get_sor_context(env, 'llmage') as sor:
catelogs = await sor.R('llmcatelog', {})
if not catelogs:
return {'success': False, 'error': 'llmage中没有产品类别数据'}
llm_sql = """select a.id, a.name, a.model, a.description, a.status, a.providerid,
m.llmcatelogid, 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 m.isdefaultcatelog = '1' and a.status = 'published'
order by lc.name, a.name"""
llms = await sor.sqlExe(llm_sql, {})
# Build standardized categories
categories = []
for c in catelogs:
categories.append({
'source_id': c.id,
'name': c.name,
'description': getattr(c, 'description', '') or '',
'product_type': 'llm_model',
'product_type_title': '大模型按量',
'sort_order': 0,
})
# Build standardized products
products = []
for llm in (llms or []):
products.append({
'source_category_id': llm.llmcatelogid,
'resource_ref_id': llm.id,
'product_code': llm.model,
'product_name': llm.name,
'product_type': 'llm_model',
'brief_intro': getattr(llm, 'description', '') or '',
'sort_order': 0,
'providerid': getattr(llm, 'providerid', '') or '',
})
return {
'success': True,
'categories': categories,
'products': products,
}
def _on_hot_reload(data=None):
"""Event handler for hot_reload — invalidate caches."""
from appPublic.log import debug
debug(f'[llmage] on_hot_reload called, invalidating caches (data={data})')
invalidate_uapi_cache()
invalidate_llmid_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
@ -150,18 +52,9 @@ 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_llmid_cached = get_llmid_cached
env.invalidate_llmid_cache = invalidate_llmid_cache
# 启动时预热 llmid 缓存
asyncio.ensure_future(_warm_llmid_cache(env))
env.get_llmcatelogs = get_llmcatelogs env.get_llmcatelogs = get_llmcatelogs
env.checkCustomerBalance = checkCustomerBalance env.checkCustomerBalance = checkCustomerBalance
env.get_llmproviders = get_llmproviders env.get_llmproviders = get_llmproviders
@ -169,33 +62,9 @@ def load_llmage():
env.keling_token = keling_token env.keling_token = keling_token
env.llm_query_price = llm_query_price env.llm_query_price = llm_query_price
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.get_plaza_models = get_plaza_models
env._reserve_balance = reserve_balance
env._finalize_balance = finalize_balance
env._refund_balance = refund_balance
env.reserve_balance = lambda llmid, userorgid, luid, ttl=600, userid=None: reserve_balance(env, llmid, userorgid, luid, ttl=ttl, userid=userid)
env.finalize_balance = lambda luid, actual_cost, llmid=None: finalize_balance(env, luid, actual_cost, llmid=llmid)
env.refund_balance = lambda luid: refund_balance(env, luid)
env.backup_accounted_llmusage = backup_accounted_llmusage env.backup_accounted_llmusage = backup_accounted_llmusage
env.read_ioinfo_content = read_ioinfo_content
env.get_llmusage_by_id = get_llmusage_by_id
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 env.get_llmage_stats = get_llmage_stats
# Product module standard interface
env.product_interface = {
'module_name': 'llmage',
'get_product_display': get_product_display,
'check_product_availability': check_product_availability,
'check_product_consumable': check_product_consumable,
'execute_product_service': execute_product_service,
'execute_product_service_stream': execute_product_service_stream,
'calculate_product_cost': calculate_product_cost,
'load_product_category_product': load_product_category_product,
}
# Bind hot_reload event — module-level function, ref safe (module keeps it alive)
cache_start_listener()
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

@ -14,7 +14,6 @@ from ahserver.filestorage import FileStorage
from .asyncinference import async_uapi_request from .asyncinference import async_uapi_request
from .syncinference import sync_uapi_request from .syncinference import sync_uapi_request
from .accounting import llm_accounting, llm_charging from .accounting import llm_accounting, llm_charging
from .balance import refund_balance, reserve_balance, extend_reserve
from .utils import * from .utils import *
async def uapi_request(request, llm, callerid, callerorgid, params_kw=None): async def uapi_request(request, llm, callerid, callerorgid, params_kw=None):
@ -27,15 +26,13 @@ async def uapi_request(request, llm, callerid, callerorgid, params_kw=None):
userid = await env.uapi_data.get_calluserid(llm.upappid, orgid=llm.ownerid) userid = await env.uapi_data.get_calluserid(llm.upappid, orgid=llm.ownerid)
outlines = [] outlines = []
txt = '' txt = ''
luid = params_kw.get('_luid') or getID() luid = getID()
llmusage = None
try: try:
start_timestamp = time.time() start_timestamp = time.time()
responsed_seconds = None responsed_seconds = None
finish_seconds = None finish_seconds = None
first = True first = True
usage = None usage = None
last_choices = None
async for l in uapi.stream_linify(llm.upappid, llm.apiname, userid, async for l in uapi.stream_linify(llm.upappid, llm.apiname, userid,
params=params_kw): params=params_kw):
if first: if first:
@ -58,10 +55,6 @@ async def uapi_request(request, llm, callerid, callerorgid, params_kw=None):
if d.get('reasoning_content'): if d.get('reasoning_content'):
txt += d.get('reasoning_content') txt += d.get('reasoning_content')
yield_it = True yield_it = True
if d.get('choices'):
last_choices = d['choices']
elif last_choices:
d['choices'] = last_choices
if d.get('content'): if d.get('content'):
txt = txt + d['content'] txt = txt + d['content']
yield_it = True yield_it = True
@ -72,7 +65,6 @@ async def uapi_request(request, llm, callerid, callerorgid, params_kw=None):
yield json.dumps(d, ensure_ascii=False) + '\n' yield json.dumps(d, ensure_ascii=False) + '\n'
if usage is None: if usage is None:
error(f'{llm=} response has not usage') error(f'{llm=} response has not usage')
finish_seconds = time.time() - start_timestamp finish_seconds = time.time() - start_timestamp
if responsed_seconds is None: if responsed_seconds is None:
responsed_seconds = finish_seconds responsed_seconds = finish_seconds
@ -95,46 +87,18 @@ async def uapi_request(request, llm, callerid, callerorgid, params_kw=None):
llmusage.finish_seconds = finish_seconds llmusage.finish_seconds = finish_seconds
llmusage.status = 'SUCCEEDED' llmusage.status = 'SUCCEEDED'
llmusage.userorgid = callerorgid llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created' llmusage.accounting_status = 'created'
# await write_llmusage(llmusage) await write_llmusage(llmusage)
except Exception as e: except Exception as e:
# Refund balance reservation on failure
try:
if luid:
await refund_balance(ServerEnv(), luid)
except:
debug(f'refund_balance(ServerEnv(), {luid=}) errir')
pass
exception(f'{e=},{format_exc()}') exception(f'{e=},{format_exc()}')
estr = erase_apikey(e) estr = erase_apikey(e)
ed = {"error": f"ERROR:{estr}", "status": "FAILED" ,"llmusageid": luid} ed = {"error": f"ERROR:{estr}", "status": "FAILED" ,"llmusageid": luid}
s = json.dumps(ed, ensure_ascii=False) s = json.dumps(ed, ensure_ascii=False)
s = ''.join(s.split('\\n')) s = ''.join(s.split('\n'))
outlines.append(ed) outlines.append(ed)
yield f'{s}\\n' yield f'{s}\n'
## except happand at call llm server return
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = callerid
ioinfo = {
"input": params_kw,
'output': ed
}
webpath = await write_llmio(llmusage.id, ioinfo)
llmusage.ioinfo = webpath
llmusage.transno = params_kw.transno
llmusage.status = 'FAILED'
llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
finally:
# GeneratorExit / client disconnect — flush partial usage
await write_llmusage(llmusage)
async def inference_generator(request, *args, params_kw=None, **kw): async def inference_generator(request, *args, params_kw=None, **kw):
env = request._run_ns.copy() env = request._run_ns.copy()
@ -152,38 +116,15 @@ 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, catelogid) llm = await get_llm(llmid)
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)
yield errmsg yield errmsg
return return
params_kw.model = llm.model if not params_kw.model:
# ── Unified balance reserve ──────────────────────────────────────── params_kw.model = llm.model
# Covers every entry that did not pre-reserve. v1/chat/completions
# reserves at dspy level and passes _luid; everyone else reserves here.
# Reusing _luid downstream also fixes the old mismatch where the
# reserve key could never equal the llmusage id used by finalize.
if params_kw.get('_luid'):
if llm.stream == 'async':
# Entry reserved with the short TTL; async tasks run longer
await extend_reserve(env, params_kw._luid, 3600)
else:
_luid = getID()
_ttl = 3600 if llm.stream == 'async' else 600
reserved = await reserve_balance(env, llm.id, callerorgid, _luid,
ttl=_ttl, userid=callerid)
if not reserved.get('ok'):
debug(f'balance reserve rejected: {reserved}')
errmsg = json.dumps({'status': 'FAILED',
'error': f'余额不足(balance reserve rejected): {reserved.get("reason")}'},
ensure_ascii=False) + '\n'
yield errmsg
return
params_kw._luid = _luid
params_kw._reserved = reserved
if llm.stream == 'async': if llm.stream == 'async':
if llm.callbackurl: if llm.callbackurl:
cb_url = env.entire_url(llm.callbackurl) cb_url = env.entire_url(llm.callbackurl)

View File

@ -1,515 +0,0 @@
"""
llmage Product Module Interface Implementation
Implements the standard resource module interface for product_management.
All functions take resource_ref_id (= llm.id) as the primary identifier.
The product module stores this mapping in product.resource_ref_id.
"""
import json
import time
from appPublic.log import debug, exception
from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject
from appPublic.timeUtils import curDateString, timestampstr
from sqlor.dbpools import get_sor_context
from ahserver.serverenv import ServerEnv
from .utils import (
get_llmage_llm,
get_llm,
get_user_tpac,
get_tpac_balance,
write_llmio,
write_llmusage,
)
from .accounting import llm_charging
from .balance import reserve_balance, refund_balance
async def _resolve_llm(resource_ref_id):
"""Resolve resource_ref_id (llm.id) to llm record with full info.
Returns (llm_record, error_message).
"""
llm = await get_llmage_llm(resource_ref_id)
if not llm:
return None, f'模型 {resource_ref_id} 不存在或配置不完整'
return llm, None
async def get_product_display(resource_ref_id):
"""获取产品定价展示信息。
Args:
resource_ref_id: 资源模块内部ID= llm.id
Returns:
{'success': True, 'pricing_text': str, 'pricing_detail': dict, 'extra_info': dict}
"""
llm, err = await _resolve_llm(resource_ref_id)
if err:
return {'success': False, 'message': err}
env = ServerEnv()
pricing_text = ''
pricing_detail = {}
if llm.ppid:
try:
pd = await env.get_pricing_display(llm.ppid)
if pd:
pricing_text = pd.get('display_text', '')
pricing_detail = pd
except Exception as e:
debug(f'get_pricing_display failed for ppid={llm.ppid}: {e}')
pricing_text = '定价信息暂不可用'
# Get provider name
provider_name = ''
if llm.providerid:
async with get_sor_context(env, 'rbac') as sor:
org_recs = await sor.R('organization', {'id': llm.providerid})
if org_recs:
provider_name = org_recs[0].orgname
extra_info = {
'provider': provider_name,
'llmid': llm.id,
'model': llm.model,
'catelog': getattr(llm, 'catelogname', ''),
'ownerid': llm.ownerid,
}
return {
'success': True,
'pricing_text': pricing_text,
'pricing_detail': pricing_detail,
'extra_info': extra_info,
}
async def check_product_availability(resource_ref_id, user_org_id=None):
"""检查产品是否可用(定价方案是否有效)。
Args:
resource_ref_id: 资源模块内部ID= llm.id
user_org_id: 用户组织ID自有模型免检
Returns:
{'available': bool, 'reason': str}
"""
llm, err = await _resolve_llm(resource_ref_id)
if err:
return {'available': False, 'reason': err}
# Self-owned model: always available
if user_org_id and llm.ownerid == user_org_id:
return {'available': True, 'reason': ''}
# Check status
if llm.status != 'published':
return {'available': False, 'reason': '模型已下线'}
# Check ppid exists
if not llm.ppid:
return {'available': False, 'reason': '无定价方案(ppid为空)'}
# Check pricing data for today
env = ServerEnv()
try:
await env.get_ppid_pricing(llm.ppid)
except Exception as e:
debug(f'check_product_availability: ppid={llm.ppid} no pricing: {e}')
return {'available': False, 'reason': '今日无有效定价数据'}
return {'available': True, 'reason': ''}
async def check_product_consumable(resource_ref_id, user_id, user_org_id):
"""消费前综合预检:可用性 + 余额 + 定价。
Args:
resource_ref_id: 资源模块内部ID= llm.id
user_id: 用户ID
user_org_id: 用户组织ID
Returns:
{'consumable': bool, 'reason': str, 'min_balance': float, 'pricing_available': bool}
"""
llm, err = await _resolve_llm(resource_ref_id)
if err:
return {'consumable': False, 'reason': err,
'min_balance': 0, 'pricing_available': False}
# Self-owned model: skip balance check
if llm.ownerid == user_org_id:
return {'consumable': True, 'reason': '',
'min_balance': 0, 'pricing_available': True}
# Check ppid
if not llm.ppid:
return {'consumable': False, 'reason': '无定价方案',
'min_balance': 0, 'pricing_available': False}
# Check pricing data
env = ServerEnv()
pricing_available = True
try:
await env.get_ppid_pricing(llm.ppid)
except Exception as e:
debug(f'check_product_consumable: ppid={llm.ppid} no pricing: {e}')
pricing_available = False
return {'consumable': False, 'reason': '今日无有效定价数据',
'min_balance': float(llm.min_balance or 0),
'pricing_available': False}
# Check balance
min_balance = float(llm.min_balance or 0)
balance = 0.0
tpac = await get_user_tpac(user_id)
if tpac:
balance = await get_tpac_balance(tpac, user_id)
if balance is None:
balance = 0.0
else:
from accounting.getaccount import getCustomerBalance
async with get_sor_context(env, 'accounting') as sor:
bal = await getCustomerBalance(sor, user_org_id)
balance = float(bal) if bal else 0.0
if balance < min_balance:
return {'consumable': False,
'reason': f'余额不足(当前{balance:.2f}, 最低{min_balance:.2f})',
'min_balance': min_balance,
'pricing_available': pricing_available}
return {'consumable': True, 'reason': '',
'min_balance': min_balance,
'pricing_available': pricing_available}
async def execute_product_service(resource_ref_id, user_id, user_org_id, request_data):
"""执行大模型API调用非流式
Args:
resource_ref_id: 资源模块内部ID= llm.id
user_id: 用户ID
user_org_id: 用户组织ID
request_data: dict, 请求参数messages, temperature, max_tokens
Returns:
{'success': True, 'result': dict, 'usage_data': dict,
'resource_ref_id': str, 'task_id': str, 'status': 'SUCCEEDED'}
"""
env = ServerEnv()
llm, err = await _resolve_llm(resource_ref_id)
if err:
return {'success': False, 'message': err, 'status': 'FAILED'}
# Get full llm info (with uapi) for API call
full_llm = await get_llm(llm.id)
if not full_llm:
return {'success': False, 'message': '模型API配置不完整', 'status': 'FAILED'}
from uapi.appapi import UAPI
# Get API user
userid = await env.uapi_data.get_calluserid(full_llm.upappid, orgid=full_llm.ownerid)
luid = getID()
params_kw = DictObject(**request_data)
params_kw.model = full_llm.model
if not params_kw.get('transno'):
params_kw.transno = luid
# ── Balance reservation (product path) ─────────────────────────
# Same atomic pre-deduct as the direct dspy path; policy checks
# (self-org / tpac / no-ppid) live inside reserve_balance.
ttl = 3600 if full_llm.stream == 'async' else 600
reserved = await reserve_balance(env, full_llm.id, user_org_id, luid,
ttl=ttl, userid=user_id)
if not reserved.get('ok'):
return {'success': False,
'message': f'余额不足(balance reserve rejected): {reserved.get("reason")}',
'status': 'FAILED', 'task_id': luid}
params_kw._luid = luid
try:
if full_llm.stream == 'async':
from .asyncinference import async_uapi_request_product
result = await async_uapi_request_product(
full_llm, userid, user_id, user_org_id, params_kw, luid)
elif not full_llm.stream:
from .syncinference import sync_uapi_request_product
result = await sync_uapi_request_product(
full_llm, userid, user_id, user_org_id, params_kw, luid)
else:
result = await _collect_stream(full_llm, userid, user_id,
user_org_id, params_kw, luid)
# Sub-callers swallow exceptions and return success=False —
# refund here too (refund is idempotent via GETDEL)
if not result.get('success'):
try:
await refund_balance(env, luid)
except Exception:
pass
return result
except Exception as e:
# Refund balance reservation on failure
try:
await refund_balance(env, luid)
except Exception:
pass
exception(f'execute_product_service error: {e}')
return {'success': False, 'message': str(e), 'status': 'FAILED',
'task_id': luid}
async def _collect_stream(llm, api_userid, user_id, user_org_id, params_kw, luid):
"""Collect streaming response into a single result dict."""
env = ServerEnv()
from uapi.appapi import UAPI
uapi = UAPI(llm.upappid, llm.apiname)
outlines = []
txt = ''
usage = None
start_timestamp = time.time()
responsed_seconds = None
try:
first = True
async for l in uapi.stream_linify(llm.upappid, llm.apiname,
api_userid, params=params_kw):
if first:
first = False
responsed_seconds = time.time() - start_timestamp
if isinstance(l, bytes):
l = l.decode('utf-8')
if l and l[-1] == '\n':
l = l[:-1]
l = ''.join(l.split('\n'))
if l and l != '[DONE]':
try:
d = json.loads(l)
except:
continue
if d.get('reasoning_content'):
txt += d.get('reasoning_content')
if d.get('content'):
txt += d['content']
if d.get('usage'):
usage = d['usage']
outlines.append(d)
finish_seconds = time.time() - start_timestamp
if responsed_seconds is None:
responsed_seconds = finish_seconds
# Write llmusage
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = user_id
llmusage.usages = json.dumps(usage, ensure_ascii=False, indent=4) if usage else '{}'
ioinfo = {"input": dict(params_kw), "output": outlines}
webpath = await write_llmio(luid, ioinfo)
llmusage.ioinfo = webpath
llmusage.transno = params_kw.get('transno', luid)
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = 'SUCCEEDED'
llmusage.userorgid = user_org_id
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created'
await write_llmusage(llmusage)
return {
'success': True,
'result': {'content': txt, 'chunks': outlines},
'usage_data': usage or {},
'resource_ref_id': llm.id,
'task_id': luid,
'status': 'SUCCEEDED',
}
except Exception as e:
exception(f'stream collect error: {e}')
return {'success': False, 'message': str(e),
'task_id': luid, 'status': 'FAILED'}
async def execute_product_service_stream(resource_ref_id, user_id, user_org_id, request_data):
"""执行大模型API调用流式返回异步生成器。
Yields: {'chunk': dict, 'usage_data': dict|None, 'done': bool}
Final chunk has done=True with complete usage_data.
"""
llm, err = await _resolve_llm(resource_ref_id)
if err:
yield {'chunk': None, 'usage_data': None, 'done': True,
'error': err}
return
full_llm = await get_llm(llm.id)
if not full_llm:
yield {'chunk': None, 'usage_data': None, 'done': True,
'error': '模型API配置不完整'}
return
env = ServerEnv()
from uapi.appapi import UAPI
uapi = UAPI(full_llm.upappid, full_llm.apiname)
api_userid = await env.uapi_data.get_calluserid(
full_llm.upappid, orgid=full_llm.ownerid)
params_kw = DictObject(**request_data)
params_kw.model = full_llm.model
luid = getID()
if not params_kw.get('transno'):
params_kw.transno = luid
# ── Balance reservation (product stream path) ──────────────────
reserved = await reserve_balance(env, full_llm.id, user_org_id, luid,
ttl=600, userid=user_id)
if not reserved.get('ok'):
yield {'chunk': None, 'usage_data': None, 'done': True,
'error': f'余额不足(balance reserve rejected): {reserved.get("reason")}',
'task_id': luid, 'status': 'FAILED'}
return
params_kw._luid = luid
outlines = []
txt = ''
usage = None
start_timestamp = time.time()
responsed_seconds = None
try:
first = True
async for l in uapi.stream_linify(full_llm.upappid, full_llm.apiname,
api_userid, params=params_kw):
if first:
first = False
responsed_seconds = time.time() - start_timestamp
if isinstance(l, bytes):
l = l.decode('utf-8')
if l and l[-1] == '\n':
l = l[:-1]
l = ''.join(l.split('\n'))
if l and l != '[DONE]':
try:
d = json.loads(l)
except:
continue
if d.get('reasoning_content'):
txt += d.get('reasoning_content')
if d.get('content'):
txt += d['content']
if d.get('usage'):
usage = d['usage']
outlines.append(d)
d['llmusageid'] = luid
yield {'chunk': d, 'usage_data': None, 'done': False}
# Stream done — write llmusage
finish_seconds = time.time() - start_timestamp
if responsed_seconds is None:
responsed_seconds = finish_seconds
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = full_llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = user_id
llmusage.usages = json.dumps(usage, ensure_ascii=False, indent=4) if usage else '{}'
ioinfo = {"input": dict(params_kw), "output": outlines}
webpath = await write_llmio(luid, ioinfo)
llmusage.ioinfo = webpath
llmusage.transno = params_kw.get('transno', luid)
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = 'SUCCEEDED'
llmusage.userorgid = user_org_id
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = full_llm.ownerid
llmusage.accounting_status = 'created'
await write_llmusage(llmusage)
yield {
'chunk': None,
'usage_data': usage or {},
'done': True,
'task_id': luid,
'status': 'SUCCEEDED',
}
except Exception as e:
# Refund balance reservation on failure
try:
await refund_balance(env, luid)
except Exception:
pass
exception(f'stream error: {e}')
yield {'chunk': None, 'usage_data': None, 'done': True,
'error': str(e), 'task_id': luid, 'status': 'FAILED'}
async def calculate_product_cost(resource_ref_id, usage_data, user_org_id=None):
"""计算本次消费的费用。
Args:
resource_ref_id: 资源模块内部ID= llm.id
usage_data: dict, 用量数据 (prompt_tokens, completion_tokens )
user_org_id: 用户组织ID用于折扣
Returns:
{'success': True, 'amount': float, 'original_amount': float,
'cost': float, 'discount': float, 'pricing_program_id': str}
"""
llm, err = await _resolve_llm(resource_ref_id)
if err:
return {'success': False, 'message': err}
if not llm.ppid:
return {'success': False, 'message': '无定价方案(ppid为空)'}
env = ServerEnv()
try:
prices = await env.buffered_charging(llm.ppid, usage_data)
if prices is None:
return {'success': False,
'message': f'定价计算返回空(ppid={llm.ppid})'}
amount = 0
cost = 0
for p in prices:
amount += p.amount
if p.cost:
cost += p.cost
# Apply customer discount
discount = 1.0
if user_org_id:
try:
discount = await env.get_customer_discount(
llm.ownerid, user_org_id)
except:
pass
return {
'success': True,
'original_amount': round(amount, 6),
'amount': round(amount * discount, 6),
'cost': round(cost, 6),
'discount': discount,
'pricing_program_id': llm.ppid,
}
except Exception as e:
exception(f'calculate_product_cost error: {e}')
return {'success': False, 'message': f'定价计算失败: {e}'}

View File

@ -14,7 +14,6 @@ from appPublic.base64_to_file import base64_to_file, getFilenameFromBase64
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 .accounting import llm_accounting, llm_charging from .accounting import llm_accounting, llm_charging
from .balance import refund_balance
from .utils import * from .utils import *
async def sync_uapi_request(request, llm, callerid, callerorgid, params_kw=None): async def sync_uapi_request(request, llm, callerid, callerorgid, params_kw=None):
@ -29,7 +28,7 @@ async def sync_uapi_request(request, llm, callerid, callerorgid, params_kw=None)
outlines = [] outlines = []
b = None b = None
d = None d = None
luid = params_kw.get('_luid') or getID() luid = getID()
try: try:
start_timestamp = time.time() start_timestamp = time.time()
responsed_seconds = None responsed_seconds = None
@ -61,21 +60,34 @@ async def sync_uapi_request(request, llm, callerid, callerorgid, params_kw=None)
llmusage.responsed_seconds = responsed_seconds llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds llmusage.finish_seconds = finish_seconds
llmusage.status = 'SUCCEEDED' llmusage.status = 'SUCCEEDED'
llmusage.amount = 0.00 llmusage.amount = llmusage.cost = 0.00
""" 联机不记账
if llm.ppid:
try:
charging = await llm_charging(llm.ppid, llmusage)
if charging:
llmusage.amount = charging.amount
llmusage.cost = charging.cost
else:
llmusage.amount = llmusage.cost = 0.0
except Exception as e:
e = Exception(f'{llm.pid} charging error{e}')
exception(f'{e}')
else:
llmusage.amount = 0
llmusage.cost = 0
"""
llmusage.userorgid = callerorgid llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created' llmusage.accounting_status = 'created'
b = json.dumps(d, ensure_ascii=False) b = json.dumps(d, ensure_ascii=False)
yield b yield b
# await write_llmusage(llmusage) await write_llmusage(llmusage)
"""联机不记账
if llmusage.amount > 0.0001:
await llm_accounting(llmusage)
"""
except Exception as e: except Exception as e:
# Refund balance reservation on failure
try:
if luid:
await refund_balance(ServerEnv(), luid)
except Exception:
pass
exception(f'{e=},{format_exc()}, {b=}') exception(f'{e=},{format_exc()}, {b=}')
estr = erase_apikey(e) estr = erase_apikey(e)
ed = {"error": f"ERROR:{estr}", "status": "FAILED" ,"llmusageid": luid} ed = {"error": f"ERROR:{estr}", "status": "FAILED" ,"llmusageid": luid}
@ -83,88 +95,4 @@ async def sync_uapi_request(request, llm, callerid, callerorgid, params_kw=None)
s = ''.join(s.split('\n')) s = ''.join(s.split('\n'))
outlines.append(ed) outlines.append(ed)
yield f'{s}\n' yield f'{s}\n'
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = callerid
llmusage.usages = None
ioinfo = {
"input": params_kw,
'output': [ed]
}
webpath = await write_llmio(llmusage.id, ioinfo)
llmusage.ioinfo = webpath
llmusage.transno = params_kw.transno
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = 'FAILED'
llmusage.amount = 0.00
llmusage.userorgid = callerorgid
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
finally:
await write_llmusage(llmusage)
async def sync_uapi_request_product(llm, api_userid, user_id, user_org_id, params_kw, luid):
"""Product interface version: no HTTP request dependency. Returns dict."""
env = ServerEnv()
from uapi.appapi import UAPI
uapi = UAPI(llm.upappid, llm.apiname)
b = None
d = None
try:
start_timestamp = time.time()
b = await uapi.call(llm.upappid, llm.apiname, api_userid, params=params_kw)
if isinstance(b, bytes):
b = b.decode('utf-8')
d = json.loads(b)
status = d.get('status')
usage = d.get('usage')
if status and status != 'SUCCEEDED':
raise Exception(d.get('error', 'Unknown error'))
responsed_seconds = time.time() - start_timestamp
finish_seconds = responsed_seconds
llmusage = DictObject()
llmusage.id = luid
llmusage.llmid = llm.id
llmusage.use_date = curDateString()
llmusage.use_time = timestampstr()
llmusage.userid = user_id
llmusage.usages = json.dumps(usage, ensure_ascii=False) if usage else '{}'
ioinfo = {"input": dict(params_kw), "output": [d]}
webpath = await write_llmio(luid, ioinfo)
llmusage.ioinfo = webpath
llmusage.transno = params_kw.get('transno', luid)
llmusage.responsed_seconds = responsed_seconds
llmusage.finish_seconds = finish_seconds
llmusage.status = 'SUCCEEDED'
llmusage.amount = 0.00
llmusage.userorgid = user_org_id
llmusage.tenantid = params_kw.get('tenantid', params_kw.get('tentantid'))
llmusage.ownerid = llm.ownerid
llmusage.accounting_status = 'created'
await write_llmusage(llmusage)
return {
'success': True,
'result': d,
'usage_data': usage or {},
'resource_ref_id': llm.id,
'task_id': luid,
'status': 'SUCCEEDED',
}
except Exception as e:
exception(f'sync_uapi_request_product error: {e}')
estr = erase_apikey(e)
return {
'success': False,
'message': str(estr),
'task_id': luid,
'status': 'FAILED',
}

View File

@ -1,118 +1,21 @@
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, get_uapi from uapi.appapi import UAPI, sor_get_callerid, sor_get_uapi
from appPublic.share_cache import cache_get, cache_invalidate
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()
# =============================================================
# Process-level cache for llmid lookup (model+catelogid -> llmid)
# =============================================================
_llmid_cache = {} # key: "model_name:catelogid" -> llmid
async def _warm_llmid_cache(env):
"""启动时全量加载 model+catelogid -> llmid 映射"""
global _llmid_cache
try:
async with get_sor_context(env, 'llmage') as sor:
sql = """SELECT a.name, b.id as catelogid, m.llmid
FROM llm_api_map m
JOIN llm a ON a.id = m.llmid AND a.status = 'published'
JOIN llmcatelog b ON b.id = m.llmcatelogid"""
recs = await sor.sqlExe(sql, {})
for r in recs:
key = f"{r.name}:{r.catelogid}"
_llmid_cache[key] = r.llmid
debug(f'[llmage] llmid cache warmed: {len(_llmid_cache)} entries')
except Exception as e:
exception(f'[llmage] llmid cache warm failed: {e}')
_llmid_cache = {}
async def get_llmid_cached(env, model_name, catelogid):
"""从缓存获取 llmid未命中则查 DB 并缓存"""
global _llmid_cache
key = f"{model_name}:{catelogid}"
if key in _llmid_cache:
return _llmid_cache[key]
# 缓存未命中,查 DB兼容模型在缓存预热后新增的场景
async with get_sor_context(env, 'llmage') as sor:
sql = """SELECT m.llmid
FROM llm_api_map m
JOIN llm a ON a.id = m.llmid AND a.name = ${model}$ AND a.status = 'published'
JOIN llmcatelog b ON b.id = m.llmcatelogid AND (b.id = ${catelogid}$ OR b.name = ${catelogid}$)"""
recs = await sor.sqlExe(sql, {'model': model_name, 'catelogid': catelogid})
llmid = recs[0].llmid if recs else None
if llmid:
_llmid_cache[key] = llmid
return llmid
def invalidate_llmid_cache():
global _llmid_cache
_llmid_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:
@ -143,14 +46,13 @@ 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, model): async def tpac_accounting(tpac, userid, llmid, amount, usage, luid):
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'
@ -199,32 +101,6 @@ async def write_llmio(luid, io_dic):
webpath = await fs.save(name, s, userid='llmio') webpath = await fs.save(name, s, userid='llmio')
return webpath return webpath
async def get_llmusage_by_id(usage_id):
"""从数据库获取 llmusage 记录"""
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
sql = "select id, llmid, ioinfo, usages from llmusage where id = ${id}$"
recs = await sor.sqlExe(sql, {'id': usage_id})
if recs and len(recs) > 0:
return dict(recs[0])
return None
async def read_ioinfo_content(ioinfo_webpath):
"""从 FileStorage 读取交互信息文件内容"""
if not ioinfo_webpath:
return None
try:
fs = FileStorage()
real_path = fs.realPath(ioinfo_webpath)
async with aiofiles.open(real_path, 'rb') as f:
bin_data = await f.read()
return json.loads(bin_data.decode('utf-8'))
except Exception as e:
debug(f'read_ioinfo_content error: {e}')
return None
async def llm_query_orders(userorgid, page, pagerows=80): async def llm_query_orders(userorgid, page, pagerows=80):
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
@ -275,11 +151,10 @@ def erase_apikey(e):
async def get_llmproviders(): async def get_llmproviders():
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
sql = """select a.providerid, 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, b.orgname"""
return await sor.sqlExe(sql, {}) return await sor.sqlExe(sql, {})
return [] return []
@ -290,36 +165,14 @@ 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.name 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, model=l.name)
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,
@ -333,44 +186,6 @@ 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, a.name"
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')
@ -380,19 +195,7 @@ async def get_llmcatelogs():
return [] return []
async def get_pricing_text(l): async def get_llms_by_catelog_to_customer(catelogid=None, orderby='providerid'):
try:
env = ServerEnv()
pd = await env.get_pricing_display(l.ppid, model=l.name)
if pd:
l.pricing_display = pd.get('display_text', '')
except Exception as e:
debug(f'{e}')
pass
async def get_llms_by_catelog_to_customer(catelogid=None, orderby='providerid, name'):
# icon "{{entire_url('/appbase/show_icon.dspy')}}?id={{llm.iconid}}"
# pricing: llm.pricing_display
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
today = curDateString() today = curDateString()
@ -403,24 +206,19 @@ m.llmcatelogid as catelog_id,
m.apiname, m.apiname,
m.query_apiname, m.query_apiname,
m.query_period, m.query_period,
m.ppid, m.ppid
o.orgname as provider_name
from llm 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
join organization o
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}$
and a.providerid = o.id
""" """
params = {'today': today} sortstr='catelog_id, ' + orderby
params = {'today': today, 'sort': sortstr}
if catelogid: if catelogid:
sql += " and m.llmcatelogid = ${catelogid}$" sql += " and m.llmcatelogid = ${catelogid}$"
params['catelogid'] = catelogid params['catelogid'] = catelogid
sql += " order by m.llmcatelogid, a.providerid, a.name"
debug(f'{sql=}') debug(f'{sql=}')
recs = await sor.sqlExe(sql, params.copy()) recs = await sor.sqlExe(sql, params.copy())
@ -429,7 +227,6 @@ o.orgname as provider_name
cid = '' cid = ''
x = None x = None
for r in recs: for r in recs:
await get_pricing_text(r)
if cid != r.catelog_id: if cid != r.catelog_id:
x = { x = {
'catelogid': r.catelog_id, 'catelogid': r.catelog_id,
@ -448,44 +245,24 @@ async def get_llms_by_catelog(catelogid=None, orderby='providerid'):
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
today = curDateString() today = curDateString()
# Join with llm_api_map to get catalog relationship # Join with llm_api_map to get catalog relationship
sql = """select distinct a.*, b.name as catelogname, m.llmcatelogid as catelog_id sql = """select distinct a.*, b.name as catelogname, m.llmcatelogid as catelog_id
from llm 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 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:
sql += " and m.llmcatelogid = ${catelogid}$" sql += " and m.llmcatelogid = ${catelogid}$"
params['catelogid'] = catelogid params['catelogid'] = catelogid
sql += " order by m.llmcatelogid, a.id, a.name"
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, model=r.name)
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,
@ -499,76 +276,69 @@ 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):
"""Get LLM with full uapi info for vendor API calls. today = curDateString()
Refactored to use get_llmage_llm() + cached uapi/uapiio lookups env = ServerEnv()
instead of a 6-table JOIN. async with get_sor_context(env, 'llmage') as sor:
sql = """select a.id,
Returns DictObject with merged fields: a.name,
From get_llmage_llm: id, name, model, providerid, description, a.model,
iconid, upappid, ownerid, min_balance, status, llmcatelogid, a.providerid,
apiname, query_apiname, query_period, ppid, isdefaultcatelog, a.description,
catelogname a.iconid,
From uapi (cached): ioid, stream, callbackurl a.upappid,
From uapiio (cached): input_fields a.ownerid,
""" a.min_balance,
# Step 1: Get base info from get_llmage_llm (3-table JOIN: llm + llm_api_map + llmcatelog) m.llmcatelogid,
llm = await get_llmage_llm(llmid, catelogid) m.apiname,
if not llm: m.query_apiname,
debug(f'{llmid=} not found via get_llmage_llm') m.query_period,
return None m.ppid,
e.ioid,
# Step 2: Get uapi info (cached, keyed by upappid:apiname) e.stream,
uapi = await _get_uapi_cached(llm.upappid, llm.apiname) e.callbackurl,
if not uapi: f.input_fields,
debug(f'uapi not found: upappid={llm.upappid}, apiname={llm.apiname}') lc.name as catelogname
return None from llm a
,llm_api_map m
# Step 3: Get uapiio info (cached, keyed by ioid) ,llmcatelog lc
uapiio = await _get_uapiio_cached(uapi.ioid) ,upapp c
,uapi e
# Merge uapi fields into llm result ,uapiio f
llm.ioid = uapi.ioid where a.id = m.llmid
llm.stream = uapi.stream and a.upappid = c.id
llm.callbackurl = uapi.callbackurl and c.id = e.upappid
llm.input_fields = uapiio.input_fields if uapiio else '{}' and m.apiname = e.name
and e.ioid = f.id
return llm and a.id = ${llmid}$
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):
env = ServerEnv() env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor: async with get_sor_context(env, 'llmage') as sor:
n = 0
part0 = llmusage.id
while True:
recs = await sor.R('llmusage', {'id': llmusage.id})
if len(recs) == 0:
break
n = n + 1
llmusage.id = f'{part0}*{n}'
await sor.C('llmusage', llmusage) await sor.C('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_llmage_llm(llmid) llm = await get_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}')
@ -576,76 +346,3 @@ async def llm_query_price(llmid, config_data):
prices = await env.buffered_charging(llm.ppid, config_data) prices = await env.buffered_charging(llm.ppid, config_data)
return prices return prices
import base64 as _base64
import mimetypes as _mimetypes
import os as _os
def _file_to_b64(filepath):
"""读取文件并返回 base64 字符串"""
with open(filepath, 'rb') as fh:
return _base64.b64encode(fh.read()).decode('utf-8')
def _file_mime(filepath):
"""猜测文件 MIME 类型"""
mime, _ = _mimetypes.guess_type(filepath)
return mime or 'application/octet-stream'
async def get_plaza_models(providerid='', catelogid='', search=''):
"""Flat model list for cockpit plaza: filterable by provider/catalog/search, sorted by name."""
env = ServerEnv()
data = await get_llms_by_catelog_to_customer(
catelogid=catelogid if catelogid else None,
orderby='a.name'
)
result = []
for cate in data:
for llm in cate['llms']:
if providerid and llm.providerid != providerid:
continue
if search:
sl = search.lower()
n = (llm.name or '').lower()
d = (llm.description or '').lower()
if sl not in n and sl not in d:
continue
result.append({
'id': llm.id, 'name': llm.name, 'model': llm.model,
'description': llm.description or '', 'iconid': llm.iconid,
'providerid': llm.providerid,
'provider_name': getattr(llm, 'provider_name', getattr(llm, 'orgname', '')),
'catelog_id': getattr(llm, 'catelog_id', ''),
'catelogname': getattr(llm, 'catelogname', ''),
'pricing_display': getattr(llm, 'pricing_display', []),
})
return result
ServerEnv().base64 = _base64
ServerEnv().mimetypes = _mimetypes
ServerEnv().os = _os
ServerEnv().file_to_b64 = _file_to_b64
ServerEnv().file_mime = _file_mime
async def get_model_max_cost(llmid):
"""Get historical max actual customer charge for a model from llmusage."""
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
sql = """SELECT MAX(amount) as max_cost
FROM llmusage
WHERE llmid = ${llmid}$
AND status = 'SUCCEEDED'
AND amount IS NOT NULL
AND amount > 0"""
recs = await sor.sqlExe(sql, {'llmid': llmid})
if recs and recs[0].max_cost:
return float(recs[0].max_cost)
return 0
async def update_model_max_cost(llmid, new_max):
"""Persist updated max_cost to llm table."""
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
await sor.U('llm', {'id': llmid, 'max_cost': float(new_max)})
# Redis balance reservation (implemented in balance.py)
from .balance import reserve_balance, finalize_balance, refund_balance

View File

@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""并发压力测试 llmage /v1/chat/completions — 4分钟×3组(50/100/200)TTFB/QPM/500/主机资源"""
import asyncio, aiohttp, time, json, sys, statistics, subprocess
from dataclasses import dataclass, field
from typing import List
URL = "https://token.opencomputing.cn/llmage/v1/chat/completions"
TOKEN = "V9J41PngWBUU6gdHWJWDJ"
MODEL = "qwen3.6-35b-a3b"
DURATION = 240 # 4 minutes
CONCURRENCIES = [50, 100, 200]
# Host monitoring via SSH
HOST_SSH = "token@token.opencomputing.cn"
HOST_CPU_CMD = "top -bn1 | grep 'Cpu(s)' | awk '{print $2+$4}'"
HOST_MEM_CMD = "free -m | awk '/Mem:/{printf \"%.1f\", $3/$2*100}'"
HOST_LOAD_CMD = "uptime | awk -F'[a-z]:' '{print $2}' | awk '{print $1,$2,$3}'"
@dataclass
class ReqStat:
idx: int
start_ts: float
first_byte_ts: float | None = None
end_ts: float | None = None
http_status: int = 0
@dataclass
class HostSnap:
ts: float
cpu: float
mem: float
load: str
async def host_snapshot() -> HostSnap:
loop = asyncio.get_running_loop()
try:
cpu = float((await loop.run_in_executor(
None, lambda: subprocess.run(
["ssh", "-o", "ConnectTimeout=3", HOST_SSH, HOST_CPU_CMD],
capture_output=True, text=True, timeout=5
).stdout.strip()
)) or 0)
except:
cpu = 0
try:
mem = float((await loop.run_in_executor(
None, lambda: subprocess.run(
["ssh", "-o", "ConnectTimeout=3", HOST_SSH, HOST_MEM_CMD],
capture_output=True, text=True, timeout=5
).stdout.strip()
)) or 0)
except:
mem = 0
try:
load = (await loop.run_in_executor(
None, lambda: subprocess.run(
["ssh", "-o", "ConnectTimeout=3", HOST_SSH, HOST_LOAD_CMD],
capture_output=True, text=True, timeout=5
).stdout.strip()
)) or "N/A"
except:
load = "N/A"
return HostSnap(ts=time.monotonic(), cpu=cpu, mem=mem, load=load)
async def worker(session: aiohttp.ClientSession, idx: int, stats_out: list):
payload = {
"model": MODEL,
"stream": True,
"messages": [{"role": "user", "content": f"你是谁? 请用一句话回答,编号{idx}"}],
}
stat = ReqStat(idx=idx, start_ts=time.monotonic())
try:
async with session.post(
URL, json=payload,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"},
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
stat.http_status = resp.status
first = True
async for line in resp.content:
if first:
stat.first_byte_ts = time.monotonic()
first = False
stat.end_ts = time.monotonic()
except Exception:
stat.end_ts = time.monotonic()
stats_out.append(stat)
async def run_concurrency(concurrency: int):
stats: List[ReqStat] = []
host_snaps: List[HostSnap] = []
idx = 0
stop_at = time.monotonic() + DURATION
connector = aiohttp.TCPConnector(limit=concurrency + 50, force_close=True)
async with aiohttp.ClientSession(connector=connector) as session:
tasks: list[asyncio.Task] = []
last_snap = 0
while time.monotonic() < stop_at:
# Fill to concurrency
while len(tasks) < concurrency and time.monotonic() < stop_at:
idx += 1
tasks.append(asyncio.create_task(worker(session, idx, stats)))
if not tasks:
break
# Host snapshot every 15s
now = time.monotonic()
if now - last_snap > 15:
host_snaps.append(await host_snapshot())
last_snap = now
sys.stdout.write(f"\r [{concurrency}] {len(stats)} req | CPU:{host_snaps[-1].cpu:.0f}% MEM:{host_snaps[-1].mem:.0f}% LOAD:{host_snaps[-1].load}")
sys.stdout.flush()
done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED, timeout=0.5)
tasks = list(tasks)
# Drain remaining
if tasks:
await asyncio.wait(tasks)
# Final snapshot
host_snaps.append(await host_snapshot())
return stats, host_snaps
def analyze(concurrency: int, stats: List[ReqStat], host_snaps: List[HostSnap]):
ttfb_list = [s.first_byte_ts - s.start_ts for s in stats if s.first_byte_ts]
total_list = [s.end_ts - s.start_ts for s in stats if s.end_ts and s.first_byte_ts]
failed = sum(1 for s in stats if s.first_byte_ts is None)
status_500 = sum(1 for s in stats if s.http_status >= 500)
status_errors = sum(1 for s in stats if s.http_status >= 400 and s.http_status != 200)
total_req = len(stats)
qpm = total_req / (DURATION / 60)
print(f"\n{'='*65}")
print(f" 并发={concurrency} | {DURATION}s | 请求={total_req} | 失败={failed} | 5xx={status_500}")
print(f"{'='*65}")
if ttfb_list:
print(f" TTFB(s): min={min(ttfb_list):.3f} avg={statistics.mean(ttfb_list):.3f} "
f"p50={statistics.median(ttfb_list):.3f} p95={_pct(ttfb_list,95):.3f} p99={_pct(ttfb_list,99):.3f}")
if total_list:
print(f" 完成(s): min={min(total_list):.3f} avg={statistics.mean(total_list):.3f} "
f"p50={statistics.median(total_list):.3f} p95={_pct(total_list,95):.3f} p99={_pct(total_list,99):.3f}")
print(f" QPM: {qpm:.1f} | QPS: {total_req/DURATION:.1f} | HTTP错误: {status_errors}")
# Per-minute breakdown
for minute in range(int(DURATION / 60)):
win_start = minute * 60
win_end = (minute + 1) * 60
pm = sum(1 for s in stats if s.end_ts and s.first_byte_ts
and win_start <= (s.start_ts - stats[0].start_ts) < win_end)
print(f"{minute+1}分钟完成: {pm}")
# Host stats
if host_snaps:
cpus = [s.cpu for s in host_snaps if s.cpu > 0]
mems = [s.mem for s in host_snaps if s.mem > 0]
print(f" 主机: CPU avg={statistics.mean(cpus):.1f}% max={max(cpus):.1f}% "
f"MEM avg={statistics.mean(mems):.1f}% max={max(mems):.1f}% "
f"LOAD max={max((s.load for s in host_snaps if s.load!='N/A'), default='N/A')}")
return {"concurrency": concurrency, "total": total_req, "failed": failed,
"status_500": status_500, "status_errors": status_errors,
"ttfb_avg": statistics.mean(ttfb_list) if ttfb_list else None,
"ttfb_p50": statistics.median(ttfb_list) if ttfb_list else None,
"ttfb_p95": _pct(ttfb_list, 95) if ttfb_list else None,
"ttfb_p99": _pct(ttfb_list, 99) if ttfb_list else None,
"total_avg": statistics.mean(total_list) if total_list else None,
"total_p50": statistics.median(total_list) if total_list else None,
"qpm": qpm,
"host_cpu_avg": statistics.mean(cpus) if cpus else None,
"host_cpu_max": max(cpus) if cpus else None,
"host_mem_avg": statistics.mean(mems) if mems else None}
def _pct(data, p):
return sorted(data)[int(len(data) * p / 100)]
async def main():
results = []
for c in CONCURRENCIES:
print(f"\n>>> 开始 并发={c} [{DURATION}s] ...")
stats, snaps = await run_concurrency(c)
r = analyze(c, stats, snaps)
results.append(r)
print(f"\n{'='*65}")
print(" 汇总对比")
print(f"{'='*65}")
print(f" {'并发':>5} {'请求':>7} {'失败':>5} {'5xx':>5} "
f"{'TTFB_avg':>9} {'TTFB_p50':>9} {'TTFB_p95':>9} {'TTFB_p99':>9} "
f"{'完成_avg':>9} {'QPM':>8} {'CPU_avg':>7} {'CPU_max':>7}")
for r in results:
t_avg = f'{r["ttfb_avg"]:.3f}' if r['ttfb_avg'] else 'N/A'
t_p50 = f'{r["ttfb_p50"]:.3f}' if r['ttfb_p50'] else 'N/A'
t_p95 = f'{r["ttfb_p95"]:.3f}' if r['ttfb_p95'] else 'N/A'
t_p99 = f'{r["ttfb_p99"]:.3f}' if r['ttfb_p99'] else 'N/A'
c_avg = f'{r["total_avg"]:.3f}' if r['total_avg'] else 'N/A'
cpu = f'{r["host_cpu_avg"]:.0f}%' if r['host_cpu_avg'] else 'N/A'
cmax = f'{r["host_cpu_max"]:.0f}%' if r['host_cpu_max'] else 'N/A'
print(f" {r['concurrency']:>5} {r['total']:>7} {r['failed']:>5} {r['status_500']:>5} "
f"{t_avg:>9} {t_p50:>9} {t_p95:>9} {t_p99:>9} "
f"{c_avg:>9} {r['qpm']:>8.0f} {cpu:>7} {cmax:>7}")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -72,24 +72,15 @@
"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": [
{ {
"field": "providerid", "field": "providerid",
"table": "suppliers", "table": "organization",
"valuefield": "id", "valuefield": "id",
"textfield": "supplier_name" "textfield": "orgname"
}, },
{ {
"field": "iconid", "field": "iconid",
@ -108,20 +99,6 @@
"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'"
}
],
"indexes": [
{
"name": "idx_llm_name",
"idxtype": "unique",
"idxfields": ["name"]
} }
] ]
} }

BIN
models/llm.xlsx Normal file

Binary file not shown.

View File

@ -4,99 +4,81 @@
{ {
"name": "llm_api_map", "name": "llm_api_map",
"title": "模型API映射表", "title": "模型API映射表",
"primary": [ "primary": "id",
"id"
],
"catelog": "relation" "catelog": "relation"
} }
], ],
"fields": [ "fields": [
{ {
"name": "id", "name": "id",
"type": "str", "type": "varchar(32)",
"not_null": true, "not_null": true,
"title": "主键ID", "title": "主键ID"
"length": 32
}, },
{ {
"name": "llmid", "name": "llmid",
"type": "str", "type": "varchar(32)",
"not_null": true, "not_null": true,
"title": "模型ID", "title": "模型ID"
"length": 32
}, },
{ {
"name": "llmcatelogid", "name": "llmcatelogid",
"type": "str", "type": "varchar(32)",
"not_null": true, "not_null": true,
"title": "模型分类ID", "title": "模型分类ID"
"length": 32
}, },
{ {
"name": "apiname", "name": "apiname",
"type": "str", "type": "varchar(100)",
"not_null": true, "not_null": true,
"title": "接口名称", "title": "接口名称"
"length": 100
}, },
{ {
"name": "query_apiname", "name": "query_apiname",
"type": "str", "type": "varchar(100)",
"title": "任务结果查询接口名称", "title": "任务结果查询接口名称"
"length": 100
}, },
{ {
"name": "query_period", "name": "query_period",
"type": "long", "type": "bigint",
"default": 30, "default": 30,
"title": "任务查询间隔(秒)" "title": "任务查询间隔(秒)"
}, },
{ {
"name": "ppid", "name": "ppid",
"type": "str", "type": "varchar(32)",
"title": "定价ID", "title": "定价ID"
"length": 32
}, },
{ {
"name": "isdefaultcatelog", "name": "isdefaultcatelog",
"type": "str", "type": "varchar(1)",
"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": [ "idxfields": ["llmid"],
"llmid"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "idx_api_map_catelog", "name": "idx_api_map_catelog",
"type": "normal", "type": "normal",
"idxfields": [ "idxfields": ["llmcatelogid"],
"llmcatelogid"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "idx_api_map_apiname", "name": "idx_api_map_apiname",
"type": "normal", "type": "normal",
"idxfields": [ "idxfields": ["apiname"],
"apiname"
],
"idxtype": "index" "idxtype": "index"
}, },
{ {
"name": "uk_llmid_apiname", "name": "uk_llmid_apiname",
"type": "unique", "type": "unique",
"idxfields": [ "idxfields": ["llmid", "apiname"],
"llmid",
"apiname"
],
"idxtype": "unique" "idxtype": "unique"
} }
], ],
@ -118,13 +100,6 @@
"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

@ -1,36 +0,0 @@
{
"summary": [
{
"name": "llm_metrics",
"title": "模型使用指标",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "id", "type": "str", "length": 32},
{"name": "userid", "title": "用户id", "type": "str", "length": 32},
{"name": "userorgid", "title": "用户机构", "type": "str", "length": 32},
{"name": "llmid", "title": "模型", "type": "str", "length": 32},
{"name": "use_date", "title": "使用日期", "type": "date"},
{"name": "use_time", "title": "使用时间", "type": "timestamp"},
{"name": "input_tokens", "title": "输入tokens", "type": "int", "default": "0"},
{"name": "output_tokens", "title": "输出tokens", "type": "int", "default": "0"},
{"name": "total_tokens", "title": "总tokens", "type": "int", "default": "0"},
{"name": "amount", "title": "金额", "type": "float", "length": 18, "dec": 4, "default": "0"},
{"name": "responsed_seconds", "title": "响应时长(秒)", "type": "float", "length": 18, "dec": 2, "nullable": true},
{"name": "finish_seconds", "title": "完成时长(秒)", "type": "float", "length": 18, "dec": 2, "nullable": true},
{"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": true},
{"name": "taskid", "title": "任务号", "type": "str", "length": 32, "nullable": true}
],
"indexes": [
{"name": "idx1", "idxtype": "index", "idxfields": ["userid", "use_date"]},
{"name": "idx2", "idxtype": "index", "idxfields": ["userorgid", "use_date"]},
{"name": "idx3", "idxtype": "index", "idxfields": ["llmid", "use_date"]},
{"name": "idx4", "idxtype": "index", "idxfields": ["use_time"]}
],
"codes": [
{"field": "userid", "table": "users", "valuefield": "id", "textfield": "username"},
{"field": "llmid", "table": "llm", "valuefield": "id", "textfield": "model"},
{"field": "userorgid", "table": "organization", "valuefield": "id", "textfield": "orgname"}
]
}

View File

@ -18,7 +18,7 @@
}, },
{ {
"name": "llmid", "name": "llmid",
"title": "model", "title": "模型id",
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
@ -34,7 +34,7 @@
}, },
{ {
"name": "userid", "name": "userid",
"title": "username", "title": "用户id",
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
@ -58,15 +58,13 @@
"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",
@ -84,55 +82,23 @@
"name": "amount", "name": "amount",
"title": "交易金额", "title": "交易金额",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "amount_currency",
"title": "计费币种",
"type": "str",
"length": 3,
"default": "CNY"
},
{
"name": "amount_base",
"title": "折本位币金额",
"type": "float",
"length": 20,
"dec": 2,
"default": 0
}, },
{ {
"name": "cost", "name": "cost",
"title": "交易成本", "title": "交易成本",
"type": "float", "type": "float",
"length": 18, "length": 18
"dec": 2
},
{
"name": "cost_currency",
"title": "成本币种",
"type": "str",
"length": 3,
"default": "CNY"
},
{
"name": "cost_base",
"title": "折本位币成本",
"type": "float",
"length": 20,
"dec": 2,
"default": 0
}, },
{ {
"name": "userorgid", "name": "userorgid",
"title": "user_orgname", "title": "用户机构id",
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
{ {
"name": "ownerid", "name": "ownerid",
"title": "owner_orgname", "title": "模型机构id",
"type": "str", "type": "str",
"length": 32 "length": 32
}, },
@ -165,69 +131,6 @@
"accounting_status", "accounting_status",
"use_date" "use_date"
] ]
},
{
"name": "idx_llmusage_userid_usetime",
"idxtype": "index",
"idxfields": [
"userid",
"use_time"
]
},
{
"name": "idx_llmusage_userorgid_usetime",
"idxtype": "index",
"idxfields": [
"userorgid",
"use_time"
]
},
{
"name": "idx_llmusage_usetime",
"idxtype": "index",
"idxfields": [
"use_time"
]
}
],
"codes": [
{
"field": "userid",
"table": "users",
"valuefield": "id",
"textfield": "username"
},
{
"field": "ownerid",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "userorgid",
"table": "organization",
"valuefield": "id",
"textfield": "orgname"
},
{
"field": "llmid",
"table": "llm",
"valuefield": "id",
"textfield": "model"
},
{
"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'"
} }
] ]
} }

BIN
models/llmusage.xlsx Normal file

Binary file not shown.

View File

@ -121,32 +121,5 @@
"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,27 +136,6 @@
"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

@ -1,35 +0,0 @@
{
"summary": [
{
"name": "user_llm_policy",
"title": "用户模型策略",
"primary": ["id"]
}
],
"fields": [
{"name": "id", "title": "id", "type": "str", "length": 32},
{"name": "orgid", "title": "所属机构", "type": "str", "length": 32},
{"name": "userid", "title": "用户", "type": "str", "length": 32},
{"name": "llmid", "title": "模型", "type": "str", "length": 32, "nullable": true},
{"name": "daily_call_limit", "title": "日调用上限", "type": "int", "nullable": true},
{"name": "monthly_call_limit", "title": "月调用上限", "type": "int", "nullable": true},
{"name": "spending_alert", "title": "花费预警", "type": "float", "length": 18, "dec": 2, "nullable": true},
{"name": "spending_ceiling", "title": "费用上限", "type": "float", "length": 18, "dec": 2, "nullable": true},
{"name": "max_concurrency", "title": "并发上限", "type": "int", "nullable": true},
{"name": "status", "title": "状态", "type": "str", "length": 10, "default": "active"},
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": true},
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": true},
{"name": "created_by", "title": "创建人", "type": "str", "length": 32, "nullable": true},
{"name": "remark", "title": "备注", "type": "str", "length": 500, "nullable": true}
],
"indexes": [
{"name": "idx1", "idxtype": "unique", "idxfields": ["orgid", "userid", "llmid"]},
{"name": "idx2", "idxtype": "index", "idxfields": ["orgid"]},
{"name": "idx3", "idxtype": "index", "idxfields": ["userid"]}
],
"codes": [
{"field": "userid", "table": "users", "valuefield": "id", "textfield": "username"},
{"field": "llmid", "table": "llm", "valuefield": "id", "textfield": "model"},
{"field": "orgid", "table": "organization", "valuefield": "id", "textfield": "orgname"}
]
}

View File

@ -1,83 +0,0 @@
-- ============================================================
-- 修复数字人模型显示错误 — 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

@ -1,26 +0,0 @@
-- ============================================================
-- 修复 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';

View File

@ -1,85 +0,0 @@
-- ============================================================
--
-- Kimi K3 (月之暗面 / Moonshot) 完整注册
-- 生成时间: 2026-07-20
-- 模型: kimi-k3
-- 依赖: kimi_k3_uapi.sql (uapi 'kimi_t2t' 已创建)
-- ============================================================
-- 前置条件:
-- modelprovider 已有记录: id='0OkxCHYbZOdD_W_o5N9tN', name='moonshot'
-- uapi 已有记录: id='kimi_t2t' (由 kimi_k3_uapi.sql 创建)
-- uapi 已有记录: id='oL9cufrcRb7SPfH11Ra62' (tm2t, moonshot 通用多模态)
-- uapiio 已有记录: id='Is8l4TGkcZcqFSjbbeIK2' (文本会话)
-- uapiio 已有记录: id='t-ujII59ku45tIPcdXu4O' (文本媒体转文本)
-- ============================================================
-- ============================================================
-- 1. 新增 llm: kimi-k3 模型注册
-- ============================================================
INSERT IGNORE INTO `llm` (`id`, `name`, `model`, `description`, `iconid`, `upappid`, `providerid`, `ownerid`, `enabled_date`, `expired_date`, `min_balance`, `status`)
VALUES (
'kimi-k3-llm',
'kimi-k3',
'kimi-k3',
'月之暗面 Kimi K3支持多模态(图片/视频)输入,兼容 OpenAI Chat Completions 格式,支持深度思考模式',
'moonshot',
'upapp_moonshot',
'0OkxCHYbZOdD_W_o5N9tN',
'0',
'2026-07-20',
'9999-12-31',
10.00,
'published'
);
-- ============================================================
-- 2. 新增 llm_api_map: t2t (纯文本对话)
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'kimi_k3_map_t2t',
'kimi-k3-llm',
't2t',
't2t',
NULL,
30,
NULL,
'1'
);
-- ============================================================
-- 3. 新增 llm_api_map: tm2t (多模态对话, 文本+图片+视频)
-- ============================================================
INSERT IGNORE INTO `llm_api_map` (`id`, `llmid`, `llmcatelogid`, `apiname`, `query_apiname`, `query_period`, `ppid`, `isdefaultcatelog`)
VALUES (
'kimi_k3_map_tm2t',
'kimi-k3-llm',
'vision',
'tm2t',
NULL,
30,
NULL,
'1'
);
-- ============================================================
-- 验证 (执行后运行确认)
-- ============================================================
-- SELECT m.id, m.llmid, m.llmcatelogid, m.apiname, m.ppid,
-- l.name as model_name, l.model, l.status
-- FROM llm_api_map m
-- JOIN llm l ON m.llmid = l.id
-- WHERE m.llmid = 'kimi-k3-llm';
--
-- 预期: 2 行 (t2t + tm2t/vision)
-- SELECT id, name, model, status, providerid, upappid
-- FROM llm WHERE id = 'kimi-k3-llm';
--
-- 预期: 1 行, status='published'
-- ============================================================
-- 回滚
-- ============================================================
-- DELETE FROM llm_api_map WHERE id IN ('kimi_k3_map_t2t', 'kimi_k3_map_tm2t');
-- DELETE FROM llm WHERE id = 'kimi-k3-llm';

View File

@ -1,93 +0,0 @@
-- ============================================================
-- Kimi K3 API 接入 (月之暗面 / Moonshot)
-- Base URL: https://api.moonshot.cn/v1
-- 兼容 OpenAI 格式, 文档: https://platform.kimi.com/docs/api/chat
-- 多模态: image_files/video_files 在 data 模板中用纯 Jinja2 构造 content 数组
-- ============================================================
-- ============================================================
-- 0. 前置: modelprovider / upapp (如不存在先创建)
-- ============================================================
-- INSERT IGNORE INTO modelprovider (id, name) VALUES ('moonshot', '月之暗面');
-- INSERT IGNORE INTO upapp (id, name, `key`, baseurl, enabled_date)
-- VALUES ('upapp_moonshot', '月之暗面', 'MOONSHOT_API_KEY', 'https://api.moonshot.cn', '2026-07-17');
SET @upapp_id = 'upapp_moonshot'; -- 替换为实际的月之暗面 upapp.id
-- ============================================================
-- Chat Completions — POST /v1/chat/completions
-- 支持纯文本 + 多模态(图片/视频 base64)
-- ioid 复用 Is8l4TGkcZcqFSjbbeIK2 (文本会话)
-- ============================================================
REPLACE INTO `uapi` (`id`, `name`, `need_auth`, `stream`, `path`, `httpmethod`, `chunk_match`, `headers`, `params`, `data`, `response`, `ioid`, `callbackurl`, `upappid`)
VALUES (
'kimi_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 %}
{% set _parts = [] %}
{% if prompt %}
{% set _ = _parts.append({"type": "text", "text": prompt}) %}
{% endif %}
{% if image_files %}
{% for _f in image_files %}
{% set _fp = FileStorage().realPath(_f) %}
{% if os.path.isfile(_fp) %}
{% set _mime = file_mime(_fp) %}
{% set _b64 = file_to_b64(_fp) %}
{% set _ = _parts.append({"type": "image_url", "image_url": "data:" + _mime + ";base64," + _b64}) %}
{% endif %}
{% endfor %}
{% endif %}
{% if video_files %}
{% for _f in video_files %}
{% set _fp = FileStorage().realPath(_f) %}
{% if os.path.isfile(_fp) %}
{% set _mime = file_mime(_fp) %}
{% set _b64 = file_to_b64(_fp) %}
{% set _ = _parts.append({"type": "video_url", "video_url": "data:" + _mime + ";base64," + _b64}) %}
{% endif %}
{% endfor %}
{% endif %}
{"role": "user", "content": {{json.dumps(_parts, ensure_ascii=False)}}}
],
{% endif %}
{% if stream %}
"stream":true,
{% endif %}
"model": "{{model}}",
"reasoning_effort": "max"
}',
'{
"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, @upapp_id
);

View File

@ -1,251 +0,0 @@
#!/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}/model_pricing.dspy",
f"/{MOD}/model_pricing.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_search_model.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",
f"/{MOD}/llmusage_usages_display.dspy",
f"/{MOD}/llmusage_ioinfo_display.dspy",
f"/{MOD}/api/llmusage_list.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

@ -1,245 +0,0 @@
#!/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()

View File

@ -1,393 +0,0 @@
-- ============================================================
-- 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,27 +96,6 @@ 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

@ -1,63 +0,0 @@
-- ============================================================
--
-- 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';

View File

@ -1,11 +0,0 @@
-- 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,6 +0,0 @@
-- DROP cost column, ADD tenantid column to llmusage
ALTER TABLE llmusage DROP COLUMN IF EXISTS cost;
ALTER TABLE llmusage ADD COLUMN IF NOT EXISTS tenantid VARCHAR(32) DEFAULT NULL COMMENT '租户orgid,标识客户在哪个分销商入口消费';
-- Also update llmusage_history if exists
ALTER TABLE llmusage_history DROP COLUMN IF EXISTS cost;
ALTER TABLE llmusage_history ADD COLUMN IF NOT EXISTS tenantid VARCHAR(32) DEFAULT NULL;

View File

@ -1,132 +1,76 @@
#!/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:
llmage_db = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
sage_db = get_module_dbname('sage') user_orgid = await get_userorgid()
db = DBPools()
filters = {} # Extract filter parameters from params_kw
if params_kw.get('userorgid'): filters = {}
filters['userorgid'] = params_kw.get('userorgid') if params_kw.get('userorgid'):
if params_kw.get('llmid'): filters['userorgid'] = params_kw.get('userorgid')
filters['llmid'] = params_kw.get('llmid') if params_kw.get('llmid'):
if params_kw.get('handled') is not None and params_kw.get('handled') != '': filters['llmid'] = params_kw.get('llmid')
filters['handled'] = params_kw.get('handled') if params_kw.get('handled') is not None:
if params_kw.get('start_date'): filters['handled'] = params_kw.get('handled')
filters['start_date'] = params_kw.get('start_date') if params_kw.get('start_date'):
if params_kw.get('end_date'): filters['start_date'] = params_kw.get('start_date')
filters['end_date'] = params_kw.get('end_date') if params_kw.get('end_date'):
filters['end_date'] = params_kw.get('end_date')
try: page = int(params_kw.get('page', 1))
page = int(params_kw.get('page', 1)) page_size = int(params_kw.get('page_size', 50))
except (ValueError, TypeError):
page = 1
if page < 1:
page = 1
page_size = int(params_kw.get('rows', params_kw.get('page_size', 20)))
async with db.sqlorContext(llmage_db) as sor: async with DBPools().sqlorContext(dbname) as sor:
conditions = [] # Build dynamic SQL
ns = {} conditions = []
ns = {}
if filters.get('userorgid'): # Default: show unhandled records
conditions.append("userorgid=${userorgid}$") if 'handled' not in filters:
ns['userorgid'] = filters['userorgid'] conditions.append("handled='0'")
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 = "" if filters.get('userorgid'):
if conditions: conditions.append("userorgid=${userorgid}$")
where = "WHERE " + " AND ".join(conditions) ns['userorgid'] = filters['userorgid']
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']
# 不查 failed_reason大 TEXT只查列表需要的字段 where = ""
sql = f""" if conditions:
SELECT id, llmusageid, llmid, userid, userorgid, use_date, use_time, where = "where " + " and ".join(conditions)
amount, cost, failed_time, retry_count, handled
FROM llmusage_accounting_failed
{where}
ORDER BY failed_time DESC
"""
count_sql = f""" # Count total
SELECT count(*) as cnt count_sql = f"select count(*) as cnt from llmusage_accounting_failed {where}"
FROM llmusage_accounting_failed count_recs = await sor.sqlExe(count_sql, ns)
{where} total = count_recs[0].cnt if count_recs else 0
"""
count_recs = await sor.sqlExe(count_sql, ns)
total = count_recs[0].cnt if count_recs else 0
offset = (page - 1) * page_size # Query with pagination
query_sql = sql + f" LIMIT {page_size} OFFSET {offset}" offset = (page - 1) * page_size
recs = await sor.sqlExe(query_sql, ns) query_sql = f"""select * from llmusage_accounting_failed {where}
order by failed_time desc limit {page_size} offset {offset}"""
recs = await sor.sqlExe(query_sql, ns)
if recs: result['rows'] = [dict(r) for r in (recs or [])]
# 收集所有需要转换的 ID result['total'] = total
llm_ids = list(set(r.llmid for r in recs if r.llmid)) result['page'] = page
user_ids = list(set(r.userid for r in recs if r.userid)) result['page_size'] = page_size
org_ids = list(set(r.userorgid for r in recs if r.userorgid)) result['success'] = True
# 批量查询映射(内存中完成)
llm_map = {}
if llm_ids:
llm_recs = await sor.sqlExe(
"SELECT id, name FROM llm WHERE id IN ${ids}$",
{'ids': tuple(llm_ids)}
)
if llm_recs:
llm_map = {r.id: r.name for r in llm_recs}
user_map = {}
if user_ids:
async with db.sqlorContext(sage_db) as sage_sor:
user_recs = await sage_sor.sqlExe(
"SELECT id, username FROM users WHERE id IN ${ids}$",
{'ids': tuple(user_ids)}
)
if user_recs:
user_map = {r.id: r.username for r in user_recs}
org_map = {}
if org_ids:
async with db.sqlorContext(sage_db) as sage_sor:
org_recs = await sage_sor.sqlExe(
"SELECT id, orgname FROM organization WHERE id IN ${ids}$",
{'ids': tuple(org_ids)}
)
if org_recs:
org_map = {r.id: r.orgname for r in org_recs}
# 转换
handled_map = {'0': '未处理', '1': '已处理'}
rows = []
for r in recs:
d = dict(r)
d['llmid_text'] = llm_map.get(r.llmid, r.llmid)
d['userid_text'] = user_map.get(r.userid, r.userid)
d['userorgid_text'] = org_map.get(r.userorgid, r.userorgid)
d['handled_text'] = handled_map.get(r.handled, r.handled)
rows.append(d)
result['rows'] = rows
result['total'] = total
result['page'] = page
result['page_size'] = page_size
result['success'] = True
else:
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)

View File

@ -1,13 +0,0 @@
#!/usr/bin/env python3
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

@ -1,13 +0,0 @@
#!/usr/bin/env python3
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

@ -1,93 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,36 +0,0 @@
import json
providerid = (params_kw or {}).get('providerid', '')
catelogid = (params_kw or {}).get('catelogid', '')
search = (params_kw or {}).get('search', '').strip()
data = await request._run_ns.get_llms_by_catelog_to_customer(
catelogid=catelogid if catelogid else None,
orderby='a.name'
)
result = []
for cate in data:
for llm in cate['llms']:
if providerid and llm.providerid != providerid:
continue
if search:
sl = search.lower()
n = (llm.name or '').lower()
d = (llm.description or '').lower()
if sl not in n and sl not in d:
continue
result.append({
'id': llm.id,
'name': llm.name,
'model': llm.model,
'description': llm.description or '',
'iconid': llm.iconid,
'providerid': llm.providerid,
'provider_name': getattr(llm, 'provider_name', getattr(llm, 'orgname', '')),
'catelog_id': getattr(llm, 'catelog_id', ''),
'catelogname': getattr(llm, 'catelogname', ''),
'pricing_display': getattr(llm, 'pricing_display', []),
})
return json.dumps({'total': len(result), 'rows': result}, ensure_ascii=False)

View File

@ -1,13 +0,0 @@
#!/usr/bin/env python3
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

@ -1,37 +0,0 @@
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

@ -1,7 +0,0 @@
async with get_sor_context(request._run_ns, 'llmage') as sor:
recs = await sor.sqlExe(
"SELECT id, name FROM llm ORDER BY name", {})
result = [{'value': '', 'text': '全部'}]
for r in recs:
result.append({'value': r.id, 'text': r.name})
return result

View File

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

View File

@ -1,13 +0,0 @@
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

@ -1,16 +0,0 @@
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

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
@ -59,4 +60,4 @@ try:
except Exception as e: except Exception as e:
result['options'] = {'title': 'Error', 'message': f'添加失败: {str(e)}', 'type': 'error'} result['options'] = {'title': 'Error', 'message': f'添加失败: {str(e)}', 'type': 'error'}
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
@ -27,4 +28,4 @@ try:
except Exception as e: except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'} result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'success': False, 'rows': [], 'total': 0} result = {'success': False, 'rows': [], 'total': 0}

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'success': False, 'data': {'llms': [], 'catelogs': [], 'apis': [], 'ppids': []}} result = {'success': False, 'data': {'llms': [], 'catelogs': [], 'apis': [], 'ppids': []}}

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'success': False, 'data': {'llms': [], 'catelogs': []}} result = {'success': False, 'data': {'llms': [], 'catelogs': []}}

View File

@ -1,20 +0,0 @@
#!/usr/bin/env python3
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}}
try:
env = request._run_ns
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()
data['ownerid'] = data.get('ownerid') or await env.get_userorgid()
await sor.C('llm', data)
result = {"widgettype":"Message","options":{"title":"创建成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}}
except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3}
return result

View File

@ -1,21 +0,0 @@
#!/usr/bin/env python3
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}}
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 = {"widgettype":"Message","options":{"title":"删除成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}}
except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3}
return result

View File

@ -1,86 +0,0 @@
llmid = params_kw.get('llmid', '')
action = params_kw.get('action', 'check')
if not llmid:
return json.dumps({'widgettype':'Error','options':{'text':'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 json.dumps({'widgettype':'Error','options':{'text':'模型记录不存在'}}, ensure_ascii=False)
llm = recs[0]
# 检查 API 映射
maps = await sor.sqlExe(
"select * from llm_api_map where llmid=${llmid}$",
{'llmid': llmid})
if not maps:
return json.dumps({'widgettype':'Error','options':{'text':'无 API 映射配置'}}, ensure_ascii=False)
# 检查 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 json.dumps({'widgettype':'Error','options':{'text':'uapi 配置不完整,无法调用'}}, ensure_ascii=False)
uapi = uapi_recs[0]
# 检查 ioid
io_recs = await sor.sqlExe(
"select * from uapiio where id=${ioid}$", {'ioid': uapi.ioid})
if not io_recs:
return json.dumps({'widgettype':'Error','options':{'text':'IO 定义不存在'}}, ensure_ascii=False)
return json.dumps({'widgettype':'Message','options':{'text':f'推理配置验证通过\n模型: {llm.name}\nAPI: {uapi.api_name}\nIO: {uapi.ioid}\nStream: {uapi.stream}'}}, ensure_ascii=False)
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 json.dumps({'widgettype':'Error','options':{'text':'无 API 映射'}}, ensure_ascii=False)
ppids = [m.ppid for m in maps if m.ppid]
if not ppids:
return json.dumps({'widgettype':'Error','options':{'text':'无定价项目(ppid)'}}, ensure_ascii=False)
# 实际调用定价计算
try:
prices = await llm_query_price(llmid, usages)
if not prices:
return json.dumps({'widgettype':'Error','options':{'text':'定价计算返回空'}}, ensure_ascii=False)
lines = []
total = 0
for p in prices:
amount = getattr(p, 'amount', 0) or 0
name = getattr(p, 'name', '') or getattr(p, 'timing_name', '') or getattr(p, 'item', '') or ''
# 显示所有可用字段
fields = [f' {k}={getattr(p,k,"")}' for k in dir(p) if not k.startswith('_') and k not in ('jinja_pass_arg',)]
total += amount
lines.append(f'{name}: ¥{amount}' + ('\n' + '\n'.join(fields) if fields else ''))
lines.append(f'─────────────────')
lines.append(f'合计: ¥{total}')
return json.dumps({'widgettype':'Message','options':{'text':f'计费测试通过\n' + chr(10).join(lines)}}, ensure_ascii=False)
except Exception as e:
return json.dumps({'widgettype':'Error','options':{'text':f'计费测试失败: {e}'}}, ensure_ascii=False)
return json.dumps({'widgettype':'Error','options':{'text':'无效的操作'}}, ensure_ascii=False)

View File

@ -1,39 +0,0 @@
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 '下架成功'
if action == 'published':
# 同步必须放在事务块外:块内未提交时另一连接读到旧状态
env = request._run_ns
sync_fn = getattr(env, 'sync_llm_product', None)
if sync_fn:
try:
sync_result = await sync_fn(record_id)
except Exception as sync_e:
sync_result = {'success': False, 'error': str(sync_e)}
if not sync_result:
result['message'] += ',已同步为产品'
elif sync_result.get('success'):
result['message'] += ',已同步为产品'
else:
result['message'] += ',但产品同步失败: ' + str(sync_result.get('error', '未知错误'))
except Exception as e:
result['message'] = str(e)
return {
"widgettype": "Text",
"options": {
"otext": result['message'],
"i18n": True
}
}

View File

@ -1,19 +0,0 @@
ns = params_kw.copy()
data = params_kw.copy()
data.pop('page', None)
data.pop('rows', None)
data.pop('data_filter', None)
if not data.get('id'):
return {'widgettype':'Error','options':{'title':'Update Error','message':'缺少id'}}
try:
db = DBPools()
dbname = get_module_dbname('llmage')
async with db.sqlorContext(dbname) as sor:
await sor.U('llm', data)
return {'widgettype':'Message','options':{'title':'Update Success','message':'ok'}}
except Exception as e:
return {'widgettype':'Error','options':{'title':'Update Error','message':str(e)}}

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
@ -25,4 +26,4 @@ try:
except Exception as e: except Exception as e:
result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'} result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'}
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
@ -29,4 +30,4 @@ try:
except Exception as e: except Exception as e:
result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'} result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'}
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'success': False, 'rows': [], 'total': 0} result = {'success': False, 'rows': [], 'total': 0}

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}} result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid', 'type': 'error'}}
@ -27,4 +28,4 @@ try:
except Exception as e: except Exception as e:
result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'} result['options'] = {'title': '错误', 'message': str(e), 'type': 'error'}
return result return json.dumps(result, ensure_ascii=False)

View File

@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
from appPublic.uniqueID import getID
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -8,8 +10,9 @@ try:
data = params_kw data = params_kw
data['id'] = getID() data['id'] = getID()
await sor.C('llmusage_accounting_failed', data) await sor.C('llmusage_accounting_failed', data)
result = {"widgettype":"Message","options":{"title":"创建成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '创建成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -10,8 +11,9 @@ try:
result['message'] = '缺少id参数' result['message'] = '缺少id参数'
else: else:
await sor.D('llmusage_accounting_failed', {'id': rid}) await sor.D('llmusage_accounting_failed', {'id': rid})
result = {"widgettype":"Message","options":{"title":"删除成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '删除成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
from datetime import datetime
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -15,8 +17,9 @@ try:
if data.get('handled') == '1' and not data.get('handled_time'): if data.get('handled') == '1' and not data.get('handled_time'):
data['handled_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') data['handled_time'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await sor.U('llmusage_accounting_failed', data) await sor.U('llmusage_accounting_failed', data)
result = {"widgettype":"Message","options":{"title":"更新成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '更新成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
from appPublic.uniqueID import getID
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -8,8 +10,9 @@ try:
data = params_kw data = params_kw
data['id'] = getID() data['id'] = getID()
await sor.C('llmusage', data) await sor.C('llmusage', data)
result = {"widgettype":"Message","options":{"title":"创建成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '创建成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -10,8 +11,9 @@ try:
result['message'] = '缺少id参数' result['message'] = '缺少id参数'
else: else:
await sor.D('llmusage', {'id': rid}) await sor.D('llmusage', {'id': rid})
result = {"widgettype":"Message","options":{"title":"删除成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '删除成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,2 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
return {"widgettype":"Error","options":{"title":"操作失败","message":"历史数据为只读,不可新增","cwidth":16,"cheight":9,"timeout":3}} import json
result = {'success': False, 'message': '历史数据为只读,不可新增'}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,2 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
return {"widgettype":"Error","options":{"title":"操作失败","message":"历史数据为只读,不可删除","cwidth":16,"cheight":9,"timeout":3}} import json
result = {'success': False, 'message': '历史数据为只读,不可删除'}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,2 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
return {"widgettype":"Error","options":{"title":"操作失败","message":"历史数据为只读,不可修改","cwidth":16,"cheight":9,"timeout":3}} import json
result = {'success': False, 'message': '历史数据为只读,不可修改'}
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,83 +0,0 @@
result = {'success': False, 'rows': [], 'total': 0, 'page': 1, 'page_size': 50}
try:
# 参数解析
try:
page = int(params_kw.get('page', 1))
except (ValueError, TypeError):
page = 1
if page < 1:
page = 1
rows_per_page = int(params_kw.get('rows', params_kw.get('pagerows', 50)))
offset = (page - 1) * rows_per_page
sort_field = params_kw.get('sort', 'use_time desc')
# 构建 WHERE 条件(只支持常用筛选字段,手动拼接避免框架开销)
conditions = ['1=1']
ns = {}
llmid = params_kw.get('llmid')
if llmid:
conditions.append('llmid=${llmid}$')
ns['llmid'] = llmid
status = params_kw.get('status')
if status:
conditions.append('status=${status}$')
ns['status'] = status
accounting_status = params_kw.get('accounting_status')
if accounting_status:
conditions.append('accounting_status=${accounting_status}$')
ns['accounting_status'] = accounting_status
userid = params_kw.get('userid')
if userid:
conditions.append('userid=${userid}$')
ns['userid'] = userid
userorgid = params_kw.get('userorgid')
if userorgid:
conditions.append('userorgid=${userorgid}$')
ns['userorgid'] = userorgid
start_date = params_kw.get('start_date')
if start_date:
conditions.append('use_date>=${start_date}$')
ns['start_date'] = start_date
end_date = params_kw.get('end_date')
if end_date:
conditions.append('use_date<=${end_date}$')
ns['end_date'] = end_date
where = 'WHERE ' + ' AND '.join(conditions)
# 列表字段(不含 usages/ioinfo 大 TEXT
select_fields = 'id, llmid, use_date, use_time, userid, transno, responsed_seconds, finish_seconds, status, taskid, amount, cost, userorgid, ownerid, accounting_status'
db = DBPools()
dbname = get_module_dbname('llmage')
async with db.sqlorContext(dbname) as sor:
# count
count_recs = await sor.sqlExe(f'SELECT count(*) as cnt FROM llmusage {where}', ns)
total = count_recs[0].cnt if count_recs else 0
# data
rows = await sor.sqlExe(
f'SELECT {select_fields} FROM llmusage {where} ORDER BY {sort_field} LIMIT {rows_per_page} OFFSET {offset}',
ns
)
result['success'] = True
result['total'] = total
result['rows'] = rows if rows else []
result['page'] = page
result['page_size'] = rows_per_page
except Exception as e:
debug(f'llmusage_list error: {format_exc()}')
result['error'] = str(e)
return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
result = {"widgettype":"Error","options":{"title":"操作失败","message":"Invalid request","cwidth":16,"cheight":9,"timeout":3}} result = {'success': False, 'message': ''}
try: try:
dbname = get_module_dbname('llmage') dbname = get_module_dbname('llmage')
@ -12,8 +13,9 @@ try:
else: else:
data['id'] = rid data['id'] = rid
await sor.U('llmusage', data) await sor.U('llmusage', data)
result = {"widgettype":"Message","options":{"title":"更新成功","message":"ok","cwidth":16,"cheight":9,"timeout":3}} result['success'] = True
result['message'] = '更新成功'
except Exception as e: except Exception as e:
result["options"] = {"title":"操作失败","message":str(e),"cwidth":16,"cheight":9,"timeout":3} result['message'] = str(e)
return result return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,8 +0,0 @@
#!/usr/bin/env python3
import json
from llmage.utils import get_llmcatelogs
data = await get_llmcatelogs()
rows = [{'id': r.id, 'name': r.name} for r in (data or [])]
return json.dumps({'rows': rows}, ensure_ascii=False)

View File

@ -1,8 +0,0 @@
#!/usr/bin/env python3
import json
from llmage.utils import get_llmproviders
data = await get_llmproviders()
rows = [{'id': p.providerid, 'name': p.orgname} for p in (data or [])]
return json.dumps({'rows': rows}, ensure_ascii=False)

View File

@ -1,3 +1,7 @@
#!/usr/bin/env python3
import json
from datetime import datetime
result = {'success': False, 'message': ''} result = {'success': False, 'message': ''}
try: try:
@ -7,12 +11,14 @@ 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'
}) })
now = curDateString() + ' ' + timestampstr().split(' ')[1] if ' ' not in curDateString() else curDateString() # 2. 更新失败记录:标记已处理,增加重试次数
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',
@ -21,10 +27,10 @@ try:
handled_note = CONCAT(IFNULL(handled_note, ''), '[', ${now}$, '] 触发重试; ') handled_note = CONCAT(IFNULL(handled_note, ''), '[', ${now}$, '] 触发重试; ')
WHERE llmusageid = ${luid}$ WHERE llmusageid = ${luid}$
""", {'luid': luid, 'now': now}) """, {'luid': luid, 'now': now})
result['success'] = True result['success'] = True
result['message'] = '已重置为待记账状态,后台循环将重新处理' result['message'] = '已重置为待记账状态,后台循环将重新处理'
except Exception as e: except Exception as e:
result['message'] = str(e) result['message'] = str(e)
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False, default=str)

View File

@ -1,81 +0,0 @@
record_id = params_kw.get('id', '')
llm_info = {}
reason = '未找到记录'
if record_id:
dbname = get_module_dbname('llmage')
sage_db = get_module_dbname('sage')
async with DBPools().sqlorContext(dbname) as sor:
# 查询失败记录 + JOIN llm表获取模型详情
sql = f"""
SELECT f.*, l.name as llm_name, l.model as llm_model, l.upappid as llm_ppid,
u.username as userid_text, o.orgname as userorgid_text
FROM llmusage_accounting_failed f
LEFT JOIN llm l ON f.llmid = l.id
LEFT JOIN {sage_db}.users u ON f.userid = u.id
LEFT JOIN {sage_db}.organization o ON f.userorgid = o.id
WHERE f.id = ${{record_id}}$
"""
rows = await sor.sqlExe(sql, {'record_id': record_id})
if rows:
rec = dict(rows[0])
llm_info = {
'llmid': rec.get('llmid', ''),
'llm_name': rec.get('llm_name', ''),
'llm_model': rec.get('llm_model', ''),
'llm_ppid': rec.get('llm_ppid', ''),
'userid': rec.get('userid', ''),
'username': rec.get('userid_text', ''),
'orgid': rec.get('userorgid', ''),
'orgname': rec.get('userorgid_text', ''),
'use_time': str(rec.get('use_time', '')),
'amount': str(rec.get('amount', '')),
'failed_time': str(rec.get('failed_time', '')),
'retry_count': str(rec.get('retry_count', '')),
}
reason = rec.get('failed_reason', '') or '(空)'
fields = [
{'label': '模型ID (llmID)', 'value': llm_info.get('llmid', '')},
{'label': '模型名称', 'value': llm_info.get('llm_name', '')},
{'label': '模型标识 (model)', 'value': llm_info.get('llm_model', '')},
{'label': '上位系统 (ppid)', 'value': llm_info.get('llm_ppid', '')},
{'label': '用户', 'value': f"{llm_info.get('username', '')} ({llm_info.get('userid', '')})"},
{'label': '机构', 'value': f"{llm_info.get('orgname', '')} ({llm_info.get('orgid', '')})"},
{'label': '使用时间', 'value': llm_info.get('use_time', '')},
{'label': '金额', 'value': llm_info.get('amount', '')},
{'label': '失败时间', 'value': llm_info.get('failed_time', '')},
{'label': '重试次数', 'value': llm_info.get('retry_count', '')},
]
field_widgets = []
for f in fields:
if f['value']:
field_widgets.append({
"widgettype": "HBox",
"options": {"padding": "4px 0"},
"subwidgets": [
{"widgettype": "Text", "options": {"text": f['label'] + "", "cwidth": 12, "i18n": False, "css": "field-label"}},
{"widgettype": "Text", "options": {"text": f['value'], "cwidth": 18, "i18n": False}}
]
})
field_widgets.append({
"widgettype": "Text",
"options": {"text": "───── 失败原因 ─────", "cwidth": 30, "i18n": False, "padding": "12px 0 4px 0"}
})
field_widgets.append({
"widgettype": "Text",
"options": {"text": reason, "i18n": False}
})
return json.dumps({
"widgettype": "VScrollPanel",
"options": {
"width": "100%",
"height": "100%",
"css": "card",
"padding": "12px"
},
"subwidgets": field_widgets
}, ensure_ascii=False)

View File

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

View File

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

View File

@ -1,41 +0,0 @@
{
"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

@ -1,31 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,22 +0,0 @@
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

@ -1,39 +0,0 @@
from pricing.pricing import get_pricing_display
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:
result = await get_pricing_display(ppid)
if not result:
text = "❌ 定价数据: 无当前生效的定价记录"
elif result.get('display_text'):
lines = [f"✅ 定价项目: {result.get('name', ppid)}"]
if result.get('pricing_type'):
lines.append(f" 类型: {result['pricing_type']}")
lines.append("")
lines.append(result['display_text'])
text = '\n'.join(lines)
else:
text = f"✅ 定价项目: {result.get('name', ppid)} (无定价明细)"
except Exception as e:
text = f"❌ 定价数据: {e}"
return json.dumps({
"widgettype": "Text",
"options": {"text": text, "i18n": False}
}, ensure_ascii=False)

View File

@ -1,33 +0,0 @@
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)

View File

@ -1,30 +0,0 @@
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)

View File

@ -1,33 +0,0 @@
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)

View File

@ -1,27 +0,0 @@
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

@ -1,188 +1,136 @@
{ {
"widgettype": "Tabular", "widgettype": "VBox",
"id": "failed_table",
"options": { "options": {
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
"css": "card", "padding": "16px",
"title": "记账失败记录", "spacing": 12
"toolbar": { },
"tools": [ "subwidgets": [
{ {
"name": "filter", "widgettype": "Title2",
"label": "搜索" "options": {
}, "text": "记账失败记录",
{ "halign": "left"
"name": "show_reason", }
"label": "原因",
"selected_row": true
},
{
"name": "retry_accounting",
"label": "重试记账",
"selected_row": true
}
]
}, },
"search_form": { {
"fields": [ "widgettype": "HBox",
"options": {
"width": "100%",
"spacing": 12,
"alignItems": "flex-end"
},
"subwidgets": [
{ {
"name": "start_date", "widgettype": "VBox",
"label": "开始日期", "options": {"spacing": 4},
"uitype": "date", "subwidgets": [
"cwidth": 10 {"widgettype": "Text", "options": {"text": "开始日期", "fontSize": "12px"}},
{"widgettype": "UiDate", "id": "start_date", "options": {"width": "150px"}}
]
}, },
{ {
"name": "end_date", "widgettype": "VBox",
"label": "结束日期", "options": {"spacing": 4},
"uitype": "date", "subwidgets": [
"cwidth": 10 {"widgettype": "Text", "options": {"text": "结束日期", "fontSize": "12px"}},
{"widgettype": "UiDate", "id": "end_date", "options": {"width": "150px"}}
]
}, },
{ {
"name": "handled", "widgettype": "VBox",
"label": "处理状态", "options": {"spacing": 4},
"uitype": "code", "subwidgets": [
"cwidth": 8, {"widgettype": "Text", "options": {"text": "处理状态", "fontSize": "12px"}},
"data": [
{ {
"value": "", "widgettype": "Combobox",
"text": "全部" "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"}},
{ {
"value": "0", "widgettype": "Button",
"text": "未处理" "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"}},
{ {
"value": "1", "widgettype": "Button",
"text": "已处理" "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); });"
}]
} }
] ]
} }
] ]
}, },
"data_url": "{{entire_url('/llmage/api/failed_accounting_list.dspy')}}",
"data_method": "GET",
"page_rows": 20,
"row_options": {
"browserfields": {
"exclouded": [
"id",
"failed_reason"
],
"alters": {}
},
"fields": [
{
"name": "llmusageid",
"title": "使用记录ID",
"type": "str",
"length": 32,
"cwidth": 12,
"uitype": "str",
"label": "使用记录ID"
},
{
"name": "llmid_text",
"title": "模型",
"type": "str",
"length": 32,
"cwidth": 12,
"uitype": "str",
"label": "模型"
},
{
"name": "userid_text",
"title": "用户",
"type": "str",
"length": 32,
"cwidth": 10,
"uitype": "str",
"label": "用户"
},
{
"name": "userorgid_text",
"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_text",
"title": "状态",
"type": "str",
"length": 1,
"cwidth": 6,
"uitype": "str",
"label": "状态"
}
]
}
},
"binds": [
{ {
"wid": "self", "widgettype": "DataViewer",
"event": "show_reason", "id": "failed_table",
"actiontype": "urlwidget",
"target": "PopupWindow",
"popup_options": {
"title": "失败原因",
"cwidth": 30,
"cheight": 20
},
"options": { "options": {
"url": "{{entire_url('/llmage/api/show_failed_reason.dspy')}}?id=${id}$" "url": "{{entire_url('/llmage/api/failed_accounting_list.dspy')}}",
"title": "失败记录列表",
"pageSize": 20,
"fields": [
{"name": "id", "title": "ID", "hidden": true},
{"name": "llmusageid", "title": "使用记录ID", "width": "120px"},
{"name": "llmid", "title": "模型ID", "width": "120px"},
{"name": "userid", "title": "用户ID", "width": "120px"},
{"name": "userorgid", "title": "机构ID", "width": "120px"},
{"name": "use_date", "title": "使用日期", "width": "110px"},
{"name": "use_time", "title": "使用时间", "width": "160px"},
{"name": "amount", "title": "金额", "width": "80px"},
{"name": "cost", "title": "成本", "width": "80px"},
{"name": "failed_reason", "title": "失败原因", "width": "30%"},
{"name": "failed_time", "title": "失败时间", "width": "160px"},
{"name": "retry_count", "title": "重试次数", "width": "80px"},
{"name": "handled", "title": "状态", "width": "80px",
"formatter": "function(v){return v==='1'?'已处理':'未处理';}"}
]
} }
},
{
"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; } try { var resp = await fetch('{{entire_url('/llmage/api/retry_accounting.dspy')}}?id=' + encodeURIComponent(row.llmusageid), { credentials: 'include' }); var d = await resp.json(); if(d.success) { alert(d.message); await dv.render({}); } else { alert('失败: ' + d.message); } } catch(e) { alert('请求失败: ' + e.message); }"
} }
] ]
} }

View File

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

View File

@ -17,7 +17,9 @@
{ {
"widgettype": "Title2", "widgettype": "Title2",
"options": { "options": {
"text": "LLM 模型管理" "text": "LLM 模型管理",
"color": "#F1F5F9",
"fontWeight": "700"
} }
}, },
{ {
@ -26,176 +28,215 @@
{ {
"widgettype": "Text", "widgettype": "Text",
"options": { "options": {
"text": "模型类型、模型配置与记账失败记录", "text": "模型配置、目录分类与调用监控",
"cfontsize": 1.2 "fontSize": "14px",
"color": "#64748B"
} }
} }
] ]
}, },
{ {
"widgettype": "VBox", "widgettype": "ResponsableBox",
"options": { "options": {
"css": "filler", "gap": "16px",
"spacing": 16 "minWidth": "200px",
"marginBottom": "24px"
}, },
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "ResponsableBox", "widgettype": "urlwidget",
"options": { "options": {
"gap": "16px", "url": "{{entire_url('/llmage/stat_total_models.ui')}}"
"minWidth": "250px" }
},
{
"widgettype": "urlwidget",
"options": {
"url": "{{entire_url('/llmage/stat_today_calls.ui')}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "{{entire_url('/llmage/stat_today_amount.ui')}}"
}
},
{
"widgettype": "urlwidget",
"options": {
"url": "{{entire_url('/llmage/stat_catelog_count.ui')}}"
}
}
]
},
{
"widgettype": "ResponsableBox",
"options": {
"gap": "16px",
"minWidth": "250px",
"marginBottom": "24px"
},
"subwidgets": [
{
"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/llmcatelog_list.ui')}}"
},
"mode": "replace"
}
],
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "VBox", "widgettype": "Svg",
"options": { "options": {
"css": "card", "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>",
"cwidth": 23, "width": "36px",
"padding": "16px", "height": "36px",
"cursor": "pointer", "marginBottom": "16px"
"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": "VBox", "widgettype": "Title4",
"options": { "options": {
"css": "card", "text": "模型类型管理",
"cwidth": 23, "color": "#F1F5F9",
"padding": "16px", "fontWeight": "600",
"cursor": "pointer", "marginBottom": "8px"
"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": "VBox", "widgettype": "Text",
"options": { "options": {
"css": "card", "text": "管理模型的分类目录和类型定义",
"cwidth": 23, "fontSize": "14px",
"padding": "16px", "color": "#94A3B8"
"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": "VScrollPanel", "widgettype": "VBox",
"id": "llmage_content",
"options": { "options": {
"css": "filler", "bgcolor": "#1E293B",
"width": "100%", "padding": "24px",
"height": "100%" "borderRadius": "12px",
} "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"
}
} }
] ]
} }

Some files were not shown because too many files have changed in this diff Show More