From 585533c38d100192e627264cda54f8484e1961d3 Mon Sep 17 00:00:00 2001 From: liuwenbo <840582614@qq.com> Date: Wed, 22 Apr 2026 00:36:45 +0800 Subject: [PATCH 1/2] chore: update service path and bun lockfile --- new-api.service | 4 ++-- web/bun.lock | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/new-api.service b/new-api.service index 5a29336153f4..e6519c86175d 100644 --- a/new-api.service +++ b/new-api.service @@ -9,8 +9,8 @@ After=network.target [Service] User=ubuntu # 注意修改用户名 -WorkingDirectory=/path/to/new-api # 注意修改路径 -ExecStart=/path/to/new-api/new-api --port 3000 --log-dir /path/to/new-api/logs # 注意修改路径和端口号 +WorkingDirectory=/Users/liuwenbo/code/new-api # 注意修改路径 +ExecStart=/Users/liuwenbo/code/new-api/new-api --port 3000 --log-dir /path/to/new-api/logs # 注意修改路径和端口号 Restart=always RestartSec=5 diff --git a/web/bun.lock b/web/bun.lock index e3b293cb12a6..9a8419226a6e 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "react-template", @@ -10,7 +11,7 @@ "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", - "axios": "1.12.0", + "axios": "1.13.5", "clsx": "^2.1.1", "dayjs": "^1.11.11", "history": "^5.3.0", @@ -776,7 +777,7 @@ "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], - "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="], + "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], @@ -1104,13 +1105,13 @@ "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], From 754217775e082617de36355766a9bddc9e6f7a69 Mon Sep 17 00:00:00 2001 From: lwb8832652 <38393274+lwb8832652@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:31:04 +0800 Subject: [PATCH 2/2] feat: add RunningHub account API endpoints --- common/api_type.go | 2 + constant/channel.go | 3 + controller/channel.go | 279 +++++++++++++++++++++ docs/runninghub/INTERFACE_CATALOG.md | 114 +++++++++ relay/channel/task/runninghub/adaptor.go | 296 +++++++++++++++++++++++ relay/common/relay_info.go | 1 + relay/relay_adaptor.go | 3 + router/api-router.go | 6 + web/src/constants/channel.constants.js | 7 +- web/src/helpers/render.jsx | 1 + 10 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 docs/runninghub/INTERFACE_CATALOG.md create mode 100644 relay/channel/task/runninghub/adaptor.go diff --git a/common/api_type.go b/common/api_type.go index 39c1fe9a5406..0458c1661fc8 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeReplicate case constant.ChannelTypeCodex: apiType = constant.APITypeCodex + case constant.ChannelTypeRunningHub: + apiType = constant.APITypeOpenAI } if apiType == -1 { return constant.APITypeOpenAI, false diff --git a/constant/channel.go b/constant/channel.go index 48502bedc52c..9ad4b4db3ed8 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeRunningHub = 58 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "https://www.runninghub.cn", //58 } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", + ChannelTypeRunningHub: "RunningHub", } func GetChannelTypeName(channelType int) string { diff --git a/controller/channel.go b/controller/channel.go index b0dd22861507..fc9d5d0340db 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -1,10 +1,13 @@ package controller import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" + "net/url" "strconv" "strings" "time" @@ -1033,6 +1036,22 @@ func FetchModels(c *gin.Context) { return } + if req.Type == constant.ChannelTypeRunningHub { + models, err := fetchRunningHubModels(baseURL, key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("获取RunningHub模型失败: %s", err.Error()), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": models, + }) + return + } + client := &http.Client{} url := fmt.Sprintf("%s/v1/models", baseURL) @@ -1090,6 +1109,266 @@ func FetchModels(c *gin.Context) { }) } +func fetchRunningHubModels(baseURL, key string) ([]string, error) { + payload, err := common.Marshal(map[string]any{}) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/openapi/v2/resource/list", strings.TrimRight(baseURL, "/")) + request, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+key) + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", response.StatusCode) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Records []struct { + ResourceName string `json:"resourceName"` + } `json:"records"` + } `json:"data"` + } + if err = common.DecodeJson(response.Body, &result); err != nil { + return nil, err + } + if result.Code != 0 { + return nil, fmt.Errorf("runninghub error code=%d msg=%s", result.Code, result.Msg) + } + models := make([]string, 0, len(result.Data.Records)) + for _, record := range result.Data.Records { + name := strings.TrimSpace(record.ResourceName) + if name == "" { + continue + } + models = append(models, name) + } + return models, nil +} + +func RunningHubRunAIAppTask(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + WebAppID int64 `json:"webapp_id"` + APIKey string `json:"api_key"` + NodeInfoList []any `json:"node_info_list"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + if req.WebAppID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "webapp_id is required"}) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" { + apiKey = key + } + payload := map[string]any{ + "webappId": req.WebAppID, + "apiKey": apiKey, + } + if len(req.NodeInfoList) > 0 { + payload["nodeInfoList"] = req.NodeInfoList + } + raw, err := common.Marshal(payload) + if err != nil { + common.ApiError(c, err) + return + } + respBody, err := doRunningHubRequest(http.MethodPost, baseURL+"/task/openapi/ai-app/run", key, bytes.NewReader(raw)) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func RunningHubGetAIAppCallDemo(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + WebAppID int64 `json:"webapp_id"` + APIKey string `json:"api_key"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + if req.WebAppID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "webapp_id is required"}) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" { + apiKey = key + } + query := url.Values{} + query.Set("apiKey", apiKey) + query.Set("webappId", strconv.FormatInt(req.WebAppID, 10)) + reqURL := fmt.Sprintf("%s/api/webapp/apiCallDemo?%s", baseURL, query.Encode()) + respBody, err := doRunningHubRequest(http.MethodGet, reqURL, key, nil) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func RunningHubGetPublicModelList(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + Filter map[string]any `json:"filter"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + if req.Filter == nil { + req.Filter = map[string]any{} + } + raw, err := common.Marshal(req.Filter) + if err != nil { + common.ApiError(c, err) + return + } + respBody, err := doRunningHubRequest(http.MethodPost, baseURL+"/openapi/v2/resource/list", key, bytes.NewReader(raw)) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func RunningHubGetAccountInfo(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + APIKey string `json:"api_key"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" { + apiKey = key + } + raw, err := common.Marshal(map[string]string{ + "apikey": apiKey, + }) + if err != nil { + common.ApiError(c, err) + return + } + respBody, err := doRunningHubRequest(http.MethodPost, baseURL+"/uc/openapi/accountStatus", key, bytes.NewReader(raw)) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func RunningHubListAPIKeys(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + respBody, err := doRunningHubRequest(http.MethodGet, baseURL+"/openapi/v2/api-key/list", key, nil) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func RunningHubGetAPIKeyQueueStatus(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Key string `json:"key"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + baseURL := strings.TrimRight(req.BaseURL, "/") + if baseURL == "" { + baseURL = strings.TrimRight(constant.ChannelBaseURLs[constant.ChannelTypeRunningHub], "/") + } + key := strings.Split(strings.TrimSpace(req.Key), "\n")[0] + respBody, err := doRunningHubRequest(http.MethodGet, baseURL+"/openapi/v2/queue/status", key, nil) + if err != nil { + common.ApiError(c, err) + return + } + c.Data(http.StatusOK, "application/json", respBody) +} + +func doRunningHubRequest(method, reqURL, key string, body io.Reader) ([]byte, error) { + req, err := http.NewRequest(method, reqURL, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("runninghub request failed, status=%d, body=%s", resp.StatusCode, string(respBody)) + } + return respBody, nil +} + func BatchSetChannelTag(c *gin.Context) { channelBatch := ChannelBatch{} err := c.ShouldBindJSON(&channelBatch) diff --git a/docs/runninghub/INTERFACE_CATALOG.md b/docs/runninghub/INTERFACE_CATALOG.md new file mode 100644 index 000000000000..f1abaf9a8711 --- /dev/null +++ b/docs/runninghub/INTERFACE_CATALOG.md @@ -0,0 +1,114 @@ +# RunningHub 接口目录(知识文档) + +> 来源:`https://www.runninghub.cn/runninghub-api-doc-cn/llms.txt` 对应文档目录(标准模型 API、应用 API、账号与资源 API)。 +> 本目录用于 new-api 对接时的“接口地图 + 网关映射规则”。 + +## 1. 已在网关完成的对接能力 + +### 1.1 标准模型提交(统一) + +- **网关入口**:`POST /v1/video/generations`(或现有 task 提交入口) +- **RunningHub 上游**:`POST /openapi/v2/{resource-path}` +- **映射方式**: + - 默认取渠道模型映射后的 `upstream_model_name` 作为 `{resource-path}` + - 若 `{resource-path}` 未带 `openapi/` 前缀,网关自动补全为 `openapi/v2/{resource-path}` + - 请求体优先透传 `metadata`,并补充 `prompt/image/images/size/duration/seconds/input_reference` + +### 1.2 查询任务结果(统一) + +- **网关入口**:`GET /v1/video/generations/:task_id` 或 `GET /v1/videos/:task_id` +- **RunningHub 上游**:`POST /openapi/v2/query` +- **查询体**:`{"taskId":"..."}`(由网关把内部 task_id 映射为上游 taskId) +- **状态映射**: + - `PENDING` -> `submitted` + - `RUNNING` -> `in_progress` + - `SUCCESS` -> `success` + - `FAIL/FAILED/ERROR` -> `failure` + +### 1.3 公共模型列表 + +- **网关入口**:渠道管理 `FetchModels` +- **RunningHub 上游**:`POST /openapi/v2/resource/list` +- **返回映射**:读取 `data.records[].resourceName` 作为可选模型名称 + +### 1.4 AI 应用三接口(已联调入口) + +- **发起 AI 应用任务** + - 网关:`POST /api/channel/runninghub/ai_app/run` + - 上游:`POST /task/openapi/ai-app/run` + - 入参:`webapp_id`、`node_info_list`(与文档 `nodeInfoList` 对应),可选 `api_key` + +- **获取 AI 应用 API 调用示例** + - 网关:`POST /api/channel/runninghub/ai_app/demo` + - 上游:`GET /api/webapp/apiCallDemo?apiKey&webappId` + - 入参:`webapp_id`,可选 `api_key` + +- **获取公共模型列表(原始响应)** + - 网关:`POST /api/channel/runninghub/public_models` + - 上游:`POST /openapi/v2/resource/list` + - 入参:可选 `filter`(透传查询条件) + +### 1.5 账户相关三接口(已联调入口) + +- **获取账户信息** + - 网关:`POST /api/channel/runninghub/account/info` + - 上游:`POST /uc/openapi/accountStatus` + - 入参:可选 `api_key`(不传则默认使用渠道 key) + +- **查询 APIKEY 列表** + - 网关:`POST /api/channel/runninghub/account/api_keys` + - 上游:`GET /openapi/v2/api-key/list` + +- **查询指定 APIKEY 下队列状态** + - 网关:`POST /api/channel/runninghub/account/queue_status` + - 上游:`GET /openapi/v2/queue/status` + +--- + +## 2. RunningHub 文档接口分组目录(按功能) + +> 下面是对 llms 文档分组的“对接目录”。对于“标准模型 API”中的子接口,网关通过统一规则(`/openapi/v2/{resource-path}`)已覆盖,无需为每个子接口单独写一套 adaptor。 + +### 2.1 标准模型 API(已通过统一路径规则覆盖) + +- 文生图 / 图生图 / 图像编辑 +- 文生视频 / 图生视频 / 首尾帧视频 +- 声音/音频相关模型 +- 其他标准资源模型(以 `resourceName` 为准) + +> 使用方法:在渠道模型映射中把模型名映射为 RunningHub 文档中的资源路径(如 `xxx/xxx`),网关会自动拼接到 `/openapi/v2/...`。 + +### 2.2 通用任务接口(已对接) + +- 查询任务生成结果(V2):`/openapi/v2/query` +- 公共资源列表:`/openapi/v2/resource/list` + +### 2.3 AI 应用 API(目录层面已整理,按需走扩展映射) + +- 获取公共模型列表 +- 获取 AI 应用详情 +- 提交 AI 应用任务 +- 查询 AI 应用任务结果 + +### 2.4 账号/资源相关 API(目录层面已整理,按需走扩展映射) + +- 上传文件 +- 余额/套餐/用量相关 +- 回调通知与任务管理相关接口 + +--- + +## 3. 网关配置建议(对接 RunningHub 子接口) + +1. 新建渠道类型:`RunningHub`,`Base URL = https://www.runninghub.cn` +2. 在模型映射里把业务模型映射为 RunningHub 资源路径,例如: + - `my-t2i-model -> youchuan/text-to-image-v61` + - `my-video-model -> minimax/video-01` +3. 请求扩展字段全部放到 `metadata` 中(会透传到 RunningHub 上游请求体) +4. 通过统一任务查询接口轮询任务状态 + +--- + +## 4. 后续扩展位(已预留) + +- 若你需要 **AI 应用 API / 上传 API / 回调管理 API** 在网关层有“独立入口路由”,可继续在 `controller` 中新增 RunningHub 专用路由,复用当前 adaptor 的 URL 拼接与鉴权逻辑。 diff --git a/relay/channel/task/runninghub/adaptor.go b/relay/channel/task/runninghub/adaptor.go new file mode 100644 index 000000000000..47fe62ab8616 --- /dev/null +++ b/relay/channel/task/runninghub/adaptor.go @@ -0,0 +1,296 @@ +package runninghub + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" + taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "github.com/tidwall/sjson" +) + +type TaskAdaptor struct { + taskcommon.BaseBilling + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.baseURL = strings.TrimRight(info.ChannelBaseUrl, "/") + a.apiKey = info.ApiKey +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if taskErr := relaycommon.ValidateMultipartDirect(c, info); taskErr != nil { + return taskErr + } + taskReq, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapper(errors.Wrap(err, "get_task_request_failed"), "invalid_request", http.StatusBadRequest) + } + if taskReq.Metadata != nil { + if apiPath, ok := taskReq.Metadata["api_path"].(string); ok && strings.TrimSpace(apiPath) != "" { + info.UpstreamModelName = strings.TrimSpace(apiPath) + } + } + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + path := strings.TrimSpace(info.UpstreamModelName) + if path == "" { + return "", errors.New("runninghub model path is required") + } + return buildRunningHubURL(a.baseURL, path), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Authorization", "Bearer "+a.apiKey) + contentType := c.Request.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + req.Header.Set("Content-Type", contentType) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + taskReq, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, errors.Wrap(err, "get_task_request_failed") + } + + body := make(map[string]any) + for k, v := range taskReq.Metadata { + if isInternalRuntimeKey(k) { + continue + } + body[k] = v + } + if taskReq.Prompt != "" { + body["prompt"] = taskReq.Prompt + } + if taskReq.Image != "" { + body["image"] = taskReq.Image + } + if len(taskReq.Images) > 0 { + body["images"] = taskReq.Images + } + if taskReq.Size != "" { + body["size"] = taskReq.Size + } + if taskReq.Duration > 0 { + body["duration"] = taskReq.Duration + } + if taskReq.Seconds != "" { + body["seconds"] = taskReq.Seconds + } + if taskReq.InputReference != "" { + body["input_reference"] = taskReq.InputReference + } + + bodyBytes, err := common.Marshal(body) + if err != nil { + return nil, errors.Wrap(err, "marshal_runninghub_request_failed") + } + return bytes.NewReader(bodyBytes), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + var body map[string]any + if err = common.Unmarshal(responseBody, &body); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrap(err, "unmarshal_response_body_failed"), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + upstreamTaskID := extractTaskID(body) + if upstreamTaskID == "" && !hasInlineResult(body) { + taskErr = service.TaskErrorWrapper(errors.New("taskId is empty"), "invalid_response", http.StatusInternalServerError) + return + } + if upstreamTaskID == "" { + upstreamTaskID = info.PublicTaskID + } + + rewrittenBody, err := sjson.SetBytes(responseBody, "taskId", info.PublicTaskID) + if err == nil { + responseBody = rewrittenBody + } + c.Data(http.StatusOK, "application/json", responseBody) + return upstreamTaskID, responseBody, nil +} + +func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { + taskID := common.Interface2String(body["task_id"]) + if taskID == "" { + taskID = common.Interface2String(body["taskId"]) + } + if taskID == "" { + return nil, fmt.Errorf("task_id is required") + } + + reqBody, err := common.Marshal(map[string]string{ + "taskId": taskID, + }) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, buildRunningHubURL(baseURL, "/openapi/v2/query"), bytes.NewReader(reqBody)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var taskResp struct { + TaskID string `json:"taskId"` + Status string `json:"status"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + Results []struct { + URL string `json:"url"` + FileURL string `json:"fileUrl"` + } `json:"results"` + } + if err := common.Unmarshal(respBody, &taskResp); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal runninghub task response") + } + if taskResp.Status == "" { + var wrapped struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + TaskID string `json:"taskId"` + Status string `json:"status"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + Results []struct { + URL string `json:"url"` + FileURL string `json:"fileUrl"` + } `json:"results"` + } `json:"data"` + } + if err := common.Unmarshal(respBody, &wrapped); err == nil { + taskResp.TaskID = wrapped.Data.TaskID + taskResp.Status = wrapped.Data.Status + taskResp.ErrorCode = wrapped.Data.ErrorCode + taskResp.ErrorMessage = wrapped.Data.ErrorMessage + taskResp.Results = wrapped.Data.Results + if taskResp.Status == "" && wrapped.Code != 0 { + return &relaycommon.TaskInfo{ + Status: model.TaskStatusFailure, + Reason: wrapped.Msg, + }, nil + } + } + } + + taskInfo := &relaycommon.TaskInfo{} + switch strings.ToUpper(taskResp.Status) { + case "PENDING": + taskInfo.Status = model.TaskStatusSubmitted + taskInfo.Progress = taskcommon.ProgressSubmitted + case "RUNNING": + taskInfo.Status = model.TaskStatusInProgress + taskInfo.Progress = taskcommon.ProgressInProgress + case "SUCCESS": + taskInfo.Status = model.TaskStatusSuccess + taskInfo.Progress = taskcommon.ProgressComplete + if len(taskResp.Results) > 0 { + taskInfo.Url = taskResp.Results[0].URL + if taskInfo.Url == "" { + taskInfo.Url = taskResp.Results[0].FileURL + } + } + case "FAIL", "FAILED", "ERROR": + taskInfo.Status = model.TaskStatusFailure + if taskResp.ErrorMessage != "" { + taskInfo.Reason = taskResp.ErrorMessage + } else { + taskInfo.Reason = taskResp.ErrorCode + } + default: + return nil, fmt.Errorf("unknown task status: %s", taskResp.Status) + } + return taskInfo, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return nil +} + +func (a *TaskAdaptor) GetChannelName() string { + return "runninghub" +} + +func extractTaskID(body map[string]any) string { + if taskID, ok := body["taskId"].(string); ok { + return taskID + } + if data, ok := body["data"].(map[string]any); ok { + if taskID, ok := data["taskId"].(string); ok { + return taskID + } + } + return "" +} + +func hasInlineResult(body map[string]any) bool { + status, _ := body["status"].(string) + if strings.EqualFold(status, "SUCCESS") { + return true + } + if data, ok := body["data"].(map[string]any); ok { + status, _ = data["status"].(string) + return strings.EqualFold(status, "SUCCESS") + } + return false +} + +func isInternalRuntimeKey(key string) bool { + switch key { + case "api_path": + return true + default: + return false + } +} + +func buildRunningHubURL(baseURL, path string) string { + base := strings.TrimRight(baseURL, "/") + p := strings.TrimPrefix(strings.TrimSpace(path), "/") + if !strings.HasPrefix(p, "openapi/") { + p = "openapi/v2/" + p + } + return base + "/" + p +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index e4421fc11749..21cb0db1f473 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -318,6 +318,7 @@ var streamSupportedChannels = map[int]bool{ constant.ChannelTypeAli: true, constant.ChannelTypeSubmodel: true, constant.ChannelTypeCodex: true, + constant.ChannelTypeRunningHub: true, constant.ChannelTypeMoonshot: true, constant.ChannelTypeMiniMax: true, constant.ChannelTypeSiliconFlow: true, diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4a..2b2a1ff7bf56 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -36,6 +36,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/task/hailuo" taskjimeng "github.com/QuantumNous/new-api/relay/channel/task/jimeng" "github.com/QuantumNous/new-api/relay/channel/task/kling" + taskrunninghub "github.com/QuantumNous/new-api/relay/channel/task/runninghub" tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" "github.com/QuantumNous/new-api/relay/channel/task/suno" taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" @@ -159,6 +160,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskGemini.TaskAdaptor{} case constant.ChannelTypeMiniMax: return &hailuo.TaskAdaptor{} + case constant.ChannelTypeRunningHub: + return &taskrunninghub.TaskAdaptor{} } } return nil diff --git a/router/api-router.go b/router/api-router.go index 35d113768be7..d317ee3e7682 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -227,6 +227,12 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) + channelRoute.POST("/runninghub/ai_app/run", middleware.RootAuth(), controller.RunningHubRunAIAppTask) + channelRoute.POST("/runninghub/ai_app/demo", middleware.RootAuth(), controller.RunningHubGetAIAppCallDemo) + channelRoute.POST("/runninghub/public_models", middleware.RootAuth(), controller.RunningHubGetPublicModelList) + channelRoute.POST("/runninghub/account/info", middleware.RootAuth(), controller.RunningHubGetAccountInfo) + channelRoute.POST("/runninghub/account/api_keys", middleware.RootAuth(), controller.RunningHubListAPIKeys) + channelRoute.POST("/runninghub/account/queue_status", middleware.RootAuth(), controller.RunningHubGetAPIKeyQueueStatus) channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) diff --git a/web/src/constants/channel.constants.js b/web/src/constants/channel.constants.js index 9fa78779de8f..05a23ab85b9a 100644 --- a/web/src/constants/channel.constants.js +++ b/web/src/constants/channel.constants.js @@ -189,11 +189,16 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'Codex (OpenAI OAuth)', }, + { + value: 58, + color: 'green', + label: 'RunningHub', + }, ]; // Channel types that support upstream model list fetching in UI. export const MODEL_FETCHABLE_CHANNEL_TYPES = new Set([ - 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, + 1, 4, 14, 34, 17, 26, 27, 24, 47, 25, 20, 23, 31, 40, 42, 48, 43, 58, ]); export const MODEL_TABLE_PAGE_SIZE = 10; diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 28da657f472e..e8b5fcd6d535 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -330,6 +330,7 @@ export function getChannelIcon(channelType) { case 1: // OpenAI case 3: // Azure OpenAI case 57: // Codex + case 58: // RunningHub return ; case 2: // Midjourney Proxy case 5: // Midjourney Proxy Plus