From f047c9aad3f2ac85d897b6817342d9ff2948e14f Mon Sep 17 00:00:00 2001 From: yjx Date: Sat, 8 Aug 2026 19:18:59 +0800 Subject: [PATCH 1/3] feat(channel): add Yike video generation and credit balance Add signed Yike video task submission, polling, model discovery, and native account-credit refresh. Yike balances are explicitly returned and displayed as credits instead of USD. --- common/endpoint_type.go | 2 +- constant/channel.go | 3 + controller/channel-billing.go | 33 +- controller/channel-test.go | 27 + controller/channel.go | 11 + controller/channel_billing_yike_test.go | 13 + controller/channel_upstream_update.go | 5 + controller/model.go | 25 + controller/relay.go | 7 +- controller/video_proxy.go | 24 +- docs/channel/yike.md | 99 ++ model/task.go | 5 +- relay/channel/adapter.go | 6 + relay/channel/api_request.go | 2 +- relay/channel/task/yike/adaptor.go | 861 ++++++++++++++++++ relay/channel/task/yike/adaptor_test.go | 443 +++++++++ relay/channel/task/yike/signer.go | 145 +++ relay/channel/task/yike/signer_test.go | 46 + relay/common/relay_utils.go | 23 +- relay/common/relay_utils_test.go | 34 +- relay/relay_adaptor.go | 3 + relay/relay_task.go | 13 + service/task_polling.go | 37 +- service/task_polling_test.go | 87 ++ web/src/assets/custom/icon-yike.tsx | 50 + .../channels/components/channels-columns.tsx | 19 +- .../dialogs/balance-query-dialog.tsx | 19 +- web/src/features/channels/constants.ts | 6 +- .../lib/__tests__/yike-balance.test.ts | 37 + .../features/channels/lib/channel-actions.ts | 15 +- .../channels/lib/channel-type-config.ts | 12 + .../features/channels/lib/channel-utils.ts | 1 + web/src/features/channels/lib/index.ts | 1 + web/src/features/channels/lib/yike-balance.ts | 36 + web/src/features/channels/types.ts | 1 + web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/fr.json | 2 + web/src/i18n/locales/ja.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/vi.json | 2 + web/src/i18n/locales/zh-TW.json | 2 + web/src/i18n/locales/zh.json | 2 + web/src/lib/lobe-icon.tsx | 2 + 43 files changed, 2125 insertions(+), 42 deletions(-) create mode 100644 controller/channel_billing_yike_test.go create mode 100644 docs/channel/yike.md create mode 100644 relay/channel/task/yike/adaptor.go create mode 100644 relay/channel/task/yike/adaptor_test.go create mode 100644 relay/channel/task/yike/signer.go create mode 100644 relay/channel/task/yike/signer_test.go create mode 100644 web/src/assets/custom/icon-yike.tsx create mode 100644 web/src/features/channels/lib/__tests__/yike-balance.test.ts create mode 100644 web/src/features/channels/lib/yike-balance.ts diff --git a/common/endpoint_type.go b/common/endpoint_type.go index 126df3c8e761..bd3e12847867 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -28,7 +28,7 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} case constant.ChannelTypeXai: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} - case constant.ChannelTypeSora: + case constant.ChannelTypeSora, constant.ChannelTypeYike: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} case constant.ChannelTypeSub2API, constant.ChannelTypeNewAPI: endpointTypes = []constant.EndpointType{ diff --git a/constant/channel.go b/constant/channel.go index 2a6c4a31c138..7e61789b2da2 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -58,6 +58,7 @@ const ( ChannelTypeAdvancedCustom = 58 ChannelTypeSub2API = 59 ChannelTypeNewAPI = 60 + ChannelTypeYike = 61 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -124,6 +125,7 @@ var ChannelBaseURLs = []string{ "", //58 "", //59 "", //60 + "https://yike.cn-shanghai.aliyuncs.com", //61 } var ChannelTypeNames = map[int]string{ @@ -184,6 +186,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeAdvancedCustom: "Advanced Custom", ChannelTypeSub2API: "Sub2API", ChannelTypeNewAPI: "New API", + ChannelTypeYike: "Yike", } func GetChannelTypeName(channelType int) string { diff --git a/controller/channel-billing.go b/controller/channel-billing.go index 62982d2f5ceb..3a826716e990 100644 --- a/controller/channel-billing.go +++ b/controller/channel-billing.go @@ -1,6 +1,7 @@ package controller import ( + "context" "encoding/json" "errors" "fmt" @@ -12,6 +13,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" + taskyike "github.com/QuantumNous/new-api/relay/channel/task/yike" "github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -356,6 +358,27 @@ func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { return availableBalanceUsd, nil } +func updateChannelYikeBalance(channel *model.Channel) (float64, error) { + client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy) + if err != nil { + return 0, err + } + credit, err := taskyike.FetchAccountCredit(context.Background(), channel.GetBaseURL(), channel.Key, client) + if err != nil { + return 0, err + } + balance := credit.Remaining.InexactFloat64() + channel.UpdateBalance(balance) + return balance, nil +} + +func channelBalanceUnit(channelType int) string { + if channelType == constant.ChannelTypeYike { + return "credits" + } + return "" +} + func updateChannelBalance(channel *model.Channel) (float64, error) { baseURL := constant.ChannelBaseURLs[channel.Type] if channel.GetBaseURL() == "" { @@ -386,6 +409,8 @@ func updateChannelBalance(channel *model.Channel) (float64, error) { return updateChannelOpenRouterBalance(channel) case constant.ChannelTypeMoonshot: return updateChannelMoonshotBalance(channel) + case constant.ChannelTypeYike: + return updateChannelYikeBalance(channel) default: return 0, errors.New("尚未实现") } @@ -444,11 +469,15 @@ func UpdateChannelBalance(c *gin.Context) { common.ApiError(c, err) return } - c.JSON(http.StatusOK, gin.H{ + response := gin.H{ "success": true, "message": "", "balance": balance, - }) + } + if unit := channelBalanceUnit(channel.Type); unit != "" { + response["unit"] = unit + } + c.JSON(http.StatusOK, response) } func updateAllChannelsBalance() error { diff --git a/controller/channel-test.go b/controller/channel-test.go index f494af0431f6..6f8f487b6bb1 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -20,6 +20,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/relay" + taskyike "github.com/QuantumNous/new-api/relay/channel/task/yike" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" @@ -78,6 +79,9 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te ctx = context.Background() } tik := time.Now() + if channel.Type == constant.ChannelTypeYike { + return testYikeChannel(ctx, channel) + } var unsupportedTestChannelTypes = []int{ constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus, @@ -527,6 +531,29 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te } } +func testYikeChannel(ctx context.Context, channel *model.Channel) testResult { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + key, _, keyErr := channel.GetNextEnabledKey() + var err error + if keyErr != nil { + err = keyErr + } else if strings.TrimSpace(key) == "" { + err = fmt.Errorf("Yike channel key is empty") + } else { + common.SetContextKey(c, constant.ContextKeyChannelKey, key) + err = taskyike.CheckChannelAccountCredit(ctx, channel.GetBaseURL(), key, channel.GetSetting().Proxy) + } + if err == nil { + return testResult{context: c} + } + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusBadGateway), + } +} + func attachTestBillingRequestInput(info *relaycommon.RelayInfo, request dto.Request) error { if info == nil { return nil diff --git a/controller/channel.go b/controller/channel.go index 3a1e58328923..9b2c500448e4 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -15,6 +15,7 @@ import ( "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/ollama" + taskyike "github.com/QuantumNous/new-api/relay/channel/task/yike" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/service" @@ -484,6 +485,16 @@ func validateChannel(channel *model.Channel, isAdd bool) error { if channel.Type == constant.ChannelTypeNewAPI && strings.TrimSpace(channel.GetBaseURL()) == "" { return fmt.Errorf("New API channel base URL cannot be empty") } + if channel.Type == constant.ChannelTypeYike { + if err := taskyike.ValidateChannelEndpoint(channel.GetBaseURL()); err != nil { + return err + } + if isAdd || strings.TrimSpace(channel.Key) != "" { + if err := taskyike.ValidateChannelCredentials(channel.Key); err != nil { + return err + } + } + } // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { diff --git a/controller/channel_billing_yike_test.go b/controller/channel_billing_yike_test.go new file mode 100644 index 000000000000..12bc2d92958e --- /dev/null +++ b/controller/channel_billing_yike_test.go @@ -0,0 +1,13 @@ +package controller + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/assert" +) + +func TestChannelBalanceUnit(t *testing.T) { + assert.Equal(t, "credits", channelBalanceUnit(constant.ChannelTypeYike)) + assert.Empty(t, channelBalanceUnit(constant.ChannelTypeOpenAI)) +} diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 71ab0e53fafe..406995d9116d 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -19,6 +19,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/advancedcustom" "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" + taskyike "github.com/QuantumNous/new-api/relay/channel/task/yike" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relaykit/dto" @@ -337,6 +338,10 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) { if channel.GetBaseURL() != "" { baseURL = channel.GetBaseURL() } + if channel.Type == constant.ChannelTypeYike { + // Yike exposes task RPCs but no OpenAI-compatible /v1/models endpoint. + return (&taskyike.TaskAdaptor{}).GetModelList(), nil + } if channel.Type == constant.ChannelTypeOllama { key := strings.TrimSpace(strings.Split(channel.Key, "\n")[0]) diff --git a/controller/model.go b/controller/model.go index 1d759301bc7e..6977f066c966 100644 --- a/controller/model.go +++ b/controller/model.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "strconv" "strings" "time" @@ -97,8 +98,32 @@ func init() { for i := 1; i <= constant.ChannelTypeDummy; i++ { apiType, success := common.ChannelType2APIType(i) if !success || apiType == constant.APITypeAIProxyLibrary { + if i != constant.ChannelTypeYike { + continue + } + + // Yike is intentionally task-only, so it has no Chat APIType adaptor. + meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ChannelType: i}} + taskAdaptor := relay.GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(i))) + if taskAdaptor == nil { + continue + } + taskAdaptor.Init(meta) + models := taskAdaptor.GetModelList() + channelId2Models[i] = models + for _, modelName := range models { + aiModel := dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: taskAdaptor.GetChannelName(), + } + openAIModels = append(openAIModels, aiModel) + openAIModelsMap[modelName] = aiModel + } continue } + meta := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{ ChannelType: i, }} diff --git a/controller/relay.go b/controller/relay.go index 8dccfe76dddd..ec69f4803a24 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -526,7 +526,7 @@ func RelayTask(c *gin.Context) { if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { channel = lockedCh - if retryParam.GetRetry() > 0 { + if retryParam.GetRetry() > 0 && channel.Type != constant.ChannelTypeYike { if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) break @@ -541,6 +541,11 @@ func RelayTask(c *gin.Context) { break } } + if channel.Type == constant.ChannelTypeYike && relayInfo.LockedChannel == nil { + // A Yike submit may have reached the provider even when its response is + // ambiguous. Keep the same account and already-selected AK|SK on retry. + relayInfo.LockedChannel = channel + } addUsedChannel(c, channel.Id) bodyStorage, bodyErr := common.GetBodyStorage(c) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 996d084d88fa..60d85c90f767 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -14,6 +14,7 @@ import ( "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/system_setting" @@ -125,6 +126,10 @@ func VideoProxy(c *gin.Context) { videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content") return } + logVideoURL := videoURL + if channel.Type == constant.ChannelTypeYike { + logVideoURL = relaycommon.SanitizeURLForLog(videoURL) + } if strings.HasPrefix(videoURL, "data:") { if err := writeVideoDataURL(c, videoURL); err != nil { @@ -142,28 +147,37 @@ func VideoProxy(c *gin.Context) { validateErr = common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain) } if validateErr != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, validateErr)) - videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", validateErr)) + if channel.Type == constant.ChannelTypeYike { + logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s", taskID)) + videoProxyError(c, http.StatusForbidden, "server_error", "request blocked by security policy") + } else { + logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, validateErr)) + videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", validateErr)) + } return } req.URL, err = url.Parse(videoURL) if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error())) + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", logVideoURL, err.Error())) videoProxyError(c, http.StatusInternalServerError, "server_error", "Failed to create proxy request") return } resp, err := client.Do(req) if err != nil { - logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) + errorMessage := err.Error() + if channel.Type == constant.ChannelTypeYike { + errorMessage = relaycommon.SanitizeErrorForLog(err) + } + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", logVideoURL, errorMessage)) videoProxyError(c, http.StatusBadGateway, "server_error", "Failed to fetch video content") return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, videoURL)) + logger.LogError(c.Request.Context(), fmt.Sprintf("Upstream returned status %d for %s", resp.StatusCode, logVideoURL)) videoProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Upstream service returned status %d", resp.StatusCode)) return diff --git a/docs/channel/yike.md b/docs/channel/yike.md new file mode 100644 index 000000000000..a5e87cd0b275 --- /dev/null +++ b/docs/channel/yike.md @@ -0,0 +1,99 @@ +# 万镜一刻(Yike)渠道 + +本渠道把 new-api 视频任务协议转换为万镜一刻 `2026-07-07` RPC API。管理员配置阿里云 AK/SK,终端用户仍使用 new-api 地址和 Bearer Token。 + +## 渠道配置 + +| 配置项 | 值 | +| --- | --- | +| 类型 | `Yike` | +| API 地址 | 上海:`https://yike.cn-shanghai.aliyuncs.com`;新加坡:`https://yike.ap-southeast-1.aliyuncs.com` | +| 密钥 | `AccessKeyId\|AccessKeySecret` | +| 模型 | `Wonder-Pro,Wonder-Standard,happyhorse-1.1,happyhorse-1.0,wan2.7` | + +账号需开通万镜一刻、拥有可用点数及 Yike 调用权限。自定义地址必须使用 HTTPS;适配器始终请求 RPC 根路径 `/`。多 Key 渠道每行填写一组完整的 `AK|SK`,任务轮询会继续使用提交时选中的 Key。 + +适配器负责阿里云 V3 签名、`SubmitVideoGenerationJob` 提交、`GetVideoGenerationJob` 轮询及状态和结果转换。后台“测试渠道”和“更新余额”都调用免费只读的 `GetYikeAccountCredit`;余额为会员计划、加油包和赠送积分三类可用积分之和,不会生成视频。余额刷新响应同时返回 `unit=credits`,渠道列表按“积分”展示,不把积分解释为美元。 + +## 用户调用 + +`$NEW_API_KEY` 是 new-api 发给用户的 Token,不是阿里云 AK/SK。 + +```bash +curl "$NEW_API_URL/v1/videos" \ + -H "Authorization: Bearer $NEW_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "wan2.7", + "prompt": "一只橘猫在雨后的上海街道散步,电影感镜头", + "duration": 5, + "size": "1280x720" + }' +``` + +图生视频可增加公网图片字段: + +```json +{"image":"https://example.com/source.jpg"} +``` + +首尾帧和参考生视频通过 `metadata` 指定任务类型及素材: + +```json +{ + "metadata": { + "job_type": "first_last_frame", + "medias": [ + {"type":"image","url":"https://example.com/first.jpg"}, + {"type":"image","media_id":"imported-last-frame"} + ], + "resolution": "720P", + "aspect_ratio": "16:9" + } +} +``` + +每个素材必须在 `url` 和 `media_id` 中二选一。素材类型可为 `image`、`video`、`audio`,实际能力取决于模型和任务类型。 + +## 查询和结果 + +提交后使用返回的公共任务 ID 查询或下载: + +```bash +curl "$NEW_API_URL/v1/videos/task_xxx" \ + -H "Authorization: Bearer $NEW_API_KEY" + +curl -L "$NEW_API_URL/v1/videos/task_xxx/content" \ + -H "Authorization: Bearer $NEW_API_KEY" \ + --output result.mp4 +``` + +标准查询的 `metadata.url` 指向需鉴权的 `/content` 代理,不暴露上游签名地址。兼容路径 `/v1/video/generations/{task_id}` 则返回现有任务结构:成功后 `data.result_url` 是 Yike 官方临时 OSS 地址,`data.data` 保留脱敏后的 Yike 轮询响应及 `VideoGenerationJob.Output`,但移除 `Input`、`UserData` 和 `JobParameters`。`Output` 是 JSON 字符串,可继续解析 `Medias[].OutputUrl`。 + +| Yike 状态 | 视频状态 | +| --- | --- | +| `Created`、`Queuing` | `queued` | +| `Executing` | `in_progress` | +| `Finished` | `completed` | +| `Failed` | `failed` | + +## 参数与限制 + +- `duration`:4~15 秒,默认 5 秒。 +- `resolution`:`720P`、`1080P`。 +- `aspect_ratio`:`16:9`、`9:16`、`4:3`、`3:4`、`1:1`;`size` 会转换为对应分辨率和宽高比。 +- `job_type`:`text_to_video`、`image_to_video`、`first_last_frame`、`reference_to_video`。无媒体或单张图片可自动推断,多媒体必须显式指定。 +- 当前固定 `metadata.n=1`,不透传 `metadata.job_parameters` 或 `user_data`。 +- 不支持 remix、multipart 二进制文件、Base64/data URI 和明显的私网素材地址。 +- Wonder 真人参考素材需先通过 Yike `ImportMedia` 获得 `MediaId`;本渠道不负责导入素材。 +- HappyHorse 参考任务最多 9 个素材且不支持音频;Wonder 最多 15 个素材。 +- `wan2.7` 当前拒绝尚未完成真实联调的 `reference_to_video`。 +- OSS 结果地址可能过期;`/content` 只做代理,不负责长期归档。 + +## 验证 + +```bash +go test ./relay/channel/task/yike ./relay/common ./controller +``` + +主要实现位于 `relay/channel/task/yike`。计费复用 new-api 现有任务定价配置,生产启用前还应确认账号区域、模型权限和价格。 diff --git a/model/task.go b/model/task.go index 9a1783589a04..1e50ccd5b7e1 100644 --- a/model/task.go +++ b/model/task.go @@ -178,8 +178,11 @@ func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) properties := Properties{} privateData := TaskPrivateData{} if relayInfo != nil && relayInfo.ChannelMeta != nil { + // Signed task providers must poll with the exact credential selected at + // submit time, especially when the channel rotates through multiple keys. if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini || - relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi { + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi || + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeYike { privateData.Key = relayInfo.ChannelMeta.ApiKey } if relayInfo.UpstreamModelName != "" { diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go index 3735b6ad22ac..8ab2edd6e976 100644 --- a/relay/channel/adapter.go +++ b/relay/channel/adapter.go @@ -82,3 +82,9 @@ type TaskAdaptor interface { type OpenAIVideoConverter interface { ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) } + +// MappedModelValidator lets task-only adaptors revalidate provider-specific +// capabilities after channel model mapping has selected the upstream model. +type MappedModelValidator interface { + ValidateMappedModel(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError +} diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go index 48241b14a5e9..22b03cadd23c 100644 --- a/relay/channel/api_request.go +++ b/relay/channel/api_request.go @@ -531,7 +531,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http resp, err := relayClient.Do(req) if err != nil { - logger.LogError(c, "do request failed: "+err.Error()) + logger.LogError(c, "do request failed: "+common.SanitizeErrorForLog(err)) return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) } if resp == nil { diff --git a/relay/channel/task/yike/adaptor.go b/relay/channel/task/yike/adaptor.go new file mode 100644 index 000000000000..72968207dce4 --- /dev/null +++ b/relay/channel/task/yike/adaptor.go @@ -0,0 +1,861 @@ +package yike + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + taskdto "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/relaykit/dto" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "github.com/shopspring/decimal" +) + +const ( + apiVersion = "2026-07-07" + submitAction = "SubmitVideoGenerationJob" + getAction = "GetVideoGenerationJob" + accountCreditAction = "GetYikeAccountCredit" + + accountCreditRequestTimeout = 15 * time.Second +) + +var modelList = []string{ + "Wonder-Pro", + "Wonder-Standard", + "happyhorse-1.1", + "happyhorse-1.0", + "wan2.7", +} + +type TaskAdaptor struct { + taskcommon.BaseBilling + baseURL string + accessKeyID string + accessKeySecret string + submitQuery url.Values + signer *v3Signer + httpClient *http.Client +} + +type requestMetadata struct { + JobType string `json:"job_type"` + Medias []inputMedia `json:"medias"` + Resolution string `json:"resolution"` + AspectRatio string `json:"aspect_ratio"` + N *int `json:"n"` + Scene string `json:"scene"` + JobParameters json.RawMessage `json:"job_parameters"` +} + +type inputMedia struct { + Type string `json:"type"` + URL string `json:"url"` + MediaID string `json:"media_id"` +} + +type upstreamInput struct { + Prompt string `json:"Prompt"` + Medias []upstreamMedia `json:"Medias,omitempty"` +} + +type upstreamMedia struct { + Type string `json:"Type"` + // The live OpenAPI/SDK schema uses "Url"; the beta PDF's "URL" spelling is stale. + URL string `json:"Url,omitempty"` + MediaID string `json:"MediaId,omitempty"` +} + +type submitResponse struct { + JobID string `json:"JobId"` + Code string `json:"Code,omitempty"` + Message string `json:"Message,omitempty"` +} + +type getResponse struct { + VideoGenerationJob *videoGenerationJob `json:"VideoGenerationJob,omitempty"` + Code string `json:"Code,omitempty"` + Message string `json:"Message,omitempty"` +} + +type accountCreditResponse struct { + Code string `json:"Code,omitempty"` + Message string `json:"Message,omitempty"` + CreditInfo *accountCreditInfo `json:"CreditInfo,omitempty"` + MembershipInfo *accountMembershipInfo `json:"MembershipInfo,omitempty"` +} + +type accountCreditInfo struct { + ResourceCreditQuota *decimal.Decimal `json:"ResourceCreditQuota,omitempty"` + PackCreditQuota *decimal.Decimal `json:"PackCreditQuota,omitempty"` + GrantedCreditQuota *decimal.Decimal `json:"GrantedCreditQuota,omitempty"` + ResourceCreditQuotaUsage *decimal.Decimal `json:"ResourceCreditQuotaUsage,omitempty"` + PackCreditQuotaUsage *decimal.Decimal `json:"PackCreditQuotaUsage,omitempty"` + GrantedCreditQuotaUsage *decimal.Decimal `json:"GrantedCreditQuotaUsage,omitempty"` +} + +type accountMembershipInfo struct { + EndTime string `json:"EndTime,omitempty"` +} + +// AccountCredit contains the normalized Yike primary-account credit totals. +type AccountCredit struct { + Remaining decimal.Decimal + Used decimal.Decimal + Granted decimal.Decimal + ExpiresAt int64 +} + +// AccountCreditError represents a provider error without exposing credentials. +type AccountCreditError struct { + StatusCode int + Code string + Message string +} + +func (err *AccountCreditError) Error() string { + if err == nil { + return "" + } + if err.Code != "" && err.Message != "" { + return fmt.Sprintf("Yike account credit error %s: %s", err.Code, err.Message) + } + if err.Code != "" { + return fmt.Sprintf("Yike account credit error %s", err.Code) + } + return fmt.Sprintf("Yike account credit returned status %d", err.StatusCode) +} + +type videoGenerationJob struct { + JobID string `json:"JobId"` + Status string `json:"Status"` + ErrorMessage string `json:"ErrorMessage,omitempty"` + Output string `json:"Output,omitempty"` +} + +type jobOutput struct { + Medias []outputMedia `json:"Medias"` +} + +type outputMedia struct { + OutputURL string `json:"OutputUrl"` +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.baseURL = info.ChannelBaseUrl + if strings.TrimSpace(a.baseURL) == "" { + a.baseURL = constant.ChannelBaseURLs[constant.ChannelTypeYike] + } + a.accessKeyID, a.accessKeySecret, _ = parseCredentials(info.ApiKey) + if a.signer == nil { + a.signer = defaultV3Signer() + } +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError { + if info.Action == constant.TaskActionRemix || strings.HasSuffix(c.Request.URL.Path, "/remix") { + return service.TaskErrorWrapper( + fmt.Errorf("Yike does not support video remix"), + "unsupported_yike_remix", + http.StatusBadRequest, + ) + } + if _, _, err := parseCredentials(info.ApiKey); err != nil { + return service.TaskErrorWrapper(err, "invalid_yike_credentials", http.StatusBadRequest) + } + if err := rejectMultipartFiles(c); err != nil { + return service.TaskErrorWrapper(err, "unsupported_yike_file_upload", http.StatusBadRequest) + } + if taskErr := relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate); taskErr != nil { + return taskErr + } + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapper(err, "get_task_request_failed", http.StatusBadRequest) + } + jobType, _, err := convertRequestForValidation(req, info.OriginModelName, "", true) + if err != nil { + return service.TaskErrorWrapper(err, "invalid_yike_request", http.StatusBadRequest) + } + switch jobType { + case "image_to_video": + info.Action = constant.TaskActionGenerate + case "first_last_frame": + info.Action = constant.TaskActionFirstTailGenerate + case "reference_to_video": + info.Action = constant.TaskActionReferenceGenerate + default: + info.Action = constant.TaskActionTextGenerate + } + return nil +} + +func (a *TaskAdaptor) ValidateMappedModel(c *gin.Context, info *relaycommon.RelayInfo) *taskdto.TaskError { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return service.TaskErrorWrapper(err, "get_task_request_failed", http.StatusBadRequest) + } + if _, _, err := convertRequest(req, info.UpstreamModelName, ""); err != nil { + return service.TaskErrorWrapper(err, "invalid_yike_mapped_request", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + req, err := relaycommon.GetTaskRequest(c) + if err != nil { + return nil, err + } + _, query, err := convertRequest(req, info.UpstreamModelName, info.PublicTaskID) + if err != nil { + return nil, err + } + a.submitQuery = query + // Yike is an RPC-style API: business parameters are signed in the query and + // the POST body must remain empty. + return bytes.NewReader(nil), nil +} + +func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { + if a.submitQuery == nil { + return "", fmt.Errorf("Yike submit query is not initialized") + } + return buildEndpoint(a.baseURL, a.submitQuery) +} + +func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + return a.signer.sign(req, submitAction, apiVersion, a.accessKeyID, a.accessKeySecret) +} + +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) (string, []byte, *taskdto.TaskError) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var result submitResponse + if err := common.Unmarshal(body, &result); err != nil { + return "", body, service.TaskErrorWrapper(errors.Wrap(err, "unmarshal Yike submit response failed"), "unmarshal_response_body_failed", http.StatusInternalServerError) + } + if result.Code != "" { + return "", body, service.TaskErrorWrapper(fmt.Errorf("%s: %s", result.Code, sanitizeErrorMessage(result.Message)), result.Code, http.StatusBadGateway) + } + if strings.TrimSpace(result.JobID) == "" { + return "", body, service.TaskErrorWrapper(fmt.Errorf("Yike response did not contain JobId"), "missing_job_id", http.StatusBadGateway) + } + + video := dto.NewOpenAIVideo() + video.ID = info.PublicTaskID + video.TaskID = info.PublicTaskID + video.Model = info.OriginModelName + video.CreatedAt = time.Now().Unix() + c.JSON(http.StatusOK, video) + return result.JobID, body, nil +} + +func (a *TaskAdaptor) FetchTask(baseURL, key string, body map[string]any, proxy string) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok || strings.TrimSpace(taskID) == "" { + return nil, fmt.Errorf("invalid task_id") + } + // Polling receives the exact AK|SK pair persisted when the task was submitted, + // rather than the channel's possibly rotated multi-key value. + accessKeyID, accessKeySecret, err := parseCredentials(key) + if err != nil { + return nil, err + } + endpoint, err := buildEndpoint(baseURL, url.Values{"JobId": []string{taskID}}) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + if err != nil { + return nil, err + } + signer := a.signer + if signer == nil { + signer = defaultV3Signer() + } + if err := signer.sign(req, getAction, apiVersion, accessKeyID, accessKeySecret); err != nil { + return nil, err + } + client := a.httpClient + if client == nil { + var err error + client, err = service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } + response, err := client.Do(req) + if err != nil { + return nil, err + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + _ = response.Body.Close() + return nil, fmt.Errorf("Yike polling returned HTTP %d", response.StatusCode) + } + return response, nil +} + +func (a *TaskAdaptor) ParseTaskResult(body []byte) (*relaycommon.TaskInfo, error) { + var result getResponse + if err := common.Unmarshal(body, &result); err != nil { + return nil, errors.Wrap(err, "unmarshal Yike task result failed") + } + if result.Code != "" { + return nil, fmt.Errorf("Yike polling error %s: %s", result.Code, sanitizeErrorMessage(result.Message)) + } + if result.VideoGenerationJob == nil { + return nil, fmt.Errorf("Yike response did not contain VideoGenerationJob") + } + + taskInfo := &relaycommon.TaskInfo{TaskID: result.VideoGenerationJob.JobID} + switch result.VideoGenerationJob.Status { + case "Created": + taskInfo.Status = model.TaskStatusSubmitted + taskInfo.Progress = taskcommon.ProgressSubmitted + case "Queuing": + taskInfo.Status = model.TaskStatusQueued + taskInfo.Progress = taskcommon.ProgressQueued + case "Executing": + taskInfo.Status = model.TaskStatusInProgress + taskInfo.Progress = taskcommon.ProgressInProgress + case "Finished": + output, err := parseJobOutput(result.VideoGenerationJob.Output) + if err != nil { + return nil, err + } + if len(output.Medias) == 0 || strings.TrimSpace(output.Medias[0].OutputURL) == "" { + return nil, fmt.Errorf("Yike finished task did not contain OutputUrl") + } + taskInfo.Status = model.TaskStatusSuccess + taskInfo.Progress = taskcommon.ProgressComplete + taskInfo.Url = output.Medias[0].OutputURL + case "Failed": + taskInfo.Status = model.TaskStatusFailure + taskInfo.Progress = taskcommon.ProgressComplete + taskInfo.Reason = sanitizeErrorMessage(result.VideoGenerationJob.ErrorMessage) + if taskInfo.Reason == "" { + taskInfo.Reason = "Yike video generation failed" + } + default: + return nil, fmt.Errorf("unknown Yike task status: %s", result.VideoGenerationJob.Status) + } + return taskInfo, nil +} + +func (a *TaskAdaptor) ConvertToOpenAIVideo(task *model.Task) ([]byte, error) { + video := dto.NewOpenAIVideo() + video.ID = task.TaskID + video.Status = task.Status.ToVideoStatus() + video.Model = task.Properties.OriginModelName + video.SetProgressStr(task.Progress) + video.CreatedAt = task.CreatedAt + video.CompletedAt = task.UpdatedAt + if task.Status == model.TaskStatusSuccess { + video.SetMetadata("url", taskcommon.BuildProxyURL(task.TaskID)) + } + if task.Status == model.TaskStatusFailure { + video.Error = &dto.OpenAIVideoError{Code: "yike_task_failed", Message: sanitizeErrorMessage(task.FailReason)} + } + return common.Marshal(video) +} + +func (a *TaskAdaptor) GetModelList() []string { + return append([]string(nil), modelList...) +} + +func (a *TaskAdaptor) GetChannelName() string { + return "yike" +} + +func parseCredentials(key string) (string, string, error) { + if strings.ContainsAny(key, "\r\n") || strings.Count(key, "|") != 1 { + return "", "", fmt.Errorf("invalid Yike key format: expected one AccessKeyId|AccessKeySecret pair") + } + parts := strings.Split(key, "|") + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return "", "", fmt.Errorf("invalid Yike key format: expected AccessKeyId|AccessKeySecret") + } + return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), nil +} + +// ValidateChannelCredentials validates either one AK|SK pair or a newline- +// separated multi-key set as stored by the channel administration API. +func ValidateChannelCredentials(keys string) error { + validPairs := 0 + for lineNumber, line := range strings.Split(keys, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if _, _, err := parseCredentials(line); err != nil { + return fmt.Errorf("invalid Yike credential on line %d: %w", lineNumber+1, err) + } + validPairs++ + } + if validPairs == 0 { + return fmt.Errorf("at least one Yike AccessKeyId|AccessKeySecret pair is required") + } + return nil +} + +func ValidateChannelEndpoint(baseURL string) error { + if strings.TrimSpace(baseURL) == "" { + baseURL = constant.ChannelBaseURLs[constant.ChannelTypeYike] + } + _, err := buildEndpoint(baseURL, nil) + return err +} + +// CheckChannelAccountCredit verifies Yike account-credit access. +func CheckChannelAccountCredit(ctx context.Context, baseURL, key, proxy string) error { + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return fmt.Errorf("create Yike test client failed: %w", err) + } + _, err = FetchAccountCredit(ctx, baseURL, key, client) + return err +} + +// FetchAccountCredit reads and normalizes Yike's three account-credit buckets. +func FetchAccountCredit(ctx context.Context, baseURL, key string, client *http.Client) (AccountCredit, error) { + accessKeyID, accessKeySecret, err := parseCredentials(key) + if err != nil { + return AccountCredit{}, err + } + endpoint, err := buildEndpoint(baseURL, nil) + if err != nil { + return AccountCredit{}, err + } + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, accountCreditRequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil) + if err != nil { + return AccountCredit{}, fmt.Errorf("create Yike account credit request failed: %w", err) + } + if err := defaultV3Signer().sign(req, accountCreditAction, apiVersion, accessKeyID, accessKeySecret); err != nil { + return AccountCredit{}, fmt.Errorf("sign Yike account credit request failed: %w", err) + } + if client == nil { + client = http.DefaultClient + } + response, err := client.Do(req) + if err != nil { + return AccountCredit{}, fmt.Errorf("Yike account credit request failed: %w", err) + } + defer response.Body.Close() + + body, err := io.ReadAll(io.LimitReader(response.Body, 64*1024)) + if err != nil { + return AccountCredit{}, fmt.Errorf("read Yike account credit response failed: %w", err) + } + var result accountCreditResponse + if err := common.Unmarshal(body, &result); err != nil { + if response.StatusCode != http.StatusOK { + return AccountCredit{}, &AccountCreditError{StatusCode: response.StatusCode} + } + return AccountCredit{}, fmt.Errorf("unmarshal Yike account credit response failed: %w", err) + } + if result.Code != "" { + return AccountCredit{}, &AccountCreditError{ + StatusCode: response.StatusCode, + Code: result.Code, + Message: sanitizeErrorMessage(result.Message), + } + } + if response.StatusCode != http.StatusOK { + return AccountCredit{}, &AccountCreditError{StatusCode: response.StatusCode} + } + return normalizeAccountCredit(result) +} + +func normalizeAccountCredit(response accountCreditResponse) (AccountCredit, error) { + if response.CreditInfo == nil { + return AccountCredit{}, fmt.Errorf("Yike account credit response did not contain CreditInfo") + } + info := response.CreditInfo + if info.ResourceCreditQuota == nil && info.PackCreditQuota == nil && info.GrantedCreditQuota == nil && + info.ResourceCreditQuotaUsage == nil && info.PackCreditQuotaUsage == nil && info.GrantedCreditQuotaUsage == nil { + return AccountCredit{}, fmt.Errorf("Yike account credit response did not contain credit quotas") + } + granted := sumAccountCredits(info.ResourceCreditQuota, info.PackCreditQuota, info.GrantedCreditQuota) + remaining := sumAccountCredits(info.ResourceCreditQuotaUsage, info.PackCreditQuotaUsage, info.GrantedCreditQuotaUsage) + if granted.IsNegative() || remaining.IsNegative() || remaining.GreaterThan(granted) { + return AccountCredit{}, fmt.Errorf("invalid Yike account credit quotas") + } + + var expiresAt int64 + if response.MembershipInfo != nil && strings.TrimSpace(response.MembershipInfo.EndTime) != "" { + parsed, err := strconv.ParseInt(strings.TrimSpace(response.MembershipInfo.EndTime), 10, 64) + if err != nil || parsed < 0 { + return AccountCredit{}, fmt.Errorf("invalid Yike membership end time") + } + expiresAt = parsed + } + return AccountCredit{ + Remaining: remaining, + Used: granted.Sub(remaining), + Granted: granted, + ExpiresAt: expiresAt, + }, nil +} + +func sumAccountCredits(values ...*decimal.Decimal) decimal.Decimal { + total := decimal.Zero + for _, value := range values { + if value != nil { + total = total.Add(*value) + } + } + return total +} + +func rejectMultipartFiles(c *gin.Context) error { + contentType := strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))) + if strings.HasPrefix(contentType, "multipart/") || strings.HasPrefix(contentType, "application/octet-stream") { + return fmt.Errorf("Yike does not support multipart or binary upload; use a public URL or MediaId") + } + return nil +} + +func convertRequest(req relaycommon.TaskSubmitReq, modelName, clientToken string) (string, url.Values, error) { + return convertRequestForValidation(req, modelName, clientToken, false) +} + +func convertRequestForValidation(req relaycommon.TaskSubmitReq, modelName, clientToken string, allowUnknownModel bool) (string, url.Values, error) { + modelName = strings.TrimSpace(modelName) + if !isSupportedModel(modelName) && !allowUnknownModel { + return "", nil, fmt.Errorf("unsupported Yike model: %s", modelName) + } + var metadata requestMetadata + if err := req.UnmarshalMetadata(&metadata); err != nil { + return "", nil, err + } + if len(bytes.TrimSpace(metadata.JobParameters)) > 0 { + return "", nil, fmt.Errorf("metadata.job_parameters is not supported by the Yike channel") + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return "", nil, fmt.Errorf("Yike prompt is required") + } + medias, err := collectMedias(req, metadata.Medias) + if err != nil { + return "", nil, err + } + jobType := strings.TrimSpace(metadata.JobType) + if jobType == "" { + switch len(medias) { + case 0: + jobType = "text_to_video" + case 1: + jobType = "image_to_video" + default: + return "", nil, fmt.Errorf("multiple media inputs require metadata.job_type") + } + } + if err := validateJobType(jobType, modelName, medias); err != nil { + return "", nil, err + } + + resolution, aspectRatio, err := resolveOutputSize(req.Size, metadata.Resolution, metadata.AspectRatio) + if err != nil { + return "", nil, err + } + duration := req.Duration + if duration == 0 && strings.TrimSpace(req.Seconds) != "" { + var parseErr error + duration, parseErr = strconv.Atoi(strings.TrimSpace(req.Seconds)) + if parseErr != nil { + return "", nil, fmt.Errorf("invalid Yike seconds: %s", req.Seconds) + } + } + if duration == 0 { + duration = 5 + } + if duration < 4 || duration > 15 { + return "", nil, fmt.Errorf("Yike duration must be between 4 and 15 seconds") + } + if metadata.N != nil && *metadata.N != 1 { + return "", nil, fmt.Errorf("metadata.n must be exactly 1") + } + scene := strings.TrimSpace(metadata.Scene) + if scene == "" { + scene = "general" + } + if scene != "general" { + return "", nil, fmt.Errorf("unsupported Yike scene: %s", scene) + } + + inputBytes, err := common.Marshal(upstreamInput{Prompt: prompt, Medias: medias}) + if err != nil { + return "", nil, err + } + query := url.Values{ + "JobType": []string{jobType}, + "Model": []string{modelName}, + "Input": []string{string(inputBytes)}, + "Resolution": []string{resolution}, + "AspectRatio": []string{aspectRatio}, + "Duration": []string{strconv.Itoa(duration)}, + "N": []string{"1"}, + "Scene": []string{scene}, + } + if clientToken != "" { + query.Set("ClientToken", clientToken) + } + return jobType, query, nil +} + +func collectMedias(req relaycommon.TaskSubmitReq, configured []inputMedia) ([]upstreamMedia, error) { + if len(configured) > 0 { + medias := make([]upstreamMedia, 0, len(configured)) + for _, media := range configured { + converted, err := convertMedia(media) + if err != nil { + return nil, err + } + medias = append(medias, converted) + } + return medias, nil + } + + inputs := append([]string(nil), req.Images...) + if len(inputs) == 0 && strings.TrimSpace(req.Image) != "" { + inputs = append(inputs, req.Image) + } + if len(inputs) == 0 && strings.TrimSpace(req.InputReference) != "" { + inputs = append(inputs, req.InputReference) + } + medias := make([]upstreamMedia, 0, len(inputs)) + for _, input := range inputs { + converted, err := convertMedia(inputMedia{Type: "image", URL: input}) + if err != nil { + return nil, err + } + medias = append(medias, converted) + } + return medias, nil +} + +func convertMedia(media inputMedia) (upstreamMedia, error) { + media.Type = strings.ToLower(strings.TrimSpace(media.Type)) + media.URL = strings.TrimSpace(media.URL) + media.MediaID = strings.TrimSpace(media.MediaID) + if media.Type == "" { + media.Type = "image" + } + if media.Type != "image" && media.Type != "video" && media.Type != "audio" { + return upstreamMedia{}, fmt.Errorf("unsupported Yike media type: %s", media.Type) + } + if (media.URL == "") == (media.MediaID == "") { + return upstreamMedia{}, fmt.Errorf("each Yike media requires exactly one of url or media_id") + } + if media.URL != "" { + if !isPublicHTTPURL(media.URL) { + return upstreamMedia{}, fmt.Errorf("Yike media URL must be a public HTTP(S) URL") + } + } + return upstreamMedia{Type: media.Type, URL: media.URL, MediaID: media.MediaID}, nil +} + +func validateJobType(jobType, modelName string, medias []upstreamMedia) error { + switch jobType { + case "text_to_video": + if len(medias) != 0 { + return fmt.Errorf("text_to_video does not accept media inputs") + } + case "image_to_video": + if len(medias) != 1 || medias[0].Type != "image" { + return fmt.Errorf("image_to_video requires exactly one image") + } + case "first_last_frame": + if len(medias) != 2 || medias[0].Type != "image" || medias[1].Type != "image" { + return fmt.Errorf("first_last_frame requires exactly two images") + } + case "reference_to_video": + if len(medias) == 0 { + return fmt.Errorf("reference_to_video requires at least one media input") + } + if strings.HasPrefix(modelName, "happyhorse-") { + if len(medias) > 9 { + return fmt.Errorf("HappyHorse reference_to_video supports at most 9 media inputs") + } + for _, media := range medias { + if media.Type == "audio" { + return fmt.Errorf("HappyHorse reference_to_video does not support audio references") + } + } + } + if strings.HasPrefix(modelName, "Wonder-") && len(medias) > 15 { + return fmt.Errorf("Wonder reference_to_video supports at most 15 media inputs") + } + if modelName == "wan2.7" { + return fmt.Errorf("wan2.7 reference_to_video is unavailable until its upstream capability matrix is verified") + } + default: + return fmt.Errorf("unsupported Yike job_type: %s", jobType) + } + return nil +} + +func resolveOutputSize(size, metadataResolution, metadataAspectRatio string) (string, string, error) { + resolution := "720P" + aspectRatio := "16:9" + size = strings.TrimSpace(size) + if size != "" { + upper := strings.ToUpper(size) + if upper == "720P" || upper == "1080P" { + resolution = upper + } else { + normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(size, "*", "x"), "×", "x")) + parts := strings.Split(normalized, "x") + if len(parts) != 2 { + return "", "", fmt.Errorf("unsupported Yike size: %s", size) + } + width, errWidth := strconv.Atoi(strings.TrimSpace(parts[0])) + height, errHeight := strconv.Atoi(strings.TrimSpace(parts[1])) + if errWidth != nil || errHeight != nil { + return "", "", fmt.Errorf("unsupported Yike size: %s", size) + } + mapping := map[[2]int][2]string{ + {1280, 720}: {"720P", "16:9"}, + {720, 1280}: {"720P", "9:16"}, + {960, 720}: {"720P", "4:3"}, + {720, 960}: {"720P", "3:4"}, + {720, 720}: {"720P", "1:1"}, + {1920, 1080}: {"1080P", "16:9"}, + {1080, 1920}: {"1080P", "9:16"}, + {1440, 1080}: {"1080P", "4:3"}, + {1080, 1440}: {"1080P", "3:4"}, + {1080, 1080}: {"1080P", "1:1"}, + } + mapped, ok := mapping[[2]int{width, height}] + if !ok { + return "", "", fmt.Errorf("unsupported Yike size: %s", size) + } + resolution, aspectRatio = mapped[0], mapped[1] + } + } + if strings.TrimSpace(metadataResolution) != "" { + resolution = strings.ToUpper(strings.TrimSpace(metadataResolution)) + } + if strings.TrimSpace(metadataAspectRatio) != "" { + aspectRatio = strings.TrimSpace(metadataAspectRatio) + } + if resolution != "720P" && resolution != "1080P" { + return "", "", fmt.Errorf("unsupported Yike resolution: %s", resolution) + } + validAspectRatios := map[string]bool{"16:9": true, "9:16": true, "4:3": true, "3:4": true, "1:1": true} + if !validAspectRatios[aspectRatio] { + return "", "", fmt.Errorf("unsupported Yike aspect_ratio: %s", aspectRatio) + } + return resolution, aspectRatio, nil +} + +func buildEndpoint(baseURL string, query url.Values) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "", fmt.Errorf("invalid Yike base URL: %w", err) + } + if !strings.EqualFold(parsed.Scheme, "https") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.Opaque != "" { + return "", fmt.Errorf("invalid Yike base URL") + } + // The canonical URI is part of the V3 signature. Custom channel URLs may + // select a host, but must not alter Yike's RPC root path. + parsed.Scheme = "https" + parsed.Path = "/" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.ForceQuery = false + values := make(url.Values, len(query)) + for key, items := range query { + for _, item := range items { + values.Add(key, item) + } + } + parsed.RawQuery = canonicalQuery(values) + return parsed.String(), nil +} + +func isSupportedModel(modelName string) bool { + for _, candidate := range modelList { + if modelName == candidate { + return true + } + } + return false +} + +func isPublicHTTPURL(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.User != nil || parsed.Host == "" || parsed.Fragment != "" || + (parsed.Scheme != "http" && parsed.Scheme != "https") { + return false + } + host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") || + strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") { + return false + } + if ip := net.ParseIP(host); ip != nil { + return !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsUnspecified() && + !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() + } + return true +} + +func sanitizeErrorMessage(message string) string { + message = strings.TrimSpace(message) + runes := []rune(message) + if len(runes) > 512 { + message = string(runes[:512]) + "..." + } + for _, field := range strings.Fields(message) { + if strings.HasPrefix(field, "http://") || strings.HasPrefix(field, "https://") { + if parsed, err := url.Parse(field); err == nil && parsed.Host != "" { + message = strings.ReplaceAll(message, field, parsed.Scheme+"://"+parsed.Host+"/***masked***") + } + } + } + return sensitiveErrorValuePattern.ReplaceAllString(message, "$1=***masked***") +} + +var sensitiveErrorValuePattern = regexp.MustCompile(`(?i)\b(accesskeyid|accesskeysecret|authorization|signature)\s*[:=]\s*[^\s,]+`) + +func parseJobOutput(raw string) (jobOutput, error) { + var output jobOutput + if strings.TrimSpace(raw) == "" { + return output, fmt.Errorf("Yike task output is empty") + } + if err := common.Unmarshal([]byte(raw), &output); err != nil { + return output, errors.Wrap(err, "unmarshal Yike task output failed") + } + return output, nil +} diff --git a/relay/channel/task/yike/adaptor_test.go b/relay/channel/task/yike/adaptor_test.go new file mode 100644 index 000000000000..25eb375af43f --- /dev/null +++ b/relay/channel/task/yike/adaptor_test.go @@ -0,0 +1,443 @@ +package yike + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relaykitdto "github.com/QuantumNous/new-api/relaykit/dto" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertRequestTextToVideo(t *testing.T) { + req := relaycommon.TaskSubmitReq{ + Prompt: "一只柯基在草地上玩耍", + Duration: 5, + Size: "1280x720", + } + + jobType, query, err := convertRequest(req, "happyhorse-1.1", "task_public") + require.NoError(t, err) + assert.Equal(t, "text_to_video", jobType) + assert.Equal(t, "happyhorse-1.1", query.Get("Model")) + assert.Equal(t, "720P", query.Get("Resolution")) + assert.Equal(t, "16:9", query.Get("AspectRatio")) + assert.Equal(t, "task_public", query.Get("ClientToken")) + assert.Equal(t, "1", query.Get("N")) + assert.Empty(t, query.Get("JobParameters")) + + var input upstreamInput + require.NoError(t, json.Unmarshal([]byte(query.Get("Input")), &input)) + assert.Equal(t, req.Prompt, input.Prompt) + assert.Empty(t, input.Medias) +} + +func TestFetchAccountCreditUsesReadOnlySignedRequest(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/", r.URL.Path) + assert.Empty(t, r.URL.RawQuery) + assert.Equal(t, accountCreditAction, r.Header.Get("x-acs-action")) + assert.Equal(t, apiVersion, r.Header.Get("x-acs-version")) + assert.Contains(t, r.Header.Get("Authorization"), "Credential=test-access-key") + assert.Zero(t, r.ContentLength) + _, _ = w.Write([]byte(`{ + "RequestId":"request-id", + "MembershipInfo":{"EndTime":"1784179281"}, + "CreditInfo":{ + "ResourceCreditQuota":10000, + "PackCreditQuota":20000, + "GrantedCreditQuota":200, + "ResourceCreditQuotaUsage":2000, + "PackCreditQuotaUsage":5000, + "GrantedCreditQuotaUsage":0 + } +}`)) + })) + defer server.Close() + + credit, err := FetchAccountCredit(context.Background(), server.URL, "test-access-key|test-access-secret", server.Client()) + require.NoError(t, err) + assert.Equal(t, "7000", credit.Remaining.String()) + assert.Equal(t, "23200", credit.Used.String()) + assert.Equal(t, "30200", credit.Granted.String()) + assert.EqualValues(t, 1784179281, credit.ExpiresAt) +} + +func TestFetchAccountCreditRejectsMissingCreditInfo(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"RequestId":"request-id"}`)) + })) + defer server.Close() + + _, err := FetchAccountCredit(context.Background(), server.URL, "test-access-key|test-access-secret", server.Client()) + require.ErrorContains(t, err, "CreditInfo") +} + +func TestFetchTaskRejectsNonSuccessHTTPStatus(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, getAction, r.Header.Get("x-acs-action")) + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"Code":"Throttling"}`)) + })) + defer server.Close() + + adaptor := &TaskAdaptor{httpClient: server.Client()} + response, err := adaptor.FetchTask(server.URL, "test-access-key|test-access-secret", map[string]any{"task_id": "job-1"}, "") + + require.Error(t, err) + assert.Nil(t, response) + assert.ErrorContains(t, err, "HTTP 429") +} + +func TestConvertRequestImageToVideo(t *testing.T) { + req := relaycommon.TaskSubmitReq{ + Prompt: "让图片动起来", + Image: "https://example.com/start.jpg", + Size: "1080x1920", + } + + jobType, query, err := convertRequest(req, "wan2.7", "") + require.NoError(t, err) + assert.Equal(t, "image_to_video", jobType) + assert.Equal(t, "1080P", query.Get("Resolution")) + assert.Equal(t, "9:16", query.Get("AspectRatio")) + + var input upstreamInput + require.NoError(t, json.Unmarshal([]byte(query.Get("Input")), &input)) + require.Len(t, input.Medias, 1) + assert.Equal(t, "image", input.Medias[0].Type) + assert.Equal(t, "https://example.com/start.jpg", input.Medias[0].URL) + assert.Contains(t, query.Get("Input"), `"Url":"https://example.com/start.jpg"`) + assert.NotContains(t, query.Get("Input"), `"URL"`) +} + +func TestConvertRequestFirstLastFrameWithMetadata(t *testing.T) { + req := relaycommon.TaskSubmitReq{ + Prompt: "镜头从白天过渡到夜晚", + Metadata: map[string]interface{}{ + "job_type": "first_last_frame", + "resolution": "1080P", + "aspect_ratio": "4:3", + "medias": []map[string]interface{}{ + {"type": "image", "url": "https://example.com/first.png"}, + {"type": "image", "media_id": "media-last"}, + }, + }, + } + + jobType, query, err := convertRequest(req, "Wonder-Standard", "") + require.NoError(t, err) + assert.Equal(t, "first_last_frame", jobType) + assert.Equal(t, "1080P", query.Get("Resolution")) + assert.Equal(t, "4:3", query.Get("AspectRatio")) + + var input upstreamInput + require.NoError(t, json.Unmarshal([]byte(query.Get("Input")), &input)) + require.Len(t, input.Medias, 2) + assert.Equal(t, "media-last", input.Medias[1].MediaID) +} + +func TestConvertRequestReferenceToVideo(t *testing.T) { + req := relaycommon.TaskSubmitReq{ + Prompt: "keep the subject identity", + Metadata: map[string]any{ + "job_type": "reference_to_video", + "medias": []map[string]any{ + {"type": "image", "media_id": "imported-face"}, + {"type": "video", "url": "https://example.com/action.mp4"}, + }, + }, + } + + jobType, query, err := convertRequest(req, "Wonder-Pro", "task-reference") + + require.NoError(t, err) + assert.Equal(t, "reference_to_video", jobType) + assert.Equal(t, "task-reference", query.Get("ClientToken")) + var input upstreamInput + require.NoError(t, json.Unmarshal([]byte(query.Get("Input")), &input)) + require.Len(t, input.Medias, 2) + assert.Equal(t, "imported-face", input.Medias[0].MediaID) + assert.Equal(t, "video", input.Medias[1].Type) +} + +func TestConvertRequestValidation(t *testing.T) { + tests := []struct { + name string + req relaycommon.TaskSubmitReq + want string + }{ + { + name: "prompt is required", + req: relaycommon.TaskSubmitReq{Prompt: " "}, + want: "prompt is required", + }, + { + name: "multiple inputs require explicit job type", + req: relaycommon.TaskSubmitReq{ + Prompt: "test", + Images: []string{"https://example.com/1.png", "https://example.com/2.png"}, + }, + want: "metadata.job_type", + }, + { + name: "duration is bounded", + req: relaycommon.TaskSubmitReq{Prompt: "test", Duration: 16}, + want: "between 4 and 15", + }, + { + name: "data URL is rejected", + req: relaycommon.TaskSubmitReq{Prompt: "test", Image: "data:image/png;base64,abc"}, + want: "public HTTP(S) URL", + }, + { + name: "private URL is rejected", + req: relaycommon.TaskSubmitReq{Prompt: "test", Image: "http://127.0.0.1/start.png"}, + want: "public HTTP(S) URL", + }, + { + name: "unsupported size is rejected", + req: relaycommon.TaskSubmitReq{Prompt: "test", Size: "1024x768"}, + want: "unsupported Yike size", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, _, err := convertRequest(test.req, "wan2.7", "") + require.Error(t, err) + assert.Contains(t, err.Error(), test.want) + }) + } +} + +func TestConvertRequestRequiresNOneAndRejectsJobParameters(t *testing.T) { + for _, n := range []int{0, 2, 4} { + t.Run("n="+strconv.Itoa(n), func(t *testing.T) { + req := relaycommon.TaskSubmitReq{Prompt: "test", Metadata: map[string]any{"n": n}} + _, _, err := convertRequest(req, "wan2.7", "") + require.ErrorContains(t, err, "exactly 1") + }) + } + + _, query, err := convertRequest(relaycommon.TaskSubmitReq{Prompt: "test"}, "wan2.7", "") + require.NoError(t, err) + assert.Equal(t, "1", query.Get("N")) + + _, _, err = convertRequest(relaycommon.TaskSubmitReq{ + Prompt: "test", + Metadata: map[string]any{"job_parameters": map[string]any{"unsafe": true}}, + }, "wan2.7", "") + require.ErrorContains(t, err, "job_parameters is not supported") + + _, query, err = convertRequest(relaycommon.TaskSubmitReq{ + Prompt: "test", + Metadata: map[string]any{"user_data": map[string]any{"private": true}}, + }, "wan2.7", "") + require.NoError(t, err) + assert.Empty(t, query.Get("UserData")) +} + +func TestConvertMediaRequiresURLOrMediaIDExclusively(t *testing.T) { + _, err := convertMedia(inputMedia{Type: "image", URL: "https://example.com/a.png", MediaID: "media-1"}) + require.ErrorContains(t, err, "exactly one") + + _, err = convertMedia(inputMedia{Type: "image"}) + require.ErrorContains(t, err, "exactly one") +} + +func TestCredentialsAndEndpointValidation(t *testing.T) { + for _, key := range []string{"ak", "ak|", "|sk", "ak|sk|extra", "ak|sk\nnext|secret"} { + _, _, err := parseCredentials(key) + require.Error(t, err, key) + } + ak, sk, err := parseCredentials(" ak | sk ") + require.NoError(t, err) + assert.Equal(t, "ak", ak) + assert.Equal(t, "sk", sk) + + _, err = buildEndpoint("http://yike.example.test", nil) + require.Error(t, err) + endpoint, err := buildEndpoint("https://yike.example.test/custom/path?unsafe=1", url.Values{"JobId": {"job-1"}}) + require.NoError(t, err) + parsed, err := url.Parse(endpoint) + require.NoError(t, err) + assert.Equal(t, "/", parsed.Path) + assert.Empty(t, parsed.Query().Get("unsafe")) + assert.Equal(t, "job-1", parsed.Query().Get("JobId")) + + require.NoError(t, ValidateChannelCredentials("ak-1|sk-1\r\nak-2|sk-2")) + require.Error(t, ValidateChannelCredentials("ak-1|sk-1\nak-2|sk-2|extra")) + require.NoError(t, ValidateChannelEndpoint("https://yike.ap-southeast-1.aliyuncs.com")) +} + +func TestMappedModelIsRevalidatedAsUserError(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(`{"model":"public-alias","prompt":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + OriginModelName: "public-alias", + ChannelMeta: &relaycommon.ChannelMeta{ + ApiKey: "ak|sk", + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + } + adaptor := &TaskAdaptor{} + require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info)) + + info.UpstreamModelName = "not-a-yike-model" + taskErr := adaptor.ValidateMappedModel(c, info) + + require.NotNil(t, taskErr) + assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode) + assert.Contains(t, taskErr.Message, "unsupported Yike model") +} + +func TestValidateRequestRejectsRemix(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos/task_origin/remix", strings.NewReader(`{"model":"wan2.7","prompt":"test"}`)) + c.Request.Header.Set("Content-Type", "application/json") + info := &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{ApiKey: "ak|sk"}, + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: constant.TaskActionRemix}, + } + + taskErr := (&TaskAdaptor{}).ValidateRequestAndSetAction(c, info) + + require.NotNil(t, taskErr) + assert.Equal(t, "unsupported_yike_remix", taskErr.Code) + assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode) +} + +func TestMultipartAndBinaryInputsAreRejected(t *testing.T) { + for _, contentType := range []string{"multipart/form-data; boundary=test", "application/octet-stream"} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader("binary")) + c.Request.Header.Set("Content-Type", contentType) + require.ErrorContains(t, rejectMultipartFiles(c), "does not support") + } +} + +func TestParseTaskResult(t *testing.T) { + adaptor := &TaskAdaptor{} + created, err := adaptor.ParseTaskResult([]byte(`{"RequestId":"req","VideoGenerationJob":{"JobId":"job","Status":"Created"}}`)) + require.NoError(t, err) + assert.Equal(t, string(model.TaskStatusSubmitted), created.Status) + + queued, err := adaptor.ParseTaskResult([]byte(`{"RequestId":"req","VideoGenerationJob":{"JobId":"job","Status":"Queuing"}}`)) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusQueued, queued.Status) + + executing, err := adaptor.ParseTaskResult([]byte(`{"RequestId":"req","VideoGenerationJob":{"JobId":"job","Status":"Executing"}}`)) + require.NoError(t, err) + assert.Equal(t, string(model.TaskStatusInProgress), executing.Status) + + finishedBody := []byte(`{"RequestId":"req","VideoGenerationJob":{"JobId":"job","Status":"Finished","Output":"{\"Medias\":[{\"MediaId\":\"m1\",\"OutputUrl\":\"https://example.com/1.mp4\"},{\"MediaId\":\"m2\",\"OutputUrl\":\"https://example.com/2.mp4\"}]}"}}`) + finished, err := adaptor.ParseTaskResult(finishedBody) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusSuccess, finished.Status) + assert.Equal(t, "https://example.com/1.mp4", finished.Url) + + failed, err := adaptor.ParseTaskResult([]byte(`{"VideoGenerationJob":{"JobId":"job","Status":"Failed","ErrorMessage":"failed at https://oss.example.test/file.mp4?token=secret"}}`)) + require.NoError(t, err) + assert.Equal(t, model.TaskStatusFailure, failed.Status) + assert.NotContains(t, failed.Reason, "token=secret") + + _, err = adaptor.ParseTaskResult([]byte(`{"Code":"Throttling","Message":"try later"}`)) + require.ErrorContains(t, err, "Throttling") +} + +func TestSanitizeErrorMessageMasksCredentials(t *testing.T) { + got := sanitizeErrorMessage("AccessKeyId=LTAI-secret AccessKeySecret=very-secret Authorization:ACS3-secret Signature=abc") + assert.NotContains(t, got, "LTAI-secret") + assert.NotContains(t, got, "very-secret") + assert.NotContains(t, got, "ACS3-secret") + assert.NotContains(t, got, "Signature=abc") +} + +func TestDoResponseReturnsPublicTaskID(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"RequestId":"req","JobId":"upstream-job"}`)), + } + info := &relaycommon.RelayInfo{ + OriginModelName: "wan2.7", + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + } + + upstreamTaskID, rawBody, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, info) + + require.Nil(t, taskErr) + assert.Equal(t, "upstream-job", upstreamTaskID) + assert.JSONEq(t, `{"RequestId":"req","JobId":"upstream-job"}`, string(rawBody)) + var video relaykitdto.OpenAIVideo + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &video)) + assert.Equal(t, "task_public", video.ID) + assert.Equal(t, "task_public", video.TaskID) + assert.Equal(t, "wan2.7", video.Model) + assert.Equal(t, relaykitdto.VideoStatusQueued, video.Status) +} + +func TestConvertToOpenAIVideoReturnsOnlyProxyURL(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusSuccess, + Progress: "100%", + CreatedAt: 100, + UpdatedAt: 200, + Properties: model.Properties{OriginModelName: "Wonder-Pro"}, + Data: json.RawMessage(`{"VideoGenerationJob":{"JobId":"upstream-job","Status":"Finished","Output":"{\"Medias\":[{\"OutputUrl\":\"https://example.com/1.mp4\"},{\"OutputUrl\":\"https://example.com/2.mp4\"}]}"}}`), + } + + body, err := (&TaskAdaptor{}).ConvertToOpenAIVideo(task) + + require.NoError(t, err) + var video relaykitdto.OpenAIVideo + require.NoError(t, json.Unmarshal(body, &video)) + assert.Equal(t, relaykitdto.VideoStatusCompleted, video.Status) + assert.Contains(t, video.Metadata["url"], "/v1/videos/task_public/content") + assert.NotContains(t, string(body), "https://example.com/1.mp4") + assert.NotContains(t, video.Metadata, "urls") +} + +func TestFetchTaskSignsGetVideoGenerationJob(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "GetVideoGenerationJob", r.Header.Get("x-acs-action")) + assert.Equal(t, apiVersion, r.Header.Get("x-acs-version")) + assert.Equal(t, "job-123", r.URL.Query().Get("JobId")) + assert.Contains(t, r.Header.Get("Authorization"), "Credential=ak-test") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"VideoGenerationJob":{"JobId":"job-123","Status":"Executing"}}`) + })) + defer server.Close() + + adaptor := &TaskAdaptor{httpClient: server.Client(), signer: &v3Signer{ + now: func() time.Time { return time.Date(2026, 7, 15, 8, 30, 45, 0, time.UTC) }, + nonce: func() (string, error) { + return "nonce", nil + }, + }} + resp, err := adaptor.FetchTask(server.URL, "ak-test|sk-test", map[string]any{"task_id": "job-123"}, "") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/relay/channel/task/yike/signer.go b/relay/channel/task/yike/signer.go new file mode 100644 index 000000000000..6444268fc421 --- /dev/null +++ b/relay/channel/task/yike/signer.go @@ -0,0 +1,145 @@ +package yike + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +const signatureAlgorithm = "ACS3-HMAC-SHA256" + +type v3Signer struct { + now func() time.Time + nonce func() (string, error) +} + +func defaultV3Signer() *v3Signer { + return &v3Signer{ + now: time.Now, + nonce: randomNonce, + } +} + +func randomNonce() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func (s *v3Signer) sign(req *http.Request, action, version, accessKeyID, accessKeySecret string) error { + if req == nil || req.URL == nil { + return fmt.Errorf("request URL is required") + } + if strings.TrimSpace(accessKeyID) == "" || strings.TrimSpace(accessKeySecret) == "" { + return fmt.Errorf("AccessKeyId and AccessKeySecret are required") + } + if s == nil { + s = defaultV3Signer() + } + + nonce, err := s.nonce() + if err != nil { + return fmt.Errorf("generate signature nonce: %w", err) + } + // Yike's RPC methods carry all business parameters in the query. + payloadHash := sha256.Sum256(nil) + payloadHashHex := hex.EncodeToString(payloadHash[:]) + + req.Host = req.URL.Host + req.Header.Set("x-acs-action", action) + req.Header.Set("x-acs-version", version) + req.Header.Set("x-acs-date", s.now().UTC().Format("2006-01-02T15:04:05Z")) + req.Header.Set("x-acs-signature-nonce", nonce) + req.Header.Set("x-acs-content-sha256", payloadHashHex) + + signedHeaderNames := []string{ + "host", + "x-acs-action", + "x-acs-content-sha256", + "x-acs-date", + "x-acs-signature-nonce", + "x-acs-version", + } + var canonicalHeaders strings.Builder + for _, name := range signedHeaderNames { + value := req.Header.Get(name) + if name == "host" { + value = req.URL.Host + } + canonicalHeaders.WriteString(name) + canonicalHeaders.WriteByte(':') + canonicalHeaders.WriteString(strings.TrimSpace(value)) + canonicalHeaders.WriteByte('\n') + } + signedHeaders := strings.Join(signedHeaderNames, ";") + canonicalURI := req.URL.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + // Alibaba Cloud V3 signs the canonical RPC query together with the payload + // hash; Yike's payload hash is the SHA-256 of its empty POST body. + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI, + canonicalQuery(req.URL.Query()), + canonicalHeaders.String(), + signedHeaders, + payloadHashHex, + }, "\n") + canonicalHash := sha256.Sum256([]byte(canonicalRequest)) + stringToSign := signatureAlgorithm + "\n" + hex.EncodeToString(canonicalHash[:]) + signature := hmacSHA256Hex([]byte(accessKeySecret), []byte(stringToSign)) + + req.Header.Set("Authorization", fmt.Sprintf( + "%s Credential=%s,SignedHeaders=%s,Signature=%s", + signatureAlgorithm, + accessKeyID, + signedHeaders, + signature, + )) + return nil +} + +func canonicalQuery(values url.Values) string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + items := append([]string(nil), values[key]...) + sort.Strings(items) + if len(items) == 0 { + parts = append(parts, percentEncode(key)+"=") + continue + } + for _, value := range items { + parts = append(parts, percentEncode(key)+"="+percentEncode(value)) + } + } + return strings.Join(parts, "&") +} + +func percentEncode(value string) string { + encoded := url.QueryEscape(value) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} + +func hmacSHA256Hex(key, data []byte) string { + h := hmac.New(sha256.New, key) + _, _ = h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/relay/channel/task/yike/signer_test.go b/relay/channel/task/yike/signer_test.go new file mode 100644 index 000000000000..58cefffb3eff --- /dev/null +++ b/relay/channel/task/yike/signer_test.go @@ -0,0 +1,46 @@ +package yike + +import ( + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// This vector is published in Alibaba Cloud's "V3 request structure and +// signature" documentation (RunInstances fixed-parameter example). Keeping it +// separate from the Yike vector prevents the implementation from testing only +// against an expectation generated by itself. +func TestV3SignerAlibabaCloudOfficialVector(t *testing.T) { + endpoint, err := buildEndpoint("https://ecs.cn-shanghai.aliyuncs.com", url.Values{ + "ImageId": {"win2019_1809_x64_dtc_zh-cn_40G_alibase_20230811.vhd"}, + "RegionId": {"cn-shanghai"}, + }) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, endpoint, nil) + require.NoError(t, err) + + signer := &v3Signer{ + now: func() time.Time { + return time.Date(2023, 10, 26, 10, 22, 32, 0, time.UTC) + }, + nonce: func() (string, error) { + return "3156853299f313e23d1673dc12e1703d", nil + }, + } + require.NoError(t, signer.sign(req, "RunInstances", "2014-05-26", "YourAccessKeyId", "YourAccessKeySecret")) + + assert.Equal(t, + "ACS3-HMAC-SHA256 Credential=YourAccessKeyId,SignedHeaders=host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version,Signature=06563a9e1b43f5dfe96b81484da74bceab24a1d853912eee15083a6f0f3283c0", + req.Header.Get("Authorization"), + ) +} + +func TestCanonicalQueryUsesAlibabaPercentEncoding(t *testing.T) { + query := url.Values{"value": {"space star* tilde~ plus+"}} + + assert.Equal(t, "value=space%20star%2A%20tilde~%20plus%2B", canonicalQuery(query)) +} diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index ab9937595c5a..8508cd15bf21 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -1,6 +1,7 @@ package common import ( + "errors" "fmt" "net/http" "net/url" @@ -67,6 +68,19 @@ func SanitizeURLForLog(rawURL string) string { return parsedURL.String() } +// SanitizeErrorForLog removes a net/url transport error's raw URL before it is +// logged. That URL may contain signed credentials or an RPC Input query. +func SanitizeErrorForLog(err error) string { + if err == nil { + return "" + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + return fmt.Sprintf("%s %s: %v", urlErr.Op, SanitizeURLForLog(urlErr.URL), urlErr.Err) + } + return err.Error() +} + func isSensitiveURLQueryKey(key string) bool { normalized := strings.ToLower(strings.TrimSpace(key)) switch normalized { @@ -90,12 +104,17 @@ func isSensitiveURLQueryKey(key string) bool { "awsaccesskeyid", "x-amz-credential", "x-amz-security-token", - "x-amz-signature": + "x-amz-signature", + "input", + "userdata", + "jobparameters": return true } return strings.Contains(normalized, "token") || strings.Contains(normalized, "secret") || - strings.Contains(normalized, "signature") + strings.Contains(normalized, "signature") || + strings.Contains(normalized, "accesskeyid") || + strings.Contains(normalized, "credential") } func GetAPIVersion(c *gin.Context) string { diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go index 0746d34468a3..8a4ea0c53ae3 100644 --- a/relay/common/relay_utils_test.go +++ b/relay/common/relay_utils_test.go @@ -30,7 +30,7 @@ func TestSanitizeURLForLogMasksSensitiveQueryValues(t *testing.T) { } func TestSanitizeURLForLogMasksAWSAndSecretLikeQueryKeys(t *testing.T) { - rawURL := "https://example.test/path?X-Amz-Credential=credential&X-Amz-Signature=signature&session_token=session&client_secret=secret&model=gpt-test" + rawURL := "https://example.test/path?X-Amz-Credential=credential&X-Amz-Signature=signature&OSSAccessKeyId=access-key&session_token=session&client_secret=secret&model=gpt-test" got := SanitizeURLForLog(rawURL) @@ -38,6 +38,7 @@ func TestSanitizeURLForLogMasksAWSAndSecretLikeQueryKeys(t *testing.T) { assert.NotContains(t, got, "X-Amz-Signature=signature") assert.NotContains(t, got, "session_token=session") assert.NotContains(t, got, "client_secret=secret") + assert.NotContains(t, got, "OSSAccessKeyId=access-key") parsedURL, err := url.Parse(got) require.NoError(t, err) query := parsedURL.Query() @@ -45,6 +46,7 @@ func TestSanitizeURLForLogMasksAWSAndSecretLikeQueryKeys(t *testing.T) { assert.Equal(t, "***masked***", query.Get("X-Amz-Signature")) assert.Equal(t, "***masked***", query.Get("session_token")) assert.Equal(t, "***masked***", query.Get("client_secret")) + assert.Equal(t, "***masked***", query.Get("OSSAccessKeyId")) assert.Equal(t, "gpt-test", query.Get("model")) } @@ -56,6 +58,36 @@ func TestSanitizeURLForLogKeepsURLWithoutSensitiveQuery(t *testing.T) { assert.Equal(t, rawURL, got) } +func TestSanitizeURLForLogMasksYikePayloadQueryValues(t *testing.T) { + rawURL := "https://yike.example.test/?Input=%7B%22Prompt%22%3A%22secret+prompt%22%7D&UserData=private&JobParameters=%7B%7D&Model=wan2.7" + + got := SanitizeURLForLog(rawURL) + + assert.NotContains(t, got, "secret+prompt") + assert.NotContains(t, got, "private") + parsedURL, err := url.Parse(got) + require.NoError(t, err) + query := parsedURL.Query() + assert.Equal(t, "***masked***", query.Get("Input")) + assert.Equal(t, "***masked***", query.Get("UserData")) + assert.Equal(t, "***masked***", query.Get("JobParameters")) + assert.Equal(t, "wan2.7", query.Get("Model")) +} + +func TestSanitizeErrorForLogMasksURLQuery(t *testing.T) { + err := &url.Error{ + Op: "Post", + URL: "https://yike.example.test/?Input=secret-prompt&ClientToken=task-secret&Model=wan2.7", + Err: assert.AnError, + } + + got := SanitizeErrorForLog(err) + + assert.NotContains(t, got, "secret-prompt") + assert.NotContains(t, got, "task-secret") + assert.Contains(t, got, "Model=wan2.7") +} + func TestValidateMultipartDirectNormalizesImageField(t *testing.T) { gin.SetMode(gin.TestMode) body := strings.NewReader(`{"model":"wan2.7-i2v","prompt":"animate","image":" https://example.com/first.png "}`) diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index e6298dc034f3..c8031085d250 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -43,6 +43,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/task/suno" taskvertex "github.com/QuantumNous/new-api/relay/channel/task/vertex" taskVidu "github.com/QuantumNous/new-api/relay/channel/task/vidu" + taskYike "github.com/QuantumNous/new-api/relay/channel/task/yike" "github.com/QuantumNous/new-api/relay/channel/tencent" "github.com/QuantumNous/new-api/relay/channel/vertex" "github.com/QuantumNous/new-api/relay/channel/volcengine" @@ -168,6 +169,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &taskGemini.TaskAdaptor{} case constant.ChannelTypeMiniMax: return &hailuo.TaskAdaptor{} + case constant.ChannelTypeYike: + return &taskYike.TaskAdaptor{} } } return nil diff --git a/relay/relay_task.go b/relay/relay_task.go index fb384d18937a..535e560c0403 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -171,6 +171,11 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe if err := helper.ModelMappedHelper(c, info, nil); err != nil { return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest) } + if validator, ok := adaptor.(channel.MappedModelValidator); ok { + if taskErr := validator.ValidateMappedModel(c, info); taskErr != nil { + return nil, taskErr + } + } // 3. 预生成公开 task ID(仅首次) if info.PublicTaskID == "" { @@ -223,6 +228,14 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe } if resp != nil && resp.StatusCode != http.StatusOK { responseBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if info.ChannelType == constant.ChannelTypeYike { + return nil, service.TaskErrorWrapper( + fmt.Errorf("Yike submit returned HTTP %d", resp.StatusCode), + "fail_to_fetch_task", + resp.StatusCode, + ) + } return nil, service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode) } diff --git a/service/task_polling.go b/service/task_polling.go index 250201ae0525..879a7613e534 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -422,7 +422,11 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann return ctx.Err() } if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { - logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error())) + errorMessage := err.Error() + if cacheGetChannel.Type == constant.ChannelTypeYike { + errorMessage = relaycommon.SanitizeErrorForLog(err) + } + logger.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, errorMessage)) } if disablePollingSleep || i == len(taskIds)-1 { continue @@ -472,7 +476,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return fmt.Errorf("readAll failed for task %s: %w", taskId, err) } - logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) + if ch.Type != constant.ChannelTypeYike { + logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) + } snap := task.Snapshot() @@ -492,9 +498,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) } - task.Data = redactVideoResponseBody(responseBody) + task.Data = redactVideoResponseBody(responseBody, ch.Type) - logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) + if ch.Type == constant.ChannelTypeYike { + logger.LogDebug(ctx, "updateVideoSingleTask Yike status: task=%s status=%s progress=%s", taskId, taskResult.Status, taskResult.Progress) + } else { + logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) + } now := time.Now().Unix() if taskResult.Status == "" { @@ -513,7 +523,11 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult = relaycommon.FailTaskInfo("upstream returned error") } else { // unknown error format, log original response - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) + if ch.Type == constant.ChannelTypeYike { + logger.LogError(ctx, fmt.Sprintf("Task %s returned an unrecognized Yike polling response", taskId)) + } else { + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) + } taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message") } } @@ -601,7 +615,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return nil } -func redactVideoResponseBody(body []byte) []byte { +func redactVideoResponseBody(body []byte, channelType int) []byte { var m map[string]any if err := common.Unmarshal(body, &m); err != nil { return body @@ -620,6 +634,17 @@ func redactVideoResponseBody(body []byte) []byte { } } } + if channelType == constant.ChannelTypeYike { + if job, ok := m["VideoGenerationJob"].(map[string]any); ok { + // Keep Output for the legacy /v1/video/generations task response, which + // follows the same provider-data behavior as other video channels. + // Request inputs remain redacted because they may contain prompts, + // private media locations, or caller-defined metadata. + delete(job, "Input") + delete(job, "UserData") + delete(job, "JobParameters") + } + } b, err := common.Marshal(m) if err != nil { return body diff --git a/service/task_polling_test.go b/service/task_polling_test.go index 57b382fd6af5..523763f17340 100644 --- a/service/task_polling_test.go +++ b/service/task_polling_test.go @@ -34,6 +34,32 @@ type sunoFailurePollingAdaptor struct { failReason string } +type yikeSuccessPollingAdaptor struct { + fetchedKey string + outputURL string +} + +func (a *yikeSuccessPollingAdaptor) Init(_ *relaycommon.RelayInfo) {} + +func (a *yikeSuccessPollingAdaptor) FetchTask(_ string, key string, _ map[string]any, _ string) (*http.Response, error) { + a.fetchedKey = key + body := `{"RequestId":"req","VideoGenerationJob":{"JobId":"upstream-yike","Status":"Finished","Output":"{\"Medias\":[{\"OutputUrl\":\"` + a.outputURL + `\"}]}"}}` + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(body))}, nil +} + +func (a *yikeSuccessPollingAdaptor) ParseTaskResult(_ []byte) (*relaycommon.TaskInfo, error) { + return &relaycommon.TaskInfo{ + TaskID: "upstream-yike", + Status: model.TaskStatusSuccess, + Progress: "100%", + Url: a.outputURL, + }, nil +} + +func (a *yikeSuccessPollingAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { + return 0 +} + func (a *sunoFailurePollingAdaptor) Init(_ *relaycommon.RelayInfo) {} func (a *sunoFailurePollingAdaptor) FetchTask(_ string, _ string, body map[string]any, _ string) (*http.Response, error) { @@ -130,6 +156,67 @@ func (a *taskPollingFetchAdaptor) fetchedTaskIDs() []string { return append([]string(nil), a.taskIDs...) } +func TestRedactVideoResponseBodyKeepsYikeOutputAndRemovesRequestFields(t *testing.T) { + body := []byte(`{"RequestId":"req","VideoGenerationJob":{"JobId":"job","Status":"Finished","Input":"{\"Prompt\":\"private prompt\",\"Medias\":[{\"Url\":\"https://media.example.test/private.jpg\"}]}","UserData":"{\"tenant\":\"private\"}","JobParameters":"{\"private\":true}","Output":"{\"Medias\":[{\"OutputUrl\":\"https://oss.example.test/video.mp4?token=secret\"}]}"}}`) + + redacted := redactVideoResponseBody(body, constant.ChannelTypeYike) + + assert.Contains(t, string(redacted), "OutputUrl") + assert.Contains(t, string(redacted), "token=secret") + assert.NotContains(t, string(redacted), "private prompt") + assert.NotContains(t, string(redacted), "private.jpg") + assert.NotContains(t, string(redacted), "tenant") + assert.NotContains(t, string(redacted), "JobParameters") + assert.Contains(t, string(redacted), `"Status":"Finished"`) +} + +func TestYikePollingUsesPersistedKeyAndKeepsProviderOutputData(t *testing.T) { + truncate(t) + const channelID = 611 + baseURL := "https://yike.example.test" + channel := &model.Channel{ + Id: channelID, + Type: constant.ChannelTypeYike, + Name: "yike_polling", + Key: "new-ak|new-sk\nother-ak|other-sk", + BaseURL: &baseURL, + Status: common.ChannelStatusEnabled, + } + require.NoError(t, model.DB.Create(channel).Error) + + task := &model.Task{ + TaskID: "task_yike_public", + Platform: constant.TaskPlatform("61"), + UserId: 1, + ChannelId: channelID, + Action: constant.TaskActionTextGenerate, + Status: model.TaskStatusInProgress, + Progress: "50%", + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + PrivateData: model.TaskPrivateData{ + Key: "selected-ak|selected-sk", + UpstreamTaskID: "upstream-yike", + }, + } + require.NoError(t, model.DB.Create(task).Error) + + outputURL := "https://oss.example.test/video.mp4?token=secret" + adaptor := &yikeSuccessPollingAdaptor{outputURL: outputURL} + err := updateVideoSingleTask(context.Background(), adaptor, channel, task.GetUpstreamTaskID(), map[string]*model.Task{ + task.GetUpstreamTaskID(): task, + }) + require.NoError(t, err) + assert.Equal(t, "selected-ak|selected-sk", adaptor.fetchedKey) + + var reloaded model.Task + require.NoError(t, model.DB.First(&reloaded, task.ID).Error) + assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), reloaded.Status) + assert.Equal(t, outputURL, reloaded.PrivateData.ResultURL) + assert.Contains(t, string(reloaded.Data), "OutputUrl") + assert.Contains(t, string(reloaded.Data), "token=secret") +} + func seedTaskPollingChannel(t *testing.T, id int, disableSleep bool) { t.Helper() ch := &model.Channel{ diff --git a/web/src/assets/custom/icon-yike.tsx b/web/src/assets/custom/icon-yike.tsx new file mode 100644 index 000000000000..a1ee52c8b448 --- /dev/null +++ b/web/src/assets/custom/icon-yike.tsx @@ -0,0 +1,50 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useId, type SVGProps } from 'react' + +type IconYikeProps = SVGProps & { + size?: number +} + +export function IconYike({ size = 20, ...props }: IconYikeProps) { + const gradientId = useId() + + return ( + + + + + + + + + + ) +} diff --git a/web/src/features/channels/components/channels-columns.tsx b/web/src/features/channels/components/channels-columns.tsx index 36dc8f677625..34e009004a3c 100644 --- a/web/src/features/channels/components/channels-columns.tsx +++ b/web/src/features/channels/components/channels-columns.tsx @@ -64,6 +64,8 @@ import { getChannelTypeIcon, getChannelTypeLabel, getResponseTimeConfig, + formatYikeCredits, + isYikeChannel, isMultiKeyChannel, parseModelsList, parseGroupsList, @@ -333,6 +335,7 @@ function BalanceCell({ channel }: { channel: Channel }) { const isTagRow = isTagAggregateRow(channel) const balance = channel.balance || 0 const usedQuota = channel.used_quota || 0 + const yikeChannel = isYikeChannel(channel.type) const [isUpdating, setIsUpdating] = useState(false) const [codexUsageOpen, setCodexUsageOpen] = useState(false) const [codexUsageResponse, setCodexUsageResponse] = @@ -358,9 +361,9 @@ function BalanceCell({ channel }: { channel: Channel }) { showSymbol: layout !== 'card', }) ) - const remainingFull = withSuffix( - formatCurrencyFromUSD(balance, balanceFormatOptions) - ) + const remainingFull = yikeChannel + ? formatYikeCredits(balance, t('Credits'), locale) + : withSuffix(formatCurrencyFromUSD(balance, balanceFormatOptions)) const usedDisplay = usedFull.length > MAX_INLINE_BALANCE_CHARS ? withSuffix( @@ -371,16 +374,18 @@ function BalanceCell({ channel }: { channel: Channel }) { }) ) : usedFull - const remainingDisplay = - remainingFull.length > MAX_INLINE_BALANCE_CHARS - ? withSuffix( + let remainingDisplay = remainingFull + if (remainingFull.length > MAX_INLINE_BALANCE_CHARS) { + remainingDisplay = yikeChannel + ? formatYikeCredits(balance, t('Credits'), locale, true) + : withSuffix( formatCurrencyFromUSD(balance, { compact: true, locale, showSymbol: layout !== 'card', }) ) - : remainingFull + } const usedLabel = `${t('Used:')} ${usedFull}` const remainingLabel = `${t('Remaining:')} ${remainingFull}` const maskedUsedLabel = `${t('Used:')} ${SENSITIVE_MASK}` diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx index a9f6d11e314f..8523f5b82ded 100644 --- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import { useQueryClient } from '@tanstack/react-query' -import { Loader2, RefreshCw, DollarSign } from 'lucide-react' +import { Coins, DollarSign, Loader2, RefreshCw } from 'lucide-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -29,7 +29,7 @@ import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' import { getCodexUsage, updateChannelBalance } from '../../api' -import { channelsQueryKeys } from '../../lib' +import { channelsQueryKeys, formatYikeCredits, isYikeChannel } from '../../lib' import { useChannels } from '../channels-provider' import { CodexUsageDialog, @@ -57,6 +57,7 @@ export function BalanceQueryDialog({ useState(null) const isCodex = currentRow?.type === 57 + const isYike = isYikeChannel(currentRow?.type) const handleQueryCodexUsage = async () => { const row = currentRow @@ -129,11 +130,13 @@ export function BalanceQueryDialog({ } const formatBalance = (bal: number) => - formatCurrencyFromUSD(bal, { - digitsLarge: 2, - digitsSmall: 4, - abbreviate: false, - }) + isYike + ? formatYikeCredits(bal, t('Credits')) + : formatCurrencyFromUSD(bal, { + digitsLarge: 2, + digitsSmall: 4, + abbreviate: false, + }) const formatDate = (timestamp: number) => { if (!timestamp) return 'Never' @@ -180,7 +183,7 @@ export function BalanceQueryDialog({
- + {isYike ? : } {t('Current Balance')}
diff --git a/web/src/features/channels/constants.ts b/web/src/features/channels/constants.ts index a3cb726d5418..41a4daf5bcff 100644 --- a/web/src/features/channels/constants.ts +++ b/web/src/features/channels/constants.ts @@ -81,12 +81,13 @@ export const CHANNEL_TYPES = { 58: 'Advanced Custom', 59: 'Sub2API', 60: 'New API', + 61: 'Yike', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ 1, 14, 33, 24, 43, 3, 41, 48, 60, 58, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 59, 22, 21, 44, 2, - 5, 36, 50, 51, 52, 53, 54, 55, 56, + 5, 36, 50, 51, 52, 61, 53, 54, 55, 56, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { @@ -389,7 +390,7 @@ export const FIELD_DESCRIPTIONS = { export const MODEL_FETCHABLE_TYPES = new Set([ 1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48, 57, 58, - 59, 60, + 59, 60, 61, ]) export const TYPE_TO_KEY_PROMPT: Record = { @@ -403,6 +404,7 @@ export const TYPE_TO_KEY_PROMPT: Record = { 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', 59: 'Enter API key for this channel', 60: 'Enter API key for this channel', + 61: 'Format: AccessKeyId|AccessKeySecret (one complete pair per line for multi-key)', } export const CHANNEL_TYPE_WARNINGS: Record = { diff --git a/web/src/features/channels/lib/__tests__/yike-balance.test.ts b/web/src/features/channels/lib/__tests__/yike-balance.test.ts new file mode 100644 index 000000000000..a17b1aaf9489 --- /dev/null +++ b/web/src/features/channels/lib/__tests__/yike-balance.test.ts @@ -0,0 +1,37 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + formatYikeCredits, + isYikeChannel, + YIKE_CHANNEL_TYPE, +} from '../yike-balance' + +describe('Yike credit balance', () => { + test('formats native credits without a currency symbol', () => { + assert.equal(formatYikeCredits(104, '积分', 'zh-CN'), '104 积分') + }) + + test('recognizes only the Yike channel type', () => { + assert.equal(isYikeChannel(YIKE_CHANNEL_TYPE), true) + assert.equal(isYikeChannel(1), false) + }) +}) diff --git a/web/src/features/channels/lib/channel-actions.ts b/web/src/features/channels/lib/channel-actions.ts index 7efee24d3da2..ae7a32fbfd4b 100644 --- a/web/src/features/channels/lib/channel-actions.ts +++ b/web/src/features/channels/lib/channel-actions.ts @@ -42,6 +42,7 @@ import { } from '../api' import { CHANNEL_STATUS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants' import type { ChannelTestResponse, CopyChannelParams } from '../types' +import { formatYikeCredits } from './yike-balance' // ============================================================================ // Query Keys @@ -374,13 +375,17 @@ export async function handleUpdateChannelBalance( const response = await updateChannelBalance(id) if (response.success && response.balance !== undefined) { const balance = response.balance + const formattedBalance = + response.unit === 'credits' + ? formatYikeCredits(balance, i18next.t('Credits')) + : formatCurrencyFromUSD(balance, { + digitsLarge: 2, + digitsSmall: 4, + abbreviate: false, + }) toast.success( i18next.t('Balance updated: {{balance}}', { - balance: formatCurrencyFromUSD(balance, { - digitsLarge: 2, - digitsSmall: 4, - abbreviate: false, - }), + balance: formattedBalance, }) ) queryClient?.invalidateQueries({ queryKey: channelsQueryKeys.lists() }) diff --git a/web/src/features/channels/lib/channel-type-config.ts b/web/src/features/channels/lib/channel-type-config.ts index 8a05b86ee449..761e723f5049 100644 --- a/web/src/features/channels/lib/channel-type-config.ts +++ b/web/src/features/channels/lib/channel-type-config.ts @@ -164,6 +164,18 @@ export const CHANNEL_TYPE_CONFIGS: Record = { models: 'Models', }, }, + 61: { + id: 61, + name: CHANNEL_TYPES[61], + icon: 'Yike', + defaultBaseUrl: 'https://yike.cn-shanghai.aliyuncs.com', + hints: { + baseUrl: + 'Shanghai: https://yike.cn-shanghai.aliyuncs.com; Singapore: https://yike.ap-southeast-1.aliyuncs.com; HTTPS only', + key: 'Format: AccessKeyId|AccessKeySecret; one complete pair per line for multi-key', + models: 'Wonder-Pro,Wonder-Standard,happyhorse-1.1,happyhorse-1.0,wan2.7', + }, + }, } /** diff --git a/web/src/features/channels/lib/channel-utils.ts b/web/src/features/channels/lib/channel-utils.ts index 9424a8521b6f..725adee8538c 100644 --- a/web/src/features/channels/lib/channel-utils.ts +++ b/web/src/features/channels/lib/channel-utils.ts @@ -100,6 +100,7 @@ export function getChannelTypeIcon(type: number): string { 50: 'Kling', // Kling 51: 'Jimeng', // Jimeng 52: 'Vidu', // Vidu + 61: 'Yike', // Yike 36: 'Suno', // SunoAPI 55: 'OpenAI', // Sora 54: 'Doubao', // DoubaoVideo diff --git a/web/src/features/channels/lib/index.ts b/web/src/features/channels/lib/index.ts index 8c18151ceb5f..8d936efbc2a5 100644 --- a/web/src/features/channels/lib/index.ts +++ b/web/src/features/channels/lib/index.ts @@ -27,3 +27,4 @@ export * from './channel-utils' export * from './multi-key-utils' export * from './model-mapping-validation' export * from './model-categories' +export * from './yike-balance' diff --git a/web/src/features/channels/lib/yike-balance.ts b/web/src/features/channels/lib/yike-balance.ts new file mode 100644 index 000000000000..7b58cc915b1e --- /dev/null +++ b/web/src/features/channels/lib/yike-balance.ts @@ -0,0 +1,36 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +export const YIKE_CHANNEL_TYPE = 61 + +export function isYikeChannel(channelType: number | null | undefined): boolean { + return channelType === YIKE_CHANNEL_TYPE +} + +export function formatYikeCredits( + balance: number, + unitLabel: string, + locale?: string, + compact = false +): string { + const amount = new Intl.NumberFormat(locale, { + maximumFractionDigits: 4, + notation: compact ? 'compact' : 'standard', + }).format(balance) + return `${amount} ${unitLabel}` +} diff --git a/web/src/features/channels/types.ts b/web/src/features/channels/types.ts index f7747fa21210..42d9511239df 100644 --- a/web/src/features/channels/types.ts +++ b/web/src/features/channels/types.ts @@ -197,6 +197,7 @@ export interface ChannelBalanceResponse { message?: string balance?: number currency?: string + unit?: string } export interface FetchModelsResponse { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index b48096a7fcfa..06413c3ce1ad 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK mode: use AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", + "Yike": "Yike (Wonder)", "Alipay": "Alipay", "All": "All", "All API tokens": "All API tokens", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU Threshold (%)", + "Credits": "Credits", "Create": "Create", "Create a copy of:": "Create a copy of:", "Create a key for your app or service": "Create a key for your app or service", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 638cf9abadd1..dc99c9f491fb 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", + "Yike": "Yike (Wonder)", "Alipay": "Alipay", "All": "Tout", "All API tokens": "Tous les jetons API", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "Processeur", "CPU Threshold (%)": "Seuil CPU (%)", + "Credits": "Crédits", "Create": "Créer", "Create a copy of:": "Créer une copie de :", "Create a key for your app or service": "Créer une clé pour votre application ou service", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3b9d4ce693cc..7fb72f38ac1c 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用", "Ali": "アリババ百炼", + "Yike": "Yike (Wonder)", "Alipay": "Alipay", "All": "すべて", "All API tokens": "すべての API キー", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 閾値 (%)", + "Credits": "クレジット", "Create": "新規作成", "Create a copy of:": "コピーを作成:", "Create a key for your app or service": "アプリまたはサービス用のキーを作成", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 853dc874d463..8abf9948722a 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Байлянь", + "Yike": "Yike (Wonder)", "Alipay": "Alipay", "All": "Все", "All API tokens": "Все API-ключи", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "ЦП", "CPU Threshold (%)": "Порог CPU (%)", + "Credits": "Кредиты", "Create": "Создать", "Create a copy of:": "Создать копию:", "Create a key for your app or service": "Создайте ключ для приложения или сервиса", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index cdc1a12626e1..c617067dcd6d 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", + "Yike": "Yike (Wonder)", "Alipay": "Alipay", "All": "All", "All API tokens": "Tất cả khóa API", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "Ngưỡng CPU (%)", + "Credits": "Điểm", "Create": "Tạo", "Create a copy of:": "Tạo bản sao của:", "Create a key for your app or service": "Tạo khóa cho ứng dụng hoặc dịch vụ của bạn", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index f8b5fafd8d2c..cfad9b822ef8 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region", "Ali": "阿里百煉", + "Yike": "萬鏡一刻(Yike)", "Alipay": "支付寶", "All": "全部", "All API tokens": "全部 API 金鑰", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 閾值 (%)", + "Credits": "積分", "Create": "建立", "Create a copy of:": "建立副本:", "Create a key for your app or service": "為你的套用或服務建立金鑰", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 842991e714f0..671f3102bc78 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -279,6 +279,7 @@ "AILS": "AILS", "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region", "Ali": "阿里百炼", + "Yike": "万镜一刻(Yike)", "Alipay": "支付宝", "All": "全部", "All API tokens": "全部 API 密钥", @@ -1124,6 +1125,7 @@ "Coze": "Coze", "CPU": "CPU", "CPU Threshold (%)": "CPU 阈值 (%)", + "Credits": "积分", "Create": "新建", "Create a copy of:": "创建副本:", "Create a key for your app or service": "为你的应用或服务创建密钥", diff --git a/web/src/lib/lobe-icon.tsx b/web/src/lib/lobe-icon.tsx index bcce6208dfd0..cc38cfedbccd 100644 --- a/web/src/lib/lobe-icon.tsx +++ b/web/src/lib/lobe-icon.tsx @@ -29,9 +29,11 @@ import * as LobeIcons from '@lobehub/icons' import type React from 'react' import { IconSub2api } from '@/assets/custom/icon-sub2api' +import { IconYike } from '@/assets/custom/icon-yike' const CUSTOM_ICONS: Record> = { Sub2API: IconSub2api, + Yike: IconYike, } /** From b93df6a3d18dce864228dfa1e01727edbda73c38 Mon Sep 17 00:00:00 2001 From: yjx Date: Sun, 9 Aug 2026 14:09:06 +0800 Subject: [PATCH 2/3] feat(yike): clarify credential configuration modes --- docs/channel/yike.md | 16 +++++++++-- .../drawers/channel-mutate-drawer.tsx | 19 +++++++++++++ web/src/features/channels/constants.ts | 27 +++++++++++++++++-- .../lib/__tests__/yike-balance.test.ts | 9 +++---- .../channels/lib/channel-type-config.ts | 14 ++++++---- web/src/features/channels/lib/yike-balance.ts | 4 +-- web/src/i18n/locales/en.json | 3 +++ web/src/i18n/locales/fr.json | 3 +++ web/src/i18n/locales/ja.json | 3 +++ web/src/i18n/locales/ru.json | 3 +++ web/src/i18n/locales/vi.json | 3 +++ web/src/i18n/locales/zh-TW.json | 3 +++ web/src/i18n/locales/zh.json | 3 +++ 13 files changed, 93 insertions(+), 17 deletions(-) diff --git a/docs/channel/yike.md b/docs/channel/yike.md index a5e87cd0b275..9c28582654bf 100644 --- a/docs/channel/yike.md +++ b/docs/channel/yike.md @@ -11,9 +11,21 @@ | 密钥 | `AccessKeyId\|AccessKeySecret` | | 模型 | `Wonder-Pro,Wonder-Standard,happyhorse-1.1,happyhorse-1.0,wan2.7` | -账号需开通万镜一刻、拥有可用点数及 Yike 调用权限。自定义地址必须使用 HTTPS;适配器始终请求 RPC 根路径 `/`。多 Key 渠道每行填写一组完整的 `AK|SK`,任务轮询会继续使用提交时选中的 Key。 +账号需开通万镜一刻、拥有可用点数及 Yike 调用权限。自定义地址必须使用 HTTPS;适配器始终请求 RPC 根路径 `/`。 -适配器负责阿里云 V3 签名、`SubmitVideoGenerationJob` 提交、`GetVideoGenerationJob` 轮询及状态和结果转换。后台“测试渠道”和“更新余额”都调用免费只读的 `GetYikeAccountCredit`;余额为会员计划、加油包和赠送积分三类可用积分之和,不会生成视频。余额刷新响应同时返回 `unit=credits`,渠道列表按“积分”展示,不把积分解释为美元。 +密钥必须把 ID 和 Secret 写在同一行,中间使用英文半角竖线 `|`,竖线两侧不要加空格: + +```text +AccessKeyId|AccessKeySecret +``` + +- **单密钥**:填写一行,创建一个渠道;支持单独查询该账号积分。 +- **批量添加**:每行填写一组完整凭证;系统按行创建多个独立渠道,每个渠道都可单独查询积分。这是多个账号或多组凭证的推荐方式。 +- **多密钥模式**:每行填写一组完整凭证,但所有凭证保存在同一个渠道,按随机或轮询策略调用;该模式只用于请求轮换,不支持查询或合计多组凭证的积分。 + +多 Key 渠道的任务轮询会继续使用提交时选中的 Key,不会在任务执行中切换凭证。 + +适配器负责阿里云 V3 签名、`SubmitVideoGenerationJob` 提交、`GetVideoGenerationJob` 轮询及状态和结果转换。后台“测试渠道”使用选中的一组凭证调用免费只读的 `GetYikeAccountCredit`;单密钥渠道的“更新余额”也调用该接口,不会生成视频。余额为会员计划、加油包和赠送积分三类可用积分之和,刷新响应同时返回 `unit=credits`,渠道列表按“积分”展示,不把积分解释为美元。 ## 用户调用 diff --git a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 3380d9e52c24..99982b826909 100644 --- a/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -139,12 +139,14 @@ import { import { ADD_MODE_OPTIONS, CHANNEL_STATUS_LABELS, + CHANNEL_TYPE_YIKE, CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_WARNINGS, ERROR_MESSAGES, FIELD_DESCRIPTIONS, FIELD_PLACEHOLDERS, MODEL_FETCHABLE_TYPES, + YIKE_KEY_INPUT_GUIDES, } from '../../constants' import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form' import { @@ -852,6 +854,14 @@ export function ChannelMutateDrawer({ // Helper computed values const isBatchMode = multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single' + const yikeKeyGuide = + currentType === CHANNEL_TYPE_YIKE + ? YIKE_KEY_INPUT_GUIDES[ + isEditing && isMultiKeyChannel + ? 'multi_to_single' + : (multiKeyMode ?? 'single') + ] + : undefined const isChannelDetailLoading = isEditing && isChannelLoading const supportsMultiKeyAddMode = currentType !== 57 && !(currentType === 41 && vertexKeyType === 'api_key') @@ -2923,6 +2933,8 @@ export function ChannelMutateDrawer({ keyPlaceholder = t( 'Leave empty to keep existing key' ) + } else if (yikeKeyGuide) { + keyPlaceholder = yikeKeyGuide.placeholder } else if ( currentType === 33 && awsKeyType === 'api_key' && @@ -2972,6 +2984,11 @@ export function ChannelMutateDrawer({ {t( 'Enter new key to update, or leave empty to keep current key' )} + {yikeKeyGuide && ( + + {t(yikeKeyGuide.description)} + + )} {isMultiKeyChannel && ( {keyModeDescription} @@ -2979,6 +2996,8 @@ export function ChannelMutateDrawer({ )} ) + } else if (yikeKeyGuide) { + keyDescription = t(yikeKeyGuide.description) } else if (isBatchMode) { keyDescription = t( 'Enter one API key per line for batch creation' diff --git a/web/src/features/channels/constants.ts b/web/src/features/channels/constants.ts index 41a4daf5bcff..2c994113fcf0 100644 --- a/web/src/features/channels/constants.ts +++ b/web/src/features/channels/constants.ts @@ -22,6 +22,11 @@ For commercial licensing, please contact support@quantumnous.com // ============================================================================ export const CHANNEL_TYPE_NEW_API = 60 +export const CHANNEL_TYPE_YIKE = 61 + +const YIKE_KEY_PAIR_PLACEHOLDER = 'AccessKeyId|AccessKeySecret' +const YIKE_MULTI_KEY_PLACEHOLDER = + 'AccessKeyId_1|AccessKeySecret_1\nAccessKeyId_2|AccessKeySecret_2' export const CHANNEL_TYPES = { 0: 'Unknown', @@ -81,7 +86,7 @@ export const CHANNEL_TYPES = { 58: 'Advanced Custom', 59: 'Sub2API', 60: 'New API', - 61: 'Yike', + [CHANNEL_TYPE_YIKE]: 'Yike', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ @@ -201,6 +206,24 @@ export const ADD_MODE_OPTIONS = [ }, ] as const +export const YIKE_KEY_INPUT_GUIDES = { + single: { + placeholder: YIKE_KEY_PAIR_PLACEHOLDER, + description: + 'Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.', + }, + batch: { + placeholder: YIKE_MULTI_KEY_PLACEHOLDER, + description: + 'Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.', + }, + multi_to_single: { + placeholder: YIKE_MULTI_KEY_PLACEHOLDER, + description: + 'Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.', + }, +} as const + // ============================================================================ // Multi-Key Management // ============================================================================ @@ -404,7 +427,7 @@ export const TYPE_TO_KEY_PROMPT: Record = { 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', 59: 'Enter API key for this channel', 60: 'Enter API key for this channel', - 61: 'Format: AccessKeyId|AccessKeySecret (one complete pair per line for multi-key)', + [CHANNEL_TYPE_YIKE]: YIKE_KEY_PAIR_PLACEHOLDER, } export const CHANNEL_TYPE_WARNINGS: Record = { diff --git a/web/src/features/channels/lib/__tests__/yike-balance.test.ts b/web/src/features/channels/lib/__tests__/yike-balance.test.ts index a17b1aaf9489..1443af7f8728 100644 --- a/web/src/features/channels/lib/__tests__/yike-balance.test.ts +++ b/web/src/features/channels/lib/__tests__/yike-balance.test.ts @@ -19,11 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import assert from 'node:assert/strict' import { describe, test } from 'node:test' -import { - formatYikeCredits, - isYikeChannel, - YIKE_CHANNEL_TYPE, -} from '../yike-balance' +import { CHANNEL_TYPE_YIKE } from '../../constants' +import { formatYikeCredits, isYikeChannel } from '../yike-balance' describe('Yike credit balance', () => { test('formats native credits without a currency symbol', () => { @@ -31,7 +28,7 @@ describe('Yike credit balance', () => { }) test('recognizes only the Yike channel type', () => { - assert.equal(isYikeChannel(YIKE_CHANNEL_TYPE), true) + assert.equal(isYikeChannel(CHANNEL_TYPE_YIKE), true) assert.equal(isYikeChannel(1), false) }) }) diff --git a/web/src/features/channels/lib/channel-type-config.ts b/web/src/features/channels/lib/channel-type-config.ts index 761e723f5049..bfa49dc7d95d 100644 --- a/web/src/features/channels/lib/channel-type-config.ts +++ b/web/src/features/channels/lib/channel-type-config.ts @@ -16,7 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { CHANNEL_TYPES } from '../constants' +import { + CHANNEL_TYPES, + CHANNEL_TYPE_YIKE, + TYPE_TO_KEY_PROMPT, +} from '../constants' // ============================================================================ // Channel Type Configuration @@ -164,15 +168,15 @@ export const CHANNEL_TYPE_CONFIGS: Record = { models: 'Models', }, }, - 61: { - id: 61, - name: CHANNEL_TYPES[61], + [CHANNEL_TYPE_YIKE]: { + id: CHANNEL_TYPE_YIKE, + name: CHANNEL_TYPES[CHANNEL_TYPE_YIKE], icon: 'Yike', defaultBaseUrl: 'https://yike.cn-shanghai.aliyuncs.com', hints: { baseUrl: 'Shanghai: https://yike.cn-shanghai.aliyuncs.com; Singapore: https://yike.ap-southeast-1.aliyuncs.com; HTTPS only', - key: 'Format: AccessKeyId|AccessKeySecret; one complete pair per line for multi-key', + key: TYPE_TO_KEY_PROMPT[CHANNEL_TYPE_YIKE], models: 'Wonder-Pro,Wonder-Standard,happyhorse-1.1,happyhorse-1.0,wan2.7', }, }, diff --git a/web/src/features/channels/lib/yike-balance.ts b/web/src/features/channels/lib/yike-balance.ts index 7b58cc915b1e..94bf23a20f5e 100644 --- a/web/src/features/channels/lib/yike-balance.ts +++ b/web/src/features/channels/lib/yike-balance.ts @@ -16,10 +16,10 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -export const YIKE_CHANNEL_TYPE = 61 +import { CHANNEL_TYPE_YIKE } from '../constants' export function isYikeChannel(channelType: number | null | undefined): boolean { - return channelType === YIKE_CHANNEL_TYPE + return channelType === CHANNEL_TYPE_YIKE } export function formatYikeCredits( diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 06413c3ce1ad..772e3b71c7cc 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "Enter new tag name or leave empty", "Enter new token to update": "Enter new token to update", "Enter one API key per line for batch creation": "Enter one API key per line for batch creation", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.", "Enter one key per line for batch creation": "Enter one key per line for batch creation", "Enter one keyword per line": "Enter one keyword per line", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "Enter only a top-level callback domain, for example https://api.example.com, without any path.", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index dc99c9f491fb..988bbcac8469 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "Saisir le nouveau nom de tag ou laisser vide", "Enter new token to update": "Saisir le nouveau token à mettre à jour", "Enter one API key per line for batch creation": "Saisissez une clé API par ligne pour la création par lots", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "Saisissez une paire AccessKeyId|AccessKeySecret complète par ligne. Un canal indépendant est créé pour chaque ligne et chacun peut interroger ses propres crédits. Ne placez pas l’ID et le secret sur des lignes distinctes.", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "Saisissez une paire AccessKeyId|AccessKeySecret complète par ligne. Toutes les paires sont stockées dans un même canal et sélectionnées aléatoirement ou à tour de rôle. Ce mode sert à répartir les requêtes et ne permet ni d’interroger ni d’additionner les crédits.", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "Saisissez une paire d’identifiants complète au format AccessKeyId|AccessKeySecret. Utilisez une barre verticale demi-chasse (|), sans espaces. Ce canal peut interroger ses crédits indépendamment.", "Enter one key per line for batch creation": "Saisissez une clé par ligne pour la création par lots", "Enter one keyword per line": "Saisir un mot-clé par ligne", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "Saisissez uniquement le domaine de callback principal, par exemple https://api.example.com, sans chemin.", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 7fb72f38ac1c..fee2d8e167f9 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "新しいタグ名を入力するか、空欄にする", "Enter new token to update": "更新する新しいトークンを入力", "Enter one API key per line for batch creation": "一括作成のため、1行に1つのAPIキーを入力してください", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "1行ごとに完全な AccessKeyId|AccessKeySecret の組を入力してください。行ごとに独立したチャネルが作成され、各チャネルのクレジットを個別に照会できます。ID と Secret を別々の行に入力しないでください。", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "1行ごとに完全な AccessKeyId|AccessKeySecret の組を入力してください。すべての組は1つのチャネルに保存され、ランダムまたは順番に選択されます。このモードはリクエストの振り分け用で、クレジットの照会や合計には対応していません。", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "AccessKeyId|AccessKeySecret 形式で完全な認証情報を1組入力してください。半角の縦線(|)を使用し、空白は入れないでください。このチャネルはクレジットを個別に照会できます。", "Enter one key per line for batch creation": "一括作成のため、1行に1つのキーを入力してください", "Enter one keyword per line": "1行に1つのキーワードを入力", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "コールバックのトップレベルドメインのみを入力してください。例: https://api.example.com。パスは含めないでください。", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 8abf9948722a..b4a76cbc48df 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "Введите новое имя тега или оставьте пустым", "Enter new token to update": "Введите новый токен для обновления", "Enter one API key per line for batch creation": "Введите по одному API-ключу на строку для пакетного создания", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "Введите одну полную пару AccessKeyId|AccessKeySecret в каждой строке. Для каждой строки создается отдельный канал, который может отдельно запрашивать свои кредиты. Не размещайте ID и секрет в разных строках.", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "Введите одну полную пару AccessKeyId|AccessKeySecret в каждой строке. Все пары хранятся в одном канале и выбираются случайно или по очереди. Этот режим предназначен для ротации запросов и не поддерживает запрос или суммирование кредитов.", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "Введите одну полную пару учетных данных в формате AccessKeyId|AccessKeySecret. Используйте вертикальную черту (|) без пробелов. Этот канал может отдельно запрашивать свои кредиты.", "Enter one key per line for batch creation": "Введите по одному ключу на строку для пакетного создания", "Enter one keyword per line": "Введите по одному ключевому слову на строку", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "Введите только домен верхнего уровня для callback, например https://api.example.com, без пути.", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index c617067dcd6d..541236760f03 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "Enter new tag name or leave blank", "Enter new token to update": "Nhập mã thông báo mới để cập nhật", "Enter one API key per line for batch creation": "Nhập một khóa API mỗi dòng để tạo hàng loạt", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "Nhập một cặp AccessKeyId|AccessKeySecret hoàn chỉnh trên mỗi dòng. Mỗi dòng sẽ tạo một kênh độc lập và từng kênh có thể truy vấn điểm riêng. Không đặt ID và Secret ở hai dòng khác nhau.", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "Nhập một cặp AccessKeyId|AccessKeySecret hoàn chỉnh trên mỗi dòng. Tất cả các cặp được lưu trong một kênh và được chọn ngẫu nhiên hoặc luân phiên. Chế độ này chỉ dùng để xoay vòng yêu cầu, không hỗ trợ truy vấn hoặc cộng tổng điểm.", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "Nhập một cặp thông tin xác thực hoàn chỉnh theo định dạng AccessKeyId|AccessKeySecret. Dùng dấu gạch đứng (|) không có khoảng trắng. Kênh này có thể truy vấn điểm độc lập.", "Enter one key per line for batch creation": "Nhập một khóa mỗi dòng để tạo hàng loạt", "Enter one keyword per line": "Nhập một từ khóa mỗi dòng", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "Chỉ nhập tên miền callback cấp cao nhất, ví dụ https://api.example.com, không kèm đường dẫn.", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index cfad9b822ef8..6d4367201ce2 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "輸入新標籤名稱或留空", "Enter new token to update": "輸入新令牌以更新", "Enter one API key per line for batch creation": "每行輸入一個 API 金鑰進行大量建立", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "每行填寫一組完整的 AccessKeyId|AccessKeySecret,系統會按行分別建立獨立渠道,每個渠道都能單獨查詢自己的積分。請勿將 AccessKeyId 和 AccessKeySecret 拆成兩行。", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "每行填寫一組完整的 AccessKeyId|AccessKeySecret,所有憑證儲存在同一個渠道,並按隨機或輪詢策略選擇。此模式只用於請求輪換,不支援查詢或合計多組憑證的積分。", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "填寫一組完整憑證,格式為 AccessKeyId|AccessKeySecret。中間必須使用英文半形豎線 | 分隔,豎線兩側不要加空格;此渠道可單獨查詢積分。", "Enter one key per line for batch creation": "每行輸入一個金鑰進行大量建立", "Enter one keyword per line": "每行輸入一個關鍵詞", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "只填寫Callback頂級域名,例如 https://api.example.com,不要帶任何路徑。", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 671f3102bc78..a92d0736b766 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1675,6 +1675,9 @@ "Enter new tag name or leave empty": "输入新标签名称或留空", "Enter new token to update": "输入新令牌以更新", "Enter one API key per line for batch creation": "每行输入一个 API 密钥进行批量创建", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "每行填写一组完整的 AccessKeyId|AccessKeySecret,系统会按行分别创建独立渠道,每个渠道都能单独查询自己的积分。不要把 AccessKeyId 和 AccessKeySecret 拆成两行。", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "每行填写一组完整的 AccessKeyId|AccessKeySecret,所有凭证保存在同一个渠道,并按随机或轮询策略选择。此模式只用于请求轮换,不支持查询或合计多组凭证的积分。", + "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "填写一组完整凭证,格式为 AccessKeyId|AccessKeySecret。中间必须使用英文半角竖线 | 分隔,竖线两侧不要加空格;此渠道可单独查询积分。", "Enter one key per line for batch creation": "每行输入一个密钥进行批量创建", "Enter one keyword per line": "每行输入一个关键词", "Enter only a top-level callback domain, for example https://api.example.com, without any path.": "只填写回调顶级域名,例如 https://api.example.com,不要带任何路径。", From 241434eda2ca9c5a55e6b41a1ab34433a446e9af Mon Sep 17 00:00:00 2001 From: yjx Date: Mon, 10 Aug 2026 00:23:19 +0800 Subject: [PATCH 3/3] fix(yike): address review feedback --- .../channels/components/dialogs/balance-query-dialog.tsx | 6 ++++-- .../features/channels/lib/__tests__/yike-balance.test.ts | 6 ++++++ web/src/features/channels/lib/channel-type-config.ts | 5 +++-- web/src/features/channels/lib/yike-balance.ts | 4 +++- web/src/i18n/locales/en.json | 1 + web/src/i18n/locales/fr.json | 1 + web/src/i18n/locales/ja.json | 3 ++- web/src/i18n/locales/ru.json | 1 + web/src/i18n/locales/vi.json | 1 + web/src/i18n/locales/zh-TW.json | 1 + web/src/i18n/locales/zh.json | 1 + 11 files changed, 24 insertions(+), 6 deletions(-) diff --git a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx index 8523f5b82ded..9921c2693e7a 100644 --- a/web/src/features/channels/components/dialogs/balance-query-dialog.tsx +++ b/web/src/features/channels/components/dialogs/balance-query-dialog.tsx @@ -25,6 +25,7 @@ import { toast } from 'sonner' import { Dialog } from '@/components/dialog' import { Button } from '@/components/ui/button' import { IconBadge } from '@/components/ui/icon-badge' +import { toIntlLocale } from '@/i18n/languages' import { formatCurrencyFromUSD } from '@/lib/currency' import { formatTimestampToDate } from '@/lib/format' @@ -45,9 +46,10 @@ export function BalanceQueryDialog({ open, onOpenChange, }: BalanceQueryDialogProps) { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const { currentRow, setCurrentRow } = useChannels() const queryClient = useQueryClient() + const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language) const [isQuerying, setIsQuerying] = useState(false) const [balance, setBalance] = useState(null) const [balanceUpdatedTime, setBalanceUpdatedTime] = useState( @@ -131,7 +133,7 @@ export function BalanceQueryDialog({ const formatBalance = (bal: number) => isYike - ? formatYikeCredits(bal, t('Credits')) + ? formatYikeCredits(bal, t('Credits'), locale) : formatCurrencyFromUSD(bal, { digitsLarge: 2, digitsSmall: 4, diff --git a/web/src/features/channels/lib/__tests__/yike-balance.test.ts b/web/src/features/channels/lib/__tests__/yike-balance.test.ts index 1443af7f8728..f708d5b6d866 100644 --- a/web/src/features/channels/lib/__tests__/yike-balance.test.ts +++ b/web/src/features/channels/lib/__tests__/yike-balance.test.ts @@ -27,6 +27,12 @@ describe('Yike credit balance', () => { assert.equal(formatYikeCredits(104, '积分', 'zh-CN'), '104 积分') }) + test('falls back for missing or invalid balances', () => { + assert.equal(formatYikeCredits(null, 'Credits', 'en-US'), '-') + assert.equal(formatYikeCredits(undefined, 'Credits', 'en-US'), '-') + assert.equal(formatYikeCredits(Number.NaN, 'Credits', 'en-US'), '-') + }) + test('recognizes only the Yike channel type', () => { assert.equal(isYikeChannel(CHANNEL_TYPE_YIKE), true) assert.equal(isYikeChannel(1), false) diff --git a/web/src/features/channels/lib/channel-type-config.ts b/web/src/features/channels/lib/channel-type-config.ts index bfa49dc7d95d..d55e6ad5bde2 100644 --- a/web/src/features/channels/lib/channel-type-config.ts +++ b/web/src/features/channels/lib/channel-type-config.ts @@ -22,6 +22,8 @@ import { TYPE_TO_KEY_PROMPT, } from '../constants' +const YIKE_BASE_URL_HINT = 'Yike API base URL hint' + // ============================================================================ // Channel Type Configuration // ============================================================================ @@ -174,8 +176,7 @@ export const CHANNEL_TYPE_CONFIGS: Record = { icon: 'Yike', defaultBaseUrl: 'https://yike.cn-shanghai.aliyuncs.com', hints: { - baseUrl: - 'Shanghai: https://yike.cn-shanghai.aliyuncs.com; Singapore: https://yike.ap-southeast-1.aliyuncs.com; HTTPS only', + baseUrl: YIKE_BASE_URL_HINT, key: TYPE_TO_KEY_PROMPT[CHANNEL_TYPE_YIKE], models: 'Wonder-Pro,Wonder-Standard,happyhorse-1.1,happyhorse-1.0,wan2.7', }, diff --git a/web/src/features/channels/lib/yike-balance.ts b/web/src/features/channels/lib/yike-balance.ts index 94bf23a20f5e..ea79500fdcdb 100644 --- a/web/src/features/channels/lib/yike-balance.ts +++ b/web/src/features/channels/lib/yike-balance.ts @@ -23,11 +23,13 @@ export function isYikeChannel(channelType: number | null | undefined): boolean { } export function formatYikeCredits( - balance: number, + balance: number | null | undefined, unitLabel: string, locale?: string, compact = false ): string { + if (balance == null || !Number.isFinite(balance)) return '-' + const amount = new Intl.NumberFormat(locale, { maximumFractionDigits: 4, notation: compact ? 'compact' : 'standard', diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 772e3b71c7cc..331f6309e421 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK mode: use AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", "Yike": "Yike (Wonder)", + "Yike API base URL hint": "Shanghai: https://yike.cn-shanghai.aliyuncs.com; Singapore: https://yike.ap-southeast-1.aliyuncs.com; HTTPS only", "Alipay": "Alipay", "All": "All", "All API tokens": "All API tokens", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 988bbcac8469..2f34a79939bb 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Mode AK/SK : utiliser AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", "Yike": "Yike (Wonder)", + "Yike API base URL hint": "Shanghai : https://yike.cn-shanghai.aliyuncs.com ; Singapour : https://yike.ap-southeast-1.aliyuncs.com ; HTTPS uniquement", "Alipay": "Alipay", "All": "Tout", "All API tokens": "Tous les jetons API", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index fee2d8e167f9..59c02bd5196a 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SKモード: AccessKey | SecretAccessKey | Regionを使用", "Ali": "アリババ百炼", "Yike": "Yike (Wonder)", + "Yike API base URL hint": "上海: https://yike.cn-shanghai.aliyuncs.com、シンガポール: https://yike.ap-southeast-1.aliyuncs.com、HTTPS のみ", "Alipay": "Alipay", "All": "すべて", "All API tokens": "すべての API キー", @@ -1676,7 +1677,7 @@ "Enter new token to update": "更新する新しいトークンを入力", "Enter one API key per line for batch creation": "一括作成のため、1行に1つのAPIキーを入力してください", "Enter one complete AccessKeyId|AccessKeySecret pair per line. A separate channel is created for each line, and each channel can query its own credits. Do not put the ID and secret on separate lines.": "1行ごとに完全な AccessKeyId|AccessKeySecret の組を入力してください。行ごとに独立したチャネルが作成され、各チャネルのクレジットを個別に照会できます。ID と Secret を別々の行に入力しないでください。", - "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "1行ごとに完全な AccessKeyId|AccessKeySecret の組を入力してください。すべての組は1つのチャネルに保存され、ランダムまたは順番に選択されます。このモードはリクエストの振り分け用で、クレジットの照会や合計には対応していません。", + "Enter one complete AccessKeyId|AccessKeySecret pair per line. All pairs are stored in one channel and selected randomly or by polling. This mode is for request rotation and does not support querying or summing credits.": "1行ごとに完全な AccessKeyId|AccessKeySecret の組を入力してください。すべての組は1つのチャネルに保存され、ランダムまたは順次ポーリングで選択されます。このモードはリクエストの振り分け用で、クレジットの照会や合計には対応していません。", "Enter one complete credential pair in the format AccessKeyId|AccessKeySecret. Use a half-width vertical bar (|) with no spaces. This channel can query its credits independently.": "AccessKeyId|AccessKeySecret 形式で完全な認証情報を1組入力してください。半角の縦線(|)を使用し、空白は入れないでください。このチャネルはクレジットを個別に照会できます。", "Enter one key per line for batch creation": "一括作成のため、1行に1つのキーを入力してください", "Enter one keyword per line": "1行に1つのキーワードを入力", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index b4a76cbc48df..8847e40bc96c 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Режим AK/SK: используйте AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Байлянь", "Yike": "Yike (Wonder)", + "Yike API base URL hint": "Шанхай: https://yike.cn-shanghai.aliyuncs.com; Сингапур: https://yike.ap-southeast-1.aliyuncs.com; только HTTPS", "Alipay": "Alipay", "All": "Все", "All API tokens": "Все API-ключи", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 541236760f03..45c3a0e16dee 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "Chế độ AK/SK: sử dụng AccessKey|SecretAccessKey|Region", "Ali": "Alibaba Bailian", "Yike": "Yike (Wonder)", + "Yike API base URL hint": "Thượng Hải: https://yike.cn-shanghai.aliyuncs.com; Singapore: https://yike.ap-southeast-1.aliyuncs.com; chỉ hỗ trợ HTTPS", "Alipay": "Alipay", "All": "All", "All API tokens": "Tất cả khóa API", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 6d4367201ce2..546b209289aa 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region", "Ali": "阿里百煉", "Yike": "萬鏡一刻(Yike)", + "Yike API base URL hint": "上海:https://yike.cn-shanghai.aliyuncs.com;新加坡:https://yike.ap-southeast-1.aliyuncs.com;僅支援 HTTPS", "Alipay": "支付寶", "All": "全部", "All API tokens": "全部 API 金鑰", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index a92d0736b766..e16632b8d331 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -280,6 +280,7 @@ "AK/SK mode: use AccessKey|SecretAccessKey|Region": "AK/SK 模式:使用 AccessKey|SecretAccessKey|Region", "Ali": "阿里百炼", "Yike": "万镜一刻(Yike)", + "Yike API base URL hint": "上海:https://yike.cn-shanghai.aliyuncs.com;新加坡:https://yike.ap-southeast-1.aliyuncs.com;仅支持 HTTPS", "Alipay": "支付宝", "All": "全部", "All API tokens": "全部 API 密钥",