From 4cb483dc6ec0701a58af97fb518a11f310fc562c Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:34:12 +0800 Subject: [PATCH 01/32] Keep Flatkey authoritative while Google serves generated video bytes Constraint: Public task responses must retain Flatkey /content URLs and ModelAPI success must be archived before terminal state. Rejected: Returning upstream or permanent GCS URLs | Both break the white-label and short-lived access boundary. Confidence: high Scope-risk: moderate Directive: Do not add an upstream fallback for ModelAPI content downloads. Tested: design and plan placeholder, consistency, and whitespace review --- .../2026-08-10-modelapi-seedance-25-gcs.md | 410 ++++++++++++++++++ ...6-08-10-modelapi-seedance-25-gcs-design.md | 159 +++++++ 2 files changed, 569 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md create mode 100644 docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md diff --git a/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md b/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md new file mode 100644 index 00000000000..1ceb0216031 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-modelapi-seedance-25-gcs.md @@ -0,0 +1,410 @@ +# ModelAPI Seedance 2.5 GCS Whitelabel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a standalone ModelAPI Seedance 2.5 upstream channel while keeping all public result URLs on Flatkey and serving archived video bytes through short-lived Google Cloud Storage redirects. + +**Architecture:** Reuse the provider-neutral Seedance request binder and add a focused `modelapiseedance` task adaptor for ModelAPI's `/v1/tasks` wire protocol. Generalize the existing video-result archive hooks to a fixed channel registry, archive before terminal success, persist only Flatkey's `/content` URL, and enforce GCS-only delivery for the new channel. + +**Tech Stack:** Go 1.22+, Gin, existing task adaptor framework, GCS client and V4 signing, React/TypeScript console constants, Bun tests. + +--- + +### Task 1: Lock channel registration and endpoint classification + +**Files:** +- Modify: `constant/channel.go` +- Create: `constant/modelapi_seedance_channel_test.go` +- Modify: `common/endpoint_type.go` +- Modify: `common/endpoint_type_test.go` +- Modify: `relay/relay_adaptor.go` +- Modify: `relay/relay_adaptor_test.go` +- Modify: `relay/channel/task/taskcommon/helpers.go` +- Modify: `relay/channel/task/taskcommon/helpers_test.go` + +- [ ] **Step 1: Write failing registration tests** + +Add assertions for the exact type, name, Base URL, OpenAI Video endpoint, adaptor channel name, white-label membership, and brand scrubbing: + +```go +func TestModelAPISeedanceChannelConstants(t *testing.T) { + require.Equal(t, 111, constant.ChannelTypeModelAPISeedance) + require.Equal(t, "ModelAPISeedance", constant.ChannelTypeNames[111]) + require.Equal(t, "https://api.modelapi.co", constant.ChannelBaseURLs[111]) +} + +func TestGetTaskAdaptor_ModelAPISeedance(t *testing.T) { + adaptor := GetTaskAdaptor(constant.TaskPlatform("111")) + require.NotNil(t, adaptor) + require.Equal(t, "modelapi-seedance", adaptor.GetChannelName()) +} +``` + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./constant ./common ./relay ./relay/channel/task/taskcommon -run 'ModelAPI|Whitelabel|Scrub' -count=1 +``` + +Expected: failure because the new adaptor and/or complete registration does not exist. + +- [ ] **Step 3: Implement the minimal registration** + +Use `ChannelTypeModelAPISeedance = 111`, default URL `https://api.modelapi.co`, `EndpointTypeOpenAIVideo`, a `GetTaskAdaptor` factory branch, and fixed white-label/brand entries. Avoid unrelated formatting changes in `constant/channel.go`. + +- [ ] **Step 4: Run the Step 2 command and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Give Seedance 2.5 traffic an isolated upstream protocol boundary + +Constraint: Public video requests remain on the shared Seedance content contract. +Confidence: high +Scope-risk: narrow +Tested: channel constants, endpoint classification, adaptor registration, and white-label detection +``` + +### Task 2: Map Seedance requests to ModelAPI and parse task responses + +**Files:** +- Create: `relay/channel/task/modelapiseedance/constants.go` +- Create: `relay/channel/task/modelapiseedance/types.go` +- Create: `relay/channel/task/modelapiseedance/adaptor.go` +- Create: `relay/channel/task/modelapiseedance/adaptor_test.go` + +- [ ] **Step 1: Write failing mapping and validation tests** + +Tests must call the pure mapping function and `ValidateRequestAndSetAction` for these cases: + +```go +func TestBuildCreateRequestPreservesExplicitFalseAndZero(t *testing.T) { + zero := int64(0) + no := false + req := dto.SeedanceVideoRequest{ + Model: "seedance-2.5", + Content: []dto.SeedanceContent{{Type: "text", Text: "prompt"}}, + Seed: &zero, + GenerateAudio: &no, + Watermark: &no, + } + got, err := buildModelAPICreateRequest(&req) + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-5-260628", got.Model) + require.NotNil(t, got.Params.Seed) + require.Zero(t, *got.Params.Seed) + require.NotNil(t, got.Params.GenerateAudio) + require.False(t, *got.Params.GenerateAudio) +} +``` + +Add table tests for role mapping, duration 4–30, resolutions, aspect ratios, image/video/audio/total limits, single first/last frame, last-without-first rejection, and invalid roles. + +- [ ] **Step 2: Run mapping tests and verify RED** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./relay/channel/task/modelapiseedance -run 'Build|Validate' -count=1 +``` + +Expected: package or functions do not exist. + +- [ ] **Step 3: Implement request types and pure mapping** + +The outbound types use pointers for optional scalars: + +```go +type createRequest struct { + Model string `json:"model"` + Input createInput `json:"input"` + Params createParams `json:"params"` +} + +type createParams struct { + Duration *int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Seed *int64 `json:"seed,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` +} +``` + +Use `taskcommon.BindSeedanceRequest`, `common.MarshalNoHTMLEscape`, and no direct `encoding/json` calls. + +- [ ] **Step 4: Write failing submit/fetch/parse tests** + +Use `httptest.Server` to assert: + +- create path is `/v1/tasks`; +- poll path is `/v1/tasks/{upstream_task_id}`; +- `Authorization: Bearer ` is set; +- submit response returns the upstream task ID internally; +- statuses map to queued/in-progress/success/failure; +- success selects the asset whose `type` is `video`, even when it is not first; +- missing video asset returns an error; +- failure reason is scrubbed. + +- [ ] **Step 5: Run response tests and verify RED** + +```powershell +go test -p 1 ./relay/channel/task/modelapiseedance -run 'Request|Response|Fetch|Parse' -count=1 +``` + +- [ ] **Step 6: Implement request, response, status, billing, and OpenAI-video conversion** + +Embed `taskcommon.BaseBilling`. `ConvertToOpenAIVideo` must use `originTask.GetResultURL()` only. `ParseTaskResult` may expose the upstream asset URL only in the in-memory `relaycommon.TaskInfo.Url` field required by the archive stage. + +- [ ] **Step 7: Run all package tests and verify GREEN** + +```powershell +go test -p 1 ./relay/channel/task/modelapiseedance -count=1 +``` + +- [ ] **Step 8: Commit with a Lore message** + +```text +Translate the shared Seedance contract without leaking supplier semantics + +Constraint: Explicit false and zero values must survive the upstream conversion. +Rejected: Provider-specific client input | It would break channel failover and the Seedance SOP. +Confidence: high +Scope-risk: moderate +Tested: request mapping, validation, authentication, submission, polling, and status conversion +``` + +### Task 3: Generalize video-result channel labels with fixed cardinality + +**Files:** +- Create: `service/video_result_channels.go` +- Create: `service/video_result_channels_test.go` +- Modify: `service/video_result_storage.go` +- Modify: `service/video_result_storage_test.go` +- Modify: `pkg/perf_metrics/video_result.go` +- Modify: `pkg/perf_metrics/video_result_test.go` +- Modify: `controller/video_proxy.go` +- Modify: `controller/video_proxy_video_result_test.go` + +- [ ] **Step 1: Write failing label and metric tests** + +```go +func TestVideoResultChannelLabel(t *testing.T) { + require.Equal(t, "techmobi", VideoResultChannelLabel(constant.ChannelTypeTechMobiVideo)) + require.Equal(t, "modelapi", VideoResultChannelLabel(constant.ChannelTypeModelAPISeedance)) + require.Empty(t, VideoResultChannelLabel(constant.ChannelTypeOpenAI)) +} +``` + +Record one `modelapi` archive and redirect and assert the exact Prometheus series are exported. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./service ./controller ./pkg/perf_metrics -run 'VideoResultChannel|ModelAPI.*Metric|ArchivedModelAPI' -count=1 +``` + +- [ ] **Step 3: Implement the fixed registry and channel-aware archive wrapper** + +Keep the compatibility wrapper: + +```go +func ArchiveVideoResult(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return ArchiveVideoResultForChannel(ctx, "techmobi", publicTaskID, upstreamURL, proxy) +} +``` + +The generic function accepts only the fixed label derived from the channel registry. Expand metric arrays from `techmobi` to `techmobi,modelapi`; reject/ignore unknown labels through existing index validation. + +- [ ] **Step 4: Run the Step 2 command and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Reuse durable video delivery without creating unbounded telemetry + +Constraint: Metrics labels are fixed and may never contain upstream or storage identifiers. +Confidence: high +Scope-risk: narrow +Tested: channel registry, archive compatibility wrapper, and fixed Prometheus series +``` + +### Task 4: Archive ModelAPI success before terminal CAS and redact stored polling data + +**Files:** +- Modify: `service/task_polling.go` +- Modify: `service/task_polling_video_result_test.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor_test.go` + +- [ ] **Step 1: Write failing polling tests** + +Add ModelAPI variants proving: + +- archive hook receives the selected upstream URL and proxy; +- persisted `ResultURL` equals `/v1/videos/{public_task_id}/content`; +- `VideoResult` is present at the successful CAS; +- archive failure returns a polling error, leaves the task non-terminal, and does not settle; +- persisted `task.Data` does not contain `https://`, `modelapi`, the upstream host, or the selected asset URL; +- a second node losing the CAS does not settle twice. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./service -run 'ModelAPI.*Archive|ModelAPI.*Redact|UpdateVideoSingleTask' -count=1 +``` + +- [ ] **Step 3: Generalize the polling archive gate** + +Replace the TechMobi-only condition with `VideoResultChannelLabel(channel.Type)`. For an upstream success: + +```go +label := VideoResultChannelLabel(channel.Type) +if label != "" { + archived, err := archiveVideoResultForChannel(ctx, label, task.TaskID, taskResult.Url, channel.GetProxy()) + if err != nil { return err } + privateData.VideoResult = archived + privateData.ResultURL = taskcommon.BuildVideoContentURL(task.TaskID) +} +``` + +Before assigning the polling body to `task.Data`, call a channel-owned sanitizer that recursively clears asset URLs. Do not log the raw response or URL. + +- [ ] **Step 4: Run service and adaptor tests and verify GREEN** + +```powershell +go test -p 1 ./service ./relay/channel/task/modelapiseedance -run 'ModelAPI|UpdateVideoSingleTask|ParseTaskResult' -count=1 +``` + +- [ ] **Step 5: Commit with a Lore message** + +```text +Do not report generated video success before a durable copy exists + +Constraint: Terminal settlement remains guarded by the existing multi-node CAS. +Rejected: Persisting the upstream asset URL for later download | It leaks supplier data and expires independently. +Confidence: high +Scope-risk: moderate +Directive: New archive channels must register a fixed label and a response sanitizer. +Tested: archive ordering, retry behavior, redaction, and exactly-once settlement +``` + +### Task 5: Keep Flatkey URLs public and make ModelAPI downloads GCS-only + +**Files:** +- Modify: `controller/video_proxy.go` +- Modify: `controller/video_proxy_video_result_test.go` + +- [ ] **Step 1: Write failing controller tests** + +```go +func TestArchivedModelAPIVideoRedirect(t *testing.T) { + // channel 111 + archived metadata + signer hook + // assert 302, exact Location, no-store, and empty response body +} + +func TestModelAPIVideoWithoutArchiveDoesNotFallbackUpstream(t *testing.T) { + // successful ModelAPI task with URL-shaped task.Data and nil VideoResult + // assert safe 502 and assert the upstream httptest server received zero requests +} +``` + +Retain the existing TechMobi legacy fallback test unchanged. + +- [ ] **Step 2: Run tests and verify RED** + +```powershell +go test -p 1 ./controller -run 'ArchivedModelAPI|ModelAPI.*WithoutArchive|LegacyTechMobi' -count=1 +``` + +- [ ] **Step 3: Implement strict ModelAPI content delivery** + +Use the generic archived redirect helper for registered channels. Immediately after it returns false, detect `ChannelTypeModelAPISeedance` and return the safe unavailable response. Do not add a ModelAPI upstream extractor or fallback branch. + +- [ ] **Step 4: Run controller tests and verify GREEN** + +- [ ] **Step 5: Commit with a Lore message** + +```text +Keep Flatkey as the only public video address while Google serves the bytes + +Constraint: ModelAPI tasks may never fall back to an upstream source URL. +Confidence: high +Scope-risk: moderate +Tested: GCS redirect, expiry/storage/signing errors, strict no-archive failure, and TechMobi legacy compatibility +``` + +### Task 6: Add console channel metadata and complete verification + +**Files:** +- Modify: `web/default/src/features/channels/constants.ts` +- Modify: `web/default/src/features/channels/constants.test.ts` +- Modify: `web/default/src/features/channels/lib/channel-type-config.ts` +- Modify: `web/default/src/features/channels/lib/channel-utils.ts` +- Modify: `web/classic/src/constants/channel.constants.js` + +- [ ] **Step 1: Write failing console constant test** + +```ts +test('ModelAPISeedance channel is selectable but not model-fetchable', () => { + expect(CHANNEL_TYPES[111]).toBe('ModelAPISeedance') + expect(MODEL_FETCH_CHANNEL_TYPES).not.toContain(111) +}) +``` + +- [ ] **Step 2: Run the frontend test and verify RED** + +```powershell +Push-Location web/default +bun test src/features/channels/constants.test.ts +Pop-Location +``` + +- [ ] **Step 3: Add channel labels, API-key hint, icon-family mapping, and classic option** + +Reuse an existing Doubao/Seedance icon family; do not add a dependency or public marketing copy. + +- [ ] **Step 4: Run frontend test and build** + +```powershell +Push-Location web/default +bun test src/features/channels/constants.test.ts +bun run build +Pop-Location +``` + +- [ ] **Step 5: Run fresh backend verification** + +```powershell +$env:GOCACHE="$PWD\.tmp-gocache" +go test -p 1 ./constant ./common ./relay/channel/task/taskcommon ./relay/channel/task/modelapiseedance ./service ./controller ./pkg/perf_metrics -count=1 +go test -p 1 ./relay/... -count=1 +go build ./... +go vet ./constant ./common ./relay/... ./service ./controller ./pkg/perf_metrics +git diff --check +rg -n 'encoding/json|api\.modelapi\.co|result\.assets|taskResult\.Url' relay/channel/task/modelapiseedance service controller +``` + +Review every search match: JSON calls must use `common.*`; supplier host and asset URLs may appear only in internal constants/parsers/tests, never client messages or logs. + +- [ ] **Step 6: Request independent code review and fix every Critical/Important finding** + +The review must explicitly report: + +- `Router deploy: required`; +- `Console deploy: required`; +- `Other deploy targets: no website/Terraform/Cloudflare`; +- staging live-channel validation is required before production. + +- [ ] **Step 7: Re-run the affected tests after review fixes and commit** + +```text +Expose the new video supplier through existing Flatkey administration surfaces + +Constraint: The supplier is configurable only as an internal channel and is not public marketing content. +Confidence: high +Scope-risk: moderate +Tested: console constants, frontend build, targeted Go tests, relay tests, full Go build, vet, and diff checks +Not-tested: Live upstream generation and production deployment +``` diff --git a/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md b/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md new file mode 100644 index 00000000000..8e611aac0a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-modelapi-seedance-25-gcs-design.md @@ -0,0 +1,159 @@ +# ModelAPI Seedance 2.5 白标接入与 GCS 下载设计 + +日期:2026-08-10 + +## 目标 + +把 ModelAPI 的 `doubao-seedance-2-5-260628` 作为 Flatkey 的独立 Seedance 2.5 上游渠道。客户端继续使用 Flatkey 现有的 `POST /v1/videos`、官方 Seedance `content[]` 入参和任务查询协议。 + +成功任务对外始终返回 Flatkey 自己的内容地址: + +```text +/v1/videos/{public_task_id}/content +``` + +客户端访问该地址时,Flatkey 生成短时 Google Cloud Storage V4 Signed URL 并返回 `302 Found`。因此 API 地址和品牌归属保持为 Flatkey,实际视频字节由 Google 下载链路承载。 + +## 已确认约束 + +- 上游创建接口:`POST https://api.modelapi.co/v1/tasks`。 +- 上游查询接口:`GET https://api.modelapi.co/v1/tasks/{task_id}`。 +- 鉴权使用 `Authorization: Bearer `。 +- 上游模型固定为 `doubao-seedance-2-5-260628`,不透传客户端模型名。 +- 上游状态为 `pending | polling | running | succeeded | failed`。 +- 成功视频从 `result.assets[]` 中选择 `type == "video"` 的项目,不能依赖数组顺序。 +- 成功任务必须先完成 GCS 归档,再通过现有 CAS 路径进入最终成功状态和结算。 +- 上游真实任务 ID、响应 URL、品牌、主机名、GCS 桶名和 Signed URL 不得出现在客户端 JSON 或应用日志中。 +- 新渠道的任务数据不得保存可恢复的上游视频 URL;`TaskPrivateData.ResultURL` 只保存 Flatkey `/content` 地址。 +- 对本渠道,缺少有效 `VideoResult` 元数据时 `/content` 返回安全错误,不回退到上游直链或 Cloud Run 字节代理。 +- 生产为多节点;归档依赖现有确定性对象键、GCS generation precondition 和任务状态 CAS,不引入进程内正确性锁。 + +## 方案选择 + +### 采用:独立渠道适配器 + 复用现有 GCS 结果归档 + +新增独立 `ChannelTypeModelAPISeedance` 和 `relay/channel/task/modelapiseedance` 适配器。入站继续复用 `dto.SeedanceVideoRequest` 与 `taskcommon.BindSeedanceRequest`,只在适配器内完成 ModelAPI wire-format 映射。结果复用现有 `ArchiveVideoResult`、`SignVideoResultDownload` 和 `TaskPrivateData.VideoResult`,仅将原先 TechMobi 专用的调用点泛化为固定白名单渠道。 + +该方案最小化新增安全面,并保留现有 GCS 幂等、MP4 校验、SSRF 防护、保留期和签名逻辑。 + +### 未采用:直接把上游 URL 返回给客户端 + +会暴露供应商和真实资源地址,失去 Flatkey 白标边界,并让可用期依赖上游临时 URL。 + +### 未采用:新增第二套 Google 存储实现或公开桶 + +会重复已有归档与签名能力。公开桶或永久对象 URL无法满足短时授权和隐藏存储标识的要求。 + +## 请求映射 + +客户端仍发送 `dto.SeedanceVideoRequest`。适配器生成: + +```json +{ + "model": "doubao-seedance-2-5-260628", + "input": { + "text": [{"role": "prompt", "content": "..."}], + "image": [{"role": "reference", "url": "..."}], + "video": [{"role": "reference", "url": "..."}], + "audio": [{"role": "reference", "url": "..."}] + }, + "params": { + "duration": 5, + "resolution": "720p", + "aspect_ratio": "adaptive", + "seed": 1, + "generate_audio": false, + "watermark": false, + "return_last_frame": false + } +} +``` + +可选标量使用指针和 `omitempty`,保证显式 `false`、`0` 与未提供字段语义不同。所有 JSON 编解码调用 `common.*` 包装函数。 + +Seedance 素材角色映射如下: + +| Seedance role | ModelAPI role | +| --- | --- | +| `reference_image` | `reference` | +| `reference_video` | `reference` | +| `reference_audio` | `reference` | +| 空 role | `reference` | +| `first_frame` | `first_frame` | +| `last_frame` | `last_frame` | + +非法 role 在提交前返回 `400 invalid_request`。同时提前验证:`duration` 4–30、`resolution` 为 `480p|720p`、宽高比为官方集合、图片不超过 30、视频不超过 10、音频不超过 10、总素材不超过 50、首帧和尾帧各最多一项、尾帧必须与首帧同时出现。 + +## 创建与轮询 + +创建成功后仅把上游任务 ID保存为任务内部 ID,客户端拿到随机公开任务 ID。轮询状态映射: + +| 上游状态 | Flatkey 状态 | +| --- | --- | +| `pending` | `QUEUED` | +| `polling`、`running` | `IN_PROGRESS` | +| `succeeded` | 先归档,成功后 `SUCCESS` | +| `failed` | `FAILURE`,错误信息脱敏 | + +轮询响应进入 `task.Data` 前必须移除所有 asset URL。成功解析时,真实视频 URL只存在于当前轮询调用的内存对象中,供归档器立即读取;不得写日志。 + +## 成功数据流 + +1. 后台轮询 ModelAPI 任务。 +2. 从 `result.assets[]` 选择 `type == "video"` 的 URL。 +3. 调用现有 GCS 归档器,以固定指标标签 `modelapi` 流式下载、校验并写入私有结果桶。 +4. 归档成功后,把 `VideoResult` 写入任务私有数据,并把 `ResultURL` 设置为 Flatkey `/v1/videos/{public_task_id}/content`。 +5. 使用现有任务状态 CAS 完成成功转换和一次性结算。若其他节点已完成转换,本节点不重复结算。 +6. 客户端查询任务时只看到 Flatkey 地址。 +7. 客户端访问 `/content`;Flatkey 校验任务、渠道、过期时间和对象 generation,生成短时 Signed URL并返回 `302`。 +8. 客户端跟随跳转后从 Google 下载,Google 处理 Range、Content-Length 和实际字节吞吐。 + +归档失败时本轮不推进成功状态,下一轮重新查询并重试。这样不会出现“任务显示成功但没有可下载副本”的状态。 + +## 下载与错误语义 + +ModelAPI 渠道采用严格归档模式: + +- 有有效 `VideoResult`:`302` 到短时 GCS Signed URL,并设置 `Cache-Control: no-store`。 +- 对象过期:`410 Gone`。 +- 对象缺失或属性不匹配:`502 Bad Gateway`。 +- 签名服务临时失败:`503 Service Unavailable`。 +- 成功任务缺少 `VideoResult`:返回安全的 `502`,不尝试解析上游 URL,也不代理上游流量。 + +TechMobi 的历史任务兼容回退保持不变;严格禁止回退仅适用于新 ModelAPI 渠道。 + +## 注册与控制台 + +- 后端渠道类型值使用 `111`,默认 Base URL 为 `https://api.modelapi.co`。 +- `GetTaskAdaptor` 注册新的任务适配器。 +- endpoint 类型注册为 OpenAI Video。 +- 加入 Seedance 白标渠道集合和品牌词脱敏集合。 +- 默认控制台和 classic 控制台都显示独立渠道类型;模型列表固定为 `doubao-seedance-2-5-260628`,不调用通用模型抓取。 + +## 安全与可观测性 + +- 复用已有结果桶与运行时凭证,不新增公开 IAM、静态密钥或永久 URL。 +- 指标只使用固定 `channel=modelapi` 标签,不使用任务 ID、URL、桶或对象名,避免高基数。 +- 日志只记录公开任务 ID、阶段、状态码、字节数和耗时;不记录请求 Authorization、上游任务 ID、上游 URL或 Signed URL。 +- 存储在 `task.Data` 的轮询快照必须经过 URL 清除;失败原因经过 `ScrubBrandedText`。 + +## 测试与验收 + +- 请求映射覆盖文本、图片、视频、音频、首尾帧和显式 `false/0`。 +- 参数和素材限制在发送上游前失败。 +- 创建、查询 URL、鉴权头和状态映射正确。 +- 成功 asset 选择按 `type=video`,不依赖第一项。 +- 成功轮询必须先归档;归档失败不成功、不结算。 +- `task.Data` 与日志不包含上游 URL、主机名或品牌。 +- 查询结果中的 URL始终为 Flatkey `/content`。 +- `/content` 对归档结果返回 GCS `302`;缺元数据时不回退上游。 +- 现有 TechMobi 历史回退测试继续通过。 +- 运行相关 Go 测试、`go build ./...`、`go vet` 和控制台定向测试。 + +## 发布建议 + +- Router deploy:required。新增 `/v1/videos` 渠道路由、请求适配和 `/content` 下载分支均在 router 请求路径。 +- Console deploy:required。主节点负责异步轮询、GCS 归档和任务状态持久化。 +- Website:not required。 +- Terraform / Cloudflare:not required;沿用已部署的私有结果桶和现有环境变量。 +- 先在 staging 配置独立渠道与密钥,完成真实创建、轮询、GCS 对象、Flatkey 地址和 `302` 下载验证后再发布生产。 From b2472d59a70d9ed5d1261646f1408bd9ff724c68 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:13:50 +0800 Subject: [PATCH 02/32] Give Seedance 2.5 traffic an isolated upstream protocol boundary Constraint: public requests remain on shared Seedance content contract Rejected: implementing full ModelAPI upstream behavior | Task 1 only requires registration and a skeletal adaptor; Task 2 owns behavior Confidence: high Scope-risk: narrow Directive: keep ModelAPI public routing on OpenAI video endpoint and shared seedance binding; do not leak ModelAPI brand text through whitelabel task paths Tested: RED exact command failed in temporary HEAD worktree on missing ChannelTypeModelAPISeedance and modelapiseedance package; GREEN exact command passed for constant/common/taskcommon subset but ./relay and modelapiseedance package are blocked by unrelated service/task_polling.go undefined service compile error Not-tested: full requested GREEN until unrelated service package compile error is fixed --- common/endpoint_type.go | 2 + common/endpoint_type_test.go | 7 ++ constant/channel.go | 6 +- constant/modelapi_seedance_channel_test.go | 21 +++++ .../channel/task/modelapiseedance/adaptor.go | 84 +++++++++++++++++++ .../task/modelapiseedance/adaptor_test.go | 20 +++++ .../task/modelapiseedance/constants.go | 7 ++ relay/channel/task/taskcommon/helpers.go | 2 + relay/channel/task/taskcommon/helpers_test.go | 6 ++ relay/relay_adaptor.go | 3 + relay/relay_adaptor_test.go | 14 ++++ 11 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 constant/modelapi_seedance_channel_test.go create mode 100644 relay/channel/task/modelapiseedance/adaptor.go create mode 100644 relay/channel/task/modelapiseedance/adaptor_test.go create mode 100644 relay/channel/task/modelapiseedance/constants.go diff --git a/common/endpoint_type.go b/common/endpoint_type.go index e0feca5a409..33ba044c55e 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -40,6 +40,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant fallthrough case constant.ChannelTypeXaiGrokVideo: fallthrough + case constant.ChannelTypeModelAPISeedance: + fallthrough case constant.ChannelTypeMiniMaxH3: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} case constant.ChannelTypeSonilo: diff --git a/common/endpoint_type_test.go b/common/endpoint_type_test.go index 3b3b55d6f20..578ab1b6c60 100644 --- a/common/endpoint_type_test.go +++ b/common/endpoint_type_test.go @@ -55,3 +55,10 @@ func TestGetEndpointTypesByChannelType_Sonilo(t *testing.T) { t.Fatalf("expected endpoints to contain %q, got %v", constant.EndpointTypeVideoToMusic, got) } } + +func TestGetEndpointTypesByChannelType_ModelAPISeedance(t *testing.T) { + got := GetEndpointTypesByChannelType(constant.ChannelTypeModelAPISeedance, "doubao-seedance-2-5-260628") + if !containsEndpointType(got, constant.EndpointTypeOpenAIVideo) { + t.Fatalf("expected endpoints to contain %q, got %v", constant.EndpointTypeOpenAIVideo, got) + } +} diff --git a/constant/channel.go b/constant/channel.go index 4594565e01b..2be7cbb2ebe 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -70,8 +70,8 @@ const ( ChannelTypeXaiGrokVideo = 108 // xAI Grok Imagine async video API (submit → poll); whitelabel ChannelTypeSonilo = 109 // Sonilo async video-to-music API; whitelabel ChannelTypeMiniMaxH3 = 110 // MiniMax H3 async video API - ChannelTypeDummy // this one is only for count, do not add any channel after this - + ChannelTypeModelAPISeedance = 111 // ModelAPI Seedance 2.5 async video API; whitelabel + ChannelTypeDummy = 112 // this one is only for count, do not add any channel after this ) var ChannelBaseURLs = []string{ @@ -151,6 +151,7 @@ var ChannelBaseURLs = []string{ "https://api.x.ai", // 108 XaiGrokVideo "https://api.sonilo.com", // 109 Sonilo "https://api.minimax.io", // 110 MiniMaxH3 + "https://api.modelapi.co", // 111 ModelAPISeedance } var ChannelTypeNames = map[int]string{ @@ -220,6 +221,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeXaiGrokVideo: "XaiGrokVideo", ChannelTypeSonilo: "Sonilo", ChannelTypeMiniMaxH3: "MiniMaxH3", + ChannelTypeModelAPISeedance: "ModelAPISeedance", } func GetChannelTypeName(channelType int) string { diff --git a/constant/modelapi_seedance_channel_test.go b/constant/modelapi_seedance_channel_test.go new file mode 100644 index 00000000000..905f19ae1c9 --- /dev/null +++ b/constant/modelapi_seedance_channel_test.go @@ -0,0 +1,21 @@ +package constant + +import "testing" + +func TestModelAPISeedanceChannelRegistration(t *testing.T) { + if ChannelTypeModelAPISeedance != 111 { + t.Fatalf("ChannelTypeModelAPISeedance = %d, want 111", ChannelTypeModelAPISeedance) + } + if ChannelTypeDummy <= ChannelTypeModelAPISeedance { + t.Fatalf("ChannelTypeDummy = %d, want after ModelAPISeedance %d", ChannelTypeDummy, ChannelTypeModelAPISeedance) + } + if len(ChannelBaseURLs) <= ChannelTypeModelAPISeedance { + t.Fatalf("ChannelBaseURLs length = %d, want index %d", len(ChannelBaseURLs), ChannelTypeModelAPISeedance) + } + if got := ChannelBaseURLs[ChannelTypeModelAPISeedance]; got != "https://api.modelapi.co" { + t.Fatalf("ChannelBaseURLs[ChannelTypeModelAPISeedance] = %q, want %q", got, "https://api.modelapi.co") + } + if got := GetChannelTypeName(ChannelTypeModelAPISeedance); got != "ModelAPISeedance" { + t.Fatalf("GetChannelTypeName(ChannelTypeModelAPISeedance) = %q, want %q", got, "ModelAPISeedance") + } +} diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go new file mode 100644 index 00000000000..0cc02aacf01 --- /dev/null +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -0,0 +1,84 @@ +package modelapiseedance + +import ( + "fmt" + "io" + "net/http" + + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" + relaycommon "github.com/QuantumNous/new-api/relay/common" + + "github.com/gin-gonic/gin" +) + +type TaskAdaptor struct { + taskcommon.BaseBilling + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.apiKey = info.ApiKey + a.baseURL = info.ChannelBaseUrl +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if _, err := taskcommon.BindSeedanceRequest(c, info, constant.TaskActionGenerate); err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { + return "", notImplemented("BuildRequestURL") +} + +func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, _ *http.Request, _ *relaycommon.RelayInfo) error { + return notImplemented("BuildRequestHeader") +} + +func (a *TaskAdaptor) BuildRequestBody(_ *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { + return nil, notImplemented("BuildRequestBody") +} + +func (a *TaskAdaptor) DoRequest(_ *gin.Context, _ *relaycommon.RelayInfo, _ io.Reader) (*http.Response, error) { + return nil, notImplemented("DoRequest") +} + +func (a *TaskAdaptor) DoResponse(_ *gin.Context, _ *http.Response, _ *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { + return "", nil, taskError(notImplemented("DoResponse"), "not_implemented", http.StatusNotImplemented) +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) FetchTask(_ string, _ string, _ map[string]any, _ string) (*http.Response, error) { + return nil, notImplemented("FetchTask") +} + +func (a *TaskAdaptor) ParseTaskResult(_ []byte) (*relaycommon.TaskInfo, error) { + return nil, notImplemented("ParseTaskResult") +} + +func notImplemented(method string) error { + return fmt.Errorf("modelapi seedance task adaptor %s is not implemented", method) +} + +func taskError(err error, code string, statusCode int) *dto.TaskError { + return &dto.TaskError{ + Code: code, + Message: err.Error(), + StatusCode: statusCode, + LocalError: true, + Error: err, + } +} diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go new file mode 100644 index 00000000000..64957d144f1 --- /dev/null +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -0,0 +1,20 @@ +package modelapiseedance + +import ( + "testing" + + "github.com/QuantumNous/new-api/relay/channel" +) + +var _ channel.TaskAdaptor = (*TaskAdaptor)(nil) + +func TestModelAPISeedanceAdaptorIdentity(t *testing.T) { + adaptor := &TaskAdaptor{} + if got := adaptor.GetChannelName(); got != "modelapi-seedance" { + t.Fatalf("GetChannelName() = %q, want modelapi-seedance", got) + } + models := adaptor.GetModelList() + if len(models) != 1 || models[0] != "doubao-seedance-2-5-260628" { + t.Fatalf("GetModelList() = %v, want [doubao-seedance-2-5-260628]", models) + } +} diff --git a/relay/channel/task/modelapiseedance/constants.go b/relay/channel/task/modelapiseedance/constants.go new file mode 100644 index 00000000000..58f6faa3a3d --- /dev/null +++ b/relay/channel/task/modelapiseedance/constants.go @@ -0,0 +1,7 @@ +package modelapiseedance + +const ChannelName = "modelapi-seedance" + +var ModelList = []string{ + "doubao-seedance-2-5-260628", +} diff --git a/relay/channel/task/taskcommon/helpers.go b/relay/channel/task/taskcommon/helpers.go index 0538c35d9b9..153d6f59479 100644 --- a/relay/channel/task/taskcommon/helpers.go +++ b/relay/channel/task/taskcommon/helpers.go @@ -28,6 +28,7 @@ var whitelabelChannels = map[int]struct{}{ constant.ChannelTypeJimengZhizinan: {}, constant.ChannelTypeTechMobiVideo: {}, constant.ChannelTypeBytePlus: {}, + constant.ChannelTypeModelAPISeedance: {}, constant.ChannelTypeXaiGrokVideo: {}, constant.ChannelTypeSonilo: {}, } @@ -65,6 +66,7 @@ var brandKeywords = []string{ "jimeng", "jianying", "dreamina", "seedance", "techmobi", "chatgpttech", "byteplus", + "modelapi", "api.modelapi.co", "xai", "grok", "x.ai", "vidgen.x.ai", "sonilo", "api.sonilo.com", } diff --git a/relay/channel/task/taskcommon/helpers_test.go b/relay/channel/task/taskcommon/helpers_test.go index 1b61af7d56d..655e42fde72 100644 --- a/relay/channel/task/taskcommon/helpers_test.go +++ b/relay/channel/task/taskcommon/helpers_test.go @@ -18,6 +18,7 @@ func TestShouldWhitelabelPlatform(t *testing.T) { {"jimeng zhizinan (channel 104)", constant.TaskPlatform("104"), true}, {"techmobi video (channel 105)", constant.TaskPlatform("105"), true}, {"byteplus (channel 107)", constant.TaskPlatform("107"), true}, + {"modelapi (channel 111)", constant.TaskPlatform("111"), true}, {"openai channel type number", constant.TaskPlatform("1"), false}, {"non-numeric platform suno", constant.TaskPlatformSuno, false}, {"empty platform", constant.TaskPlatform(""), false}, @@ -51,6 +52,9 @@ func TestShouldWhitelabelChannelType(t *testing.T) { if !ShouldWhitelabelChannelType(constant.ChannelTypeBytePlus) { t.Errorf("expected BytePlus channel type %d to be whitelabeled", constant.ChannelTypeBytePlus) } + if !ShouldWhitelabelChannelType(constant.ChannelTypeModelAPISeedance) { + t.Errorf("expected ModelAPI channel type %d to be whitelabeled", constant.ChannelTypeModelAPISeedance) + } if ShouldWhitelabelChannelType(0) { t.Error("zero channel type should not be whitelabeled") } @@ -81,6 +85,8 @@ func TestScrubBrandedText(t *testing.T) { {"contains techmobi name", "TechMobi task failed", generic}, {"contains byteplus host", "ark.ap-southeast.bytepluses.com returned 500", generic}, {"contains byteplus name", "BytePlus task failed", generic}, + {"contains modelapi host", "api.modelapi.co returned 500", generic}, + {"contains modelapi name", "ModelAPI seedance failed", generic}, {"contains endpoint id", "endpoint ep-test-secret rejected the request", generic}, {"unrelated word with substring", "kuai noodles", "kuai noodles"}, } diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 16e6be26a6e..29d9a7e5535 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -46,6 +46,7 @@ import ( taskjimengzhizinan "github.com/QuantumNous/new-api/relay/channel/task/jimengzhizinan" "github.com/QuantumNous/new-api/relay/channel/task/kling" taskkuaizi "github.com/QuantumNous/new-api/relay/channel/task/kuaizi" + taskmodelapiseedance "github.com/QuantumNous/new-api/relay/channel/task/modelapiseedance" tasksonilo "github.com/QuantumNous/new-api/relay/channel/task/sonilo" tasksora "github.com/QuantumNous/new-api/relay/channel/task/sora" "github.com/QuantumNous/new-api/relay/channel/task/suno" @@ -223,6 +224,8 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { return &tasktechmobi.TaskAdaptor{} case constant.ChannelTypeBytePlus: return &taskbyteplus.TaskAdaptor{} + case constant.ChannelTypeModelAPISeedance: + return &taskmodelapiseedance.TaskAdaptor{} case constant.ChannelTypeXaiGrokVideo: return &taskxaigrok.TaskAdaptor{} case constant.ChannelTypeSonilo: diff --git a/relay/relay_adaptor_test.go b/relay/relay_adaptor_test.go index 72742c21b51..59eb142b391 100644 --- a/relay/relay_adaptor_test.go +++ b/relay/relay_adaptor_test.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel/task/byteplus" "github.com/QuantumNous/new-api/relay/channel/task/hailuo" hailuov2 "github.com/QuantumNous/new-api/relay/channel/task/hailuo_v2" + "github.com/QuantumNous/new-api/relay/channel/task/modelapiseedance" "github.com/QuantumNous/new-api/relay/channel/task/sonilo" ) @@ -103,3 +104,16 @@ func TestGetTaskAdaptor_MiniMaxVersions(t *testing.T) { }) } } + +func TestGetTaskAdaptor_ModelAPISeedance(t *testing.T) { + adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance))) + if adaptor == nil { + t.Fatal("expected ModelAPISeedance task adaptor") + } + if _, ok := adaptor.(*modelapiseedance.TaskAdaptor); !ok { + t.Fatalf("adaptor type = %T, want *modelapiseedance.TaskAdaptor", adaptor) + } + if got := adaptor.GetChannelName(); got != "modelapi-seedance" { + t.Fatalf("channel name = %q, want modelapi-seedance", got) + } +} From 596c5dc52dad1853ff6e7c21d87f73dfea3b6bc2 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:20:17 +0800 Subject: [PATCH 03/32] Reuse durable video delivery without creating unbounded telemetry Constraint: fixed labels never contain upstream/storage identifiers Confidence: high Scope-risk: narrow Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -p 1 ./service ./pkg/perf_metrics -run 'VideoResultChannel|ModelAPI.*Metric|ArchiveVideoResultForChannel' -count=1 Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -p 1 ./service ./pkg/perf_metrics -run 'VideoResult|ArchiveVideoResult' -count=1 --- pkg/perf_metrics/video_result.go | 4 +- pkg/perf_metrics/video_result_test.go | 53 ++++++++++++++++++++++++++- service/video_result_channels.go | 18 +++++++++ service/video_result_channels_test.go | 17 +++++++++ service/video_result_storage.go | 6 ++- service/video_result_storage_test.go | 24 ++++++++++++ 6 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 service/video_result_channels.go create mode 100644 service/video_result_channels_test.go diff --git a/pkg/perf_metrics/video_result.go b/pkg/perf_metrics/video_result.go index 671f610585a..dbf1fd5061f 100644 --- a/pkg/perf_metrics/video_result.go +++ b/pkg/perf_metrics/video_result.go @@ -9,7 +9,7 @@ import ( ) const ( - videoResultChannelCount = 1 + videoResultChannelCount = 2 videoResultArchiveOutcomeCount = 3 videoResultArchiveDurationBucketCount = 13 videoResultRedirectOutcomeCount = 4 @@ -17,7 +17,7 @@ const ( ) var ( - videoResultChannels = [videoResultChannelCount]string{"techmobi"} + videoResultChannels = [videoResultChannelCount]string{"techmobi", "modelapi"} videoResultArchiveOutcomes = [videoResultArchiveOutcomeCount]string{"success", "failure", "reuse"} videoResultRedirectOutcomes = [videoResultRedirectOutcomeCount]string{"success", "expired", "unavailable", "signing-or-other"} videoResultArchiveRetryReasons = [videoResultArchiveRetryReasonCount]string{"archive_failure"} diff --git a/pkg/perf_metrics/video_result_test.go b/pkg/perf_metrics/video_result_test.go index 1cd62cb34db..7f12abce3f3 100644 --- a/pkg/perf_metrics/video_result_test.go +++ b/pkg/perf_metrics/video_result_test.go @@ -32,6 +32,53 @@ func TestVideoResultMetricsExportArchiveRedirectAndRetryCounters(t *testing.T) { requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) } +func TestModelAPIVideoResultMetricsExportArchiveRedirectAndRetryCounters(t *testing.T) { + resetPerfMetricsStateForTest(t) + resetVideoResultMetricsWithCleanup(t) + + RecordVideoResultArchive("modelapi", "success", 321, 3*time.Second) + RecordVideoResultRedirect("modelapi", "success") + RecordVideoResultRedirect("modelapi", "expired") + RecordVideoResultArchiveRetry("modelapi", "archive_failure") + + text, err := BuildPrometheusText(context.Background()) + require.NoError(t, err) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 321`) + requirePrometheusSampleLine(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="expired"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_retry_total{channel="modelapi",reason="archive_failure"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_duration_seconds_count{channel="modelapi"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_duration_seconds_sum{channel="modelapi"} 3`) + requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) +} + +func TestModelAPIUnknownMetricLabelsDoNotGrowCardinality(t *testing.T) { + resetPerfMetricsStateForTest(t) + resetVideoResultMetricsWithCleanup(t) + + RecordVideoResultArchive("modelapi", "success", 321, time.Second) + text, err := BuildPrometheusText(context.Background()) + require.NoError(t, err) + seriesBefore := prometheusSampleValue(t, text, "newapi_perf_metrics_series") + + RecordVideoResultArchive("modelapi/task_1", "success", 999, time.Second) + RecordVideoResultArchive("modelapi", "task_1", 999, time.Second) + RecordVideoResultRedirect("modelapi/task_1", "success") + RecordVideoResultRedirect("modelapi", "https://signed.example/object") + RecordVideoResultArchiveRetry("modelapi", "task_1") + + text, err = BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Equal(t, seriesBefore, prometheusSampleValue(t, text, "newapi_perf_metrics_series")) + require.NotContains(t, text, "modelapi/task_1") + require.NotContains(t, text, "task_1") + require.NotContains(t, text, "signed.example") + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + requirePrometheusSampleLine(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 321`) + requirePrometheusSeriesGaugeMatchesRenderedSamples(t, text) +} + func TestVideoResultMetricsCountsBytesOnlyForSuccessfulArchives(t *testing.T) { resetPerfMetricsStateForTest(t) resetVideoResultMetricsWithCleanup(t) @@ -119,7 +166,11 @@ func TestVideoResultMetricsUseOnlyClosedLabelValues(t *testing.T) { if !strings.HasPrefix(line, "newapi_video_result_") { continue } - require.Contains(t, line, `channel="techmobi"`) + require.True(t, + strings.Contains(line, `channel="techmobi"`) || strings.Contains(line, `channel="modelapi"`), + "unexpected video result channel label in %q", + line, + ) require.NotContains(t, line, "task_") require.NotContains(t, line, "video-results/") require.NotContains(t, line, "http") diff --git a/service/video_result_channels.go b/service/video_result_channels.go new file mode 100644 index 00000000000..8b0a3339453 --- /dev/null +++ b/service/video_result_channels.go @@ -0,0 +1,18 @@ +package service + +import "github.com/QuantumNous/new-api/constant" + +// VideoResultChannelLabel returns the fixed metrics/archival channel label for +// channels whose completed video should be archived into GCS and re-served via +// the signed download proxy. Empty means the channel does not use the archive +// redirect path. +func VideoResultChannelLabel(channelType int) string { + switch channelType { + case constant.ChannelTypeTechMobiVideo: + return "techmobi" + case constant.ChannelTypeModelAPISeedance: + return "modelapi" + default: + return "" + } +} diff --git a/service/video_result_channels_test.go b/service/video_result_channels_test.go new file mode 100644 index 00000000000..7cd8ad2a0cf --- /dev/null +++ b/service/video_result_channels_test.go @@ -0,0 +1,17 @@ +package service + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/require" +) + +func TestVideoResultChannelLabel(t *testing.T) { + require.Equal(t, "techmobi", VideoResultChannelLabel(constant.ChannelTypeTechMobiVideo)) + require.Equal(t, "modelapi", VideoResultChannelLabel(constant.ChannelTypeModelAPISeedance)) + + for _, channelType := range []int{0, 1, 104, 106, 110, 112, 999} { + require.Empty(t, VideoResultChannelLabel(channelType)) + } +} diff --git a/service/video_result_storage.go b/service/video_result_storage.go index 6361189bb06..bb650a53ac5 100644 --- a/service/video_result_storage.go +++ b/service/video_result_storage.go @@ -135,9 +135,13 @@ func CurrentVideoResultStorageConfig() VideoResultStorageConfig { } func ArchiveVideoResult(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return ArchiveVideoResultForChannel(ctx, "techmobi", publicTaskID, upstreamURL, proxy) +} + +func ArchiveVideoResultForChannel(ctx context.Context, channel, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { archiveStart := videoResultNow().UTC() recordArchive := func(outcome string, bytes int64) { - perfmetrics.RecordVideoResultArchive("techmobi", outcome, bytes, videoResultNow().UTC().Sub(archiveStart)) + perfmetrics.RecordVideoResultArchive(channel, outcome, bytes, videoResultNow().UTC().Sub(archiveStart)) } cfg := CurrentVideoResultStorageConfig() if strings.TrimSpace(cfg.Bucket) == "" { diff --git a/service/video_result_storage_test.go b/service/video_result_storage_test.go index b9fcdc1cced..eb772c551a9 100644 --- a/service/video_result_storage_test.go +++ b/service/video_result_storage_test.go @@ -135,6 +135,30 @@ func TestArchiveVideoResult(t *testing.T) { require.Contains(t, text, `newapi_video_result_archive_bytes_total{channel="techmobi"} 16`) }) + t.Run("archives modelapi video with modelapi metric label", func(t *testing.T) { + resetVideoResultMetricsForServiceTest(t) + start := time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + + server := newVideoResultTestServer(t, http.StatusOK, "video/mp4", string(payload)) + defer server.Close() + + result, err := ArchiveVideoResultForChannel(context.Background(), "modelapi", "task_modelapi_archive", server.URL, "") + require.NoError(t, err) + require.Equal(t, "video-results/20260806/task_modelapi_archive.mp4", result.Object) + require.Contains(t, store.created, "video-bucket/video-results/20260806/task_modelapi_archive.mp4") + + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_archive_total{channel="modelapi",outcome="success"} 1`) + require.Contains(t, text, `newapi_video_result_archive_bytes_total{channel="modelapi"} 16`) + require.Contains(t, text, `newapi_video_result_archive_total{channel="techmobi",outcome="success"} 0`) + }) + for _, testCase := range []struct { name string taskID string From 523102fe5698f808311a98b5d0e2b4be7f7e5eda Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:28:50 +0800 Subject: [PATCH 04/32] Do not report generated video success before a durable copy exists Constraint: terminal settlement remains guarded by existing multi-node CAS Rejected: persisting upstream URL | archived whitelabel results must serve through the proxy and persisted polling payloads must not retain provider URLs Confidence: high Scope-risk: moderate Directive: new archive channels require fixed label/redaction Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -p 1 ./service -run 'ModelAPI.*Archive|ModelAPI.*Redact|UpdateVideoSingleTask' -count=1 Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -v -timeout 60s -p 1 ./service -run 'UpdateVideoSingleTask|RedactTechMobiVideoResponseBody|ModelAPI.*Archive|ModelAPI.*Redact' -count=1 --- service/task_polling.go | 102 ++++-- service/task_polling_video_result_test.go | 393 +++++++++++++++++++++- 2 files changed, 464 insertions(+), 31 deletions(-) diff --git a/service/task_polling.go b/service/task_polling.go index 39504858a7c..19d9b8974c7 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -52,9 +52,13 @@ type perCallTaskBillingAdjuster interface { // 打破 service -> relay -> relay/channel -> service 的循环依赖。 var GetTaskAdaptorFunc func(platform constant.TaskPlatform) TaskPollingAdaptor +var archiveVideoResultForChannel = ArchiveVideoResultForChannel var archiveTechMobiVideoResult = ArchiveVideoResult +var archiveModelAPIVideoResult = func(ctx context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + return archiveVideoResultForChannel(ctx, "modelapi", publicTaskID, upstreamURL, proxy) +} -var techMobiLogURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) +var archivedVideoLogURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) // sweepTimedOutTasks 在主轮询之前独立清理超时任务。 // 每次最多处理 100 条,剩余的下个周期继续处理。 @@ -398,7 +402,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return fmt.Errorf("readAll failed for task %s: %w", taskId, err) } - if ch.Type == constant.ChannelTypeTechMobiVideo { + if VideoResultChannelLabel(ch.Type) != "" { logger.LogDebug(ctx, "updateVideoSingleTask response received: task_id=%s upstream_task_id=%s phase=fetched bytes=%d", task.TaskID, task.GetUpstreamTaskID(), len(responseBody)) } else { logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) @@ -410,7 +414,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * // try parse as New API response format var responseItems dto.TaskResponse[model.Task] if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { - if ch.Type == constant.ChannelTypeTechMobiVideo { + if VideoResultChannelLabel(ch.Type) != "" { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s upstream_task_id=%s phase=parsed status=%s", task.TaskID, task.GetUpstreamTaskID(), responseItems.Data.Status) } else { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems) @@ -428,7 +432,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task.Data = redactVideoResponseForChannel(ch.Type, responseBody) - if ch.Type == constant.ChannelTypeTechMobiVideo { + if VideoResultChannelLabel(ch.Type) != "" { logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s upstream_task_id=%s phase=parsed status=%s progress=%s", task.TaskID, task.GetUpstreamTaskID(), taskResult.Status, taskResult.Progress) } else { logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) @@ -451,7 +455,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult = relaycommon.FailTaskInfo("upstream returned error") } else { // unknown error format, log original response - if ch.Type == constant.ChannelTypeTechMobiVideo { + if VideoResultChannelLabel(ch.Type) != "" { logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format", taskId)) } else { logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) @@ -461,16 +465,32 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } } - if returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { + archiveChannelLabel := VideoResultChannelLabel(ch.Type) + if (returnSourceURL || (archiveChannelLabel != "" && !returnSourceURL)) && + taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { + if archiveChannelLabel != "" { + return fmt.Errorf("%s task %s missing source URL", archiveChannelLabel, task.TaskID) + } return fmt.Errorf("techmobi task %s missing source URL", task.TaskID) } - if ch.Type == constant.ChannelTypeTechMobiVideo && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { + if archiveChannelLabel != "" && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { if task.PrivateData.VideoResult == nil { - videoResult, archiveErr := archiveTechMobiVideoResult(ctx, task.TaskID, taskResult.Url, proxy) + var ( + videoResult *model.VideoResult + archiveErr error + ) + switch ch.Type { + case constant.ChannelTypeTechMobiVideo: + videoResult, archiveErr = archiveTechMobiVideoResult(ctx, task.TaskID, taskResult.Url, proxy) + case constant.ChannelTypeModelAPISeedance: + videoResult, archiveErr = archiveModelAPIVideoResult(ctx, task.TaskID, taskResult.Url, proxy) + default: + videoResult, archiveErr = archiveVideoResultForChannel(ctx, archiveChannelLabel, task.TaskID, taskResult.Url, proxy) + } if archiveErr != nil { - perfmetrics.RecordVideoResultArchiveRetry("techmobi", "archive_failure") - return fmt.Errorf("archive techmobi video result failed for task %s: %s", task.TaskID, sanitizeVideoResultArchiveError(archiveErr)) + perfmetrics.RecordVideoResultArchiveRetry(archiveChannelLabel, "archive_failure") + return fmt.Errorf("archive %s video result failed for task %s: %s", archiveChannelLabel, task.TaskID, sanitizeVideoResultArchiveError(archiveErr)) } task.PrivateData.VideoResult = videoResult } @@ -520,8 +540,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } shouldSettle = true case model.TaskStatusFailure: - if ch.Type == constant.ChannelTypeTechMobiVideo { - logger.LogInfo(ctx, fmt.Sprintf("TechMobi task failed: task_id=%s channel_id=%d status=%s reason=%s", task.TaskID, ch.Id, taskResult.Status, sanitizeTechMobiLogText(taskResult.Reason))) + if VideoResultChannelLabel(ch.Type) != "" { + logger.LogInfo(ctx, fmt.Sprintf("Archived video task failed: task_id=%s channel_id=%d status=%s reason=%s", task.TaskID, ch.Id, taskResult.Status, sanitizeArchivedVideoLogText(ch.Type, taskResult.Reason))) } else { logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task) } @@ -531,8 +551,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task.FinishTime = now } task.FailReason = taskResult.Reason - if ch.Type == constant.ChannelTypeTechMobiVideo { - task.FailReason = sanitizeTechMobiLogText(task.FailReason) + if VideoResultChannelLabel(ch.Type) != "" { + task.FailReason = sanitizeArchivedVideoLogText(ch.Type, task.FailReason) } logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) taskResult.Progress = taskcommon.ProgressComplete @@ -609,17 +629,24 @@ func redactVideoResponseBody(body []byte) []byte { func redactVideoResponseForChannel(channelType int, body []byte) []byte { redacted := redactVideoResponseBody(body) if channelType == constant.ChannelTypeTechMobiVideo { - return redactTechMobiVideoResponseBody(redacted) + return redactArchivedVideoResponseBody(redacted, false) + } + if channelType == constant.ChannelTypeModelAPISeedance { + return redactArchivedVideoResponseBody(redacted, true) } return redacted } func redactTechMobiVideoResponseBody(body []byte) []byte { + return redactArchivedVideoResponseBody(body, false) +} + +func redactArchivedVideoResponseBody(body []byte, scrubBrand bool) []byte { var value any if err := common.Unmarshal(body, &value); err != nil { return body } - redacted := redactTechMobiVideoValue(value) + redacted := redactArchivedVideoValue(value, scrubBrand) b, err := common.Marshal(redacted) if err != nil { return body @@ -627,34 +654,38 @@ func redactTechMobiVideoResponseBody(body []byte) []byte { return b } -func redactTechMobiVideoValue(v any) any { +func redactArchivedVideoValue(v any, scrubBrand bool) any { switch value := v.(type) { case map[string]any: for key, child := range value { - if isTechMobiVideoURLKey(key) { - value[key] = redactTechMobiVideoURLValue(child) + if isArchivedVideoURLKey(key) { + value[key] = redactArchivedVideoURLValue(child, scrubBrand) continue } - value[key] = redactTechMobiVideoValue(child) + value[key] = redactArchivedVideoValue(child, scrubBrand) } return value case []any: for i, child := range value { - value[i] = redactTechMobiVideoValue(child) + value[i] = redactArchivedVideoValue(child, scrubBrand) } return value case string: - return redactTechMobiURLs(value) + return sanitizeArchivedVideoString(value, scrubBrand) default: return value } } -func redactTechMobiVideoURLValue(v any) any { - return redactTechMobiVideoValue(v) +func redactArchivedVideoURLValue(v any, scrubBrand bool) any { + return redactArchivedVideoValue(v, scrubBrand) } func isTechMobiVideoURLKey(key string) bool { + return isArchivedVideoURLKey(key) +} + +func isArchivedVideoURLKey(key string) bool { normalized := strings.ToLower(strings.ReplaceAll(key, "_", "")) switch normalized { case "url", "videourl", "downloadurl", "fileurl", "objecturl", "remoteurl": @@ -689,15 +720,32 @@ func sanitizeVideoResultArchiveError(err error) string { return "archive unavailable" } -func sanitizeTechMobiLogText(text string) string { +func sanitizeArchivedVideoLogText(channelType int, text string) string { if strings.TrimSpace(text) == "" { return "" } - return taskcommon.ScrubBrandedText(redactTechMobiURLs(text)) + scrubBrand := channelType == constant.ChannelTypeModelAPISeedance + return sanitizeArchivedVideoString(text, scrubBrand) +} + +func sanitizeTechMobiLogText(text string) string { + return sanitizeArchivedVideoLogText(constant.ChannelTypeTechMobiVideo, text) +} + +func sanitizeArchivedVideoString(text string, scrubBrand bool) string { + redacted := redactArchivedVideoURLs(text) + if scrubBrand { + return taskcommon.ScrubBrandedText(redacted) + } + return redacted } func redactTechMobiURLs(text string) string { - return techMobiLogURLPattern.ReplaceAllStringFunc(text, func(match string) string { + return redactArchivedVideoURLs(text) +} + +func redactArchivedVideoURLs(text string) string { + return archivedVideoLogURLPattern.ReplaceAllStringFunc(text, func(match string) string { trimmed := strings.TrimRight(match, ",.;:)") return "[redacted]" + match[len(trimmed):] }) diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index b08d18ce4f9..fc6f456cd3d 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "net/http" + "strings" "testing" "time" @@ -292,6 +293,303 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="techmobi",reason="archive_failure"} 1`) } +func TestUpdateVideoSingleTaskModelAPIArchivesAndSetsProxyURL(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 910, 1000) + seedToken(t, 920, 910, "sk-modelapi-archive-success", 500) + task := newModelAPIPollingTask(t, 910, 940, 100, 920) + ch := newModelAPIPollingChannel("http://proxy.internal:8080") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + expected := &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_modelapi_success.mp4", + Generation: 12, + ContentType: "video/mp4", + Size: 2048, + StoredAt: time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC).Unix(), + ExpiresAt: time.Date(2026, 8, 7, 1, 2, 3, 0, time.UTC).Unix(), + } + var archiveCalls int + archiveModelAPIVideoResult = func(_ context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { + archiveCalls++ + require.Equal(t, "task_modelapi_success", publicTaskID) + require.Equal(t, "https://secret.example/video.mp4?token=secret", upstreamURL) + require.Equal(t, "http://proxy.internal:8080", proxy) + return expected, nil + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.NoError(t, err) + require.Equal(t, 1, archiveCalls) + require.Equal(t, 1, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusSuccess, stored.Status) + require.Equal(t, taskcommon.BuildProxyURL(task.TaskID), stored.PrivateData.ResultURL) + require.Equal(t, expected, stored.PrivateData.VideoResult) + require.NotContains(t, string(stored.Data), "https://") + require.NotContains(t, string(stored.Data), "api.modelapi.co") + require.NotContains(t, strings.ToLower(string(stored.Data)), "modelapi") + require.NotContains(t, string(stored.Data), "secret.example") + +} + +func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + resetVideoResultMetricsForServiceTest(t) + ctx := context.Background() + + seedUser(t, 911, 1000) + seedToken(t, 921, 911, "sk-modelapi-archive-error", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_archive_error", 911, 941, 100, 921) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return nil, errors.New("download failed from https://secret.example/video.mp4?token=secret") + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "archive modelapi video result failed") + require.NotContains(t, err.Error(), "secret.example") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Nil(t, stored.PrivateData.VideoResult) + require.Empty(t, stored.PrivateData.ResultURL) + require.Equal(t, 100, stored.Quota) + + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="modelapi",reason="archive_failure"} 1`) +} + +func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 912, 1000) + seedToken(t, 922, 912, "sk-modelapi-empty-url", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_empty_url", 912, 942, 100, 922) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: " ", + Progress: "100%", + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + t.Fatal("archive hook must not be called when ModelAPI success URL is empty") + return nil, nil + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "missing source URL") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Nil(t, stored.PrivateData.VideoResult) + require.Empty(t, stored.PrivateData.ResultURL) +} + +func TestUpdateVideoSingleTaskModelAPIRedactsStoredDataAndLogs(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 913, 1000) + seedToken(t, 923, 913, "sk-modelapi-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_redaction", 913, 943, 100, 923) + ch := newModelAPIPollingChannel("") + upstreamURL := "https://api.modelapi.co/private/video.mp4?token=secret" + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIRedactionResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: upstreamURL, + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_archive_redaction.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + require.NoError(t, updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + storedData := string(stored.Data) + require.NotContains(t, storedData, upstreamURL) + require.NotContains(t, storedData, "https://") + require.NotContains(t, storedData, "api.modelapi.co") + require.NotContains(t, strings.ToLower(storedData), "modelapi") + + logText := logs.String() + require.NotContains(t, logText, upstreamURL) + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, strings.ToLower(logText), "modelapi") +} + +func TestUpdateVideoSingleTaskModelAPIFailureRedactsDBAndLogs(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 914, 1000) + seedToken(t, 924, 914, "sk-modelapi-failure-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_failure", 914, 944, 100, 924) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIFailureResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusFailure, + Reason: "ModelAPI render failed at https://api.modelapi.co/private/failure.mp4?token=secret", + Progress: "100%", + }, + } + + require.NoError(t, updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusFailure, stored.Status) + require.NotContains(t, strings.ToLower(stored.FailReason), "modelapi") + require.NotContains(t, stored.FailReason, "https://") + require.NotContains(t, stored.FailReason, "api.modelapi.co") + require.NotContains(t, strings.ToLower(string(stored.Data)), "modelapi") + require.NotContains(t, string(stored.Data), "https://") + require.NotContains(t, string(stored.Data), "api.modelapi.co") + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") +} + +func TestUpdateVideoSingleTaskModelAPIUnknownErrorFormatDoesNotLogRawResponse(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 915, 1000) + seedToken(t, 925, 915, "sk-modelapi-unknown-redaction", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_unknown", 915, 945, 100, 925) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"unexpected":"ModelAPI raw https://api.modelapi.co/private/video.mp4?token=secret"}`), + taskResult: &relaycommon.TaskInfo{}, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.NoError(t, err) + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") +} + +func TestUpdateVideoSingleTaskModelAPICASLoserDoesNotSettleTwice(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 916, 1000) + seedToken(t, 926, 916, "sk-modelapi-cas-loser", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_cas_loser", 916, 946, 100, 926) + var staleTask model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&staleTask).Error) + + ch := newModelAPIPollingChannel("") + winnerAdaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + loserAdaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + TotalTokens: 40, + }, + actualQuota: 40, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_modelapi_cas_loser.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + require.NoError(t, updateVideoSingleTask(ctx, winnerAdaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task))) + require.NoError(t, updateVideoSingleTask(ctx, loserAdaptor, ch, staleTask.GetUpstreamTaskID(), modelAPITaskMap(&staleTask))) + require.Equal(t, 1, winnerAdaptor.adjustCalls) + require.Equal(t, 0, loserAdaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusSuccess, stored.Status) + require.Equal(t, 40, stored.PrivateData.TotalTokens) +} + func TestUpdateVideoSingleTaskArchiveSkipsExistingMetadata(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -409,7 +707,7 @@ func TestUpdateVideoSingleTaskArchiveFailurePayloadRedactsDBAndLogs(t *testing.T require.NotContains(t, string(stored.Data), "token=secret") var data map[string]any - require.NoError(t, json.Unmarshal(stored.Data, &data)) + require.NoError(t, common.Unmarshal(stored.Data, &data)) require.Equal(t, "failed", data["status"]) require.Equal(t, "render failed", data["reason"]) @@ -439,12 +737,13 @@ func TestRedactTechMobiVideoResponseBodyRemovesUpstreamURLsAndKeepsPublicFields( }`) redacted := redactTechMobiVideoResponseBody(body) - require.True(t, json.Valid(redacted)) + var redactedValue any + require.NoError(t, common.Unmarshal(redacted, &redactedValue)) require.NotContains(t, string(redacted), "secret.example") require.NotContains(t, string(redacted), "token=secret") var got map[string]any - require.NoError(t, json.Unmarshal(redacted, &got)) + require.NoError(t, common.Unmarshal(redacted, &got)) require.Equal(t, "upstream-techmobi-123", got["id"]) require.Equal(t, "succeeded", got["status"]) require.Equal(t, "100%", got["progress"]) @@ -491,7 +790,13 @@ func TestRedactTechMobiVideoResponseBodyHandlesTopLevelValues(t *testing.T) { func restoreArchiveHookForPollingTest(t *testing.T) { t.Helper() original := archiveTechMobiVideoResult - t.Cleanup(func() { archiveTechMobiVideoResult = original }) + originalModelAPI := archiveModelAPIVideoResult + originalForChannel := archiveVideoResultForChannel + t.Cleanup(func() { + archiveTechMobiVideoResult = original + archiveModelAPIVideoResult = originalModelAPI + archiveVideoResultForChannel = originalForChannel + }) } func capturePollingLogs(t *testing.T) *bytes.Buffer { @@ -544,6 +849,86 @@ func newTechMobiPollingTask(t *testing.T, userID, channelID, quota, tokenID int) return task } +func newModelAPIPollingTask(t *testing.T, userID, channelID, quota, tokenID int) *model.Task { + t.Helper() + return newModelAPIPollingTaskWithID(t, "task_modelapi_success", userID, channelID, quota, tokenID) +} + +func newModelAPIPollingTaskWithID(t *testing.T, taskID string, userID, channelID, quota, tokenID int) *model.Task { + t.Helper() + task := &model.Task{ + TaskID: taskID, + UserId: userID, + ChannelId: channelID, + Quota: quota, + Status: model.TaskStatusInProgress, + Group: "default", + Progress: "50%", + Data: json.RawMessage(`{"status":"processing"}`), + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-video-success", + BillingSource: BillingSourceWallet, + TokenId: tokenID, + BillingContext: &model.TaskBillingContext{OriginModelName: "seedance-2.5"}, + }, + Properties: model.Properties{OriginModelName: "seedance-2.5"}, + } + require.NoError(t, model.DB.Create(task).Error) + return task +} + +func newModelAPIPollingChannel(proxy string) *model.Channel { + ch := &model.Channel{ + Id: 940, + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-modelapi", + Status: 1, + } + if proxy != "" { + ch.SetSetting(dto.ChannelSettings{Proxy: proxy}) + } + return ch +} + +func modelAPIArchiveResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "status":"succeeded", + "result":{"assets":[{"type":"video","url":"https://secret.example/video.mp4?token=secret"}]}, + "usage":{"total_tokens":40} + }`) +} + +func modelAPIRedactionResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "status":"succeeded", + "result":{ + "assets":[ + {"type":"video","url":"https://api.modelapi.co/private/video.mp4?token=secret"}, + {"type":"thumbnail","download_url":"https://api.modelapi.co/private/thumb.jpg?token=secret"} + ], + "message":"ModelAPI asset at https://api.modelapi.co/private/video.mp4?token=secret" + }, + "usage":{"total_tokens":40} + }`) +} + +func modelAPIFailureResponseBody() []byte { + return []byte(`{ + "id":"upstream-modelapi-success", + "status":"failed", + "reason":"ModelAPI render failed at https://api.modelapi.co/private/failure.mp4?token=secret", + "result":{"assets":[{"url":"https://api.modelapi.co/private/video.mp4?token=secret"}]} + }`) +} + +func modelAPITaskMap(task *model.Task) map[string]*model.Task { + return map[string]*model.Task{task.GetUpstreamTaskID(): task} +} + func newTechMobiPollingChannel(proxy string) *model.Channel { ch := &model.Channel{ Id: 931, From 5b71db6143d0594ba1730025a783e2183d74e22d Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:29:53 +0800 Subject: [PATCH 05/32] Expose the new video supplier through existing Flatkey administration surfaces Constraint: internal configurable channel only Confidence: high Scope-risk: narrow Tested: Push-Location web/default; bun test src/features/channels/constants.test.ts; Pop-Location Tested: Push-Location web/default; bun run build; Pop-Location Tested: Push-Location web/classic; bun run build; Pop-Location Tested: Push-Location web/default; bun run typecheck; Pop-Location --- .../src/constants/channel.constants.js | 5 +++++ .../src/features/channels/constants.test.ts | 20 +++++++++++++++++++ .../src/features/channels/constants.ts | 3 +++ .../channels/lib/channel-type-config.ts | 12 +++++++++++ .../features/channels/lib/channel-utils.ts | 1 + 5 files changed, 41 insertions(+) diff --git a/web/classic/src/constants/channel.constants.js b/web/classic/src/constants/channel.constants.js index 6613a326bb4..f609e24fe8c 100644 --- a/web/classic/src/constants/channel.constants.js +++ b/web/classic/src/constants/channel.constants.js @@ -216,6 +216,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'TechMobiVideo', }, + { + value: 111, + color: 'blue', + label: 'ModelAPISeedance', + }, ]; // Channel types that support upstream model list fetching in UI. diff --git a/web/default/src/features/channels/constants.test.ts b/web/default/src/features/channels/constants.test.ts index bebe6f9bb5c..19de46bd084 100644 --- a/web/default/src/features/channels/constants.test.ts +++ b/web/default/src/features/channels/constants.test.ts @@ -10,6 +10,7 @@ import { getChannelTypeHints, getDefaultBaseUrl, } from './lib/channel-type-config' +import { getChannelTypeIcon, getKeyPromptForType } from './lib/channel-utils' test('Jimeng zhizinan channel is selectable and model-fetchable', () => { expect(CHANNEL_TYPES[104]).toBe('JimengZhizinan') @@ -58,3 +59,22 @@ test('MiniMax H3 channel has a visible channel type label', () => { expect(CHANNEL_TYPES[110]).toBe('MiniMax H3') expect(CHANNEL_TYPE_OPTIONS.some((option) => option.value === 110)).toBe(true) }) + +test('ModelAPISeedance channel is selectable with internal video-channel metadata only', () => { + expect(CHANNEL_TYPES[111]).toBe('ModelAPISeedance') + expect(CHANNEL_TYPE_OPTIONS.some((option) => option.value === 111)).toBe(true) + expect(MODEL_FETCHABLE_TYPES.has(111)).toBe(false) + expect(CREATE_MODEL_FETCHABLE_TYPES.has(111)).toBe(false) + expect(getDefaultBaseUrl(111)).toBe('https://api.modelapi.co') + expect(getChannelTypeIcon(111)).toBe('Doubao') + expect(getKeyPromptForType(111)).toBe('API key from the provider') + + const config = getChannelTypeConfig(111) + const hints = getChannelTypeHints(111) + + expect(config.icon).toBe('doubao') + expect(config.supportedModels).toEqual(['doubao-seedance-2-5-260628']) + expect(hints.key).toBe('API key from the provider') + expect(hints.models).toBe('doubao-seedance-2-5-260628') + expect(hints.other).toBeUndefined() +}) diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index bf57db687e3..5b0d2ce6fa0 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -88,12 +88,14 @@ export const CHANNEL_TYPES = { 107: 'BytePlus', 109: 'Sonilo', 110: 'MiniMax H3', + 111: 'ModelAPISeedance', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ 1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 100, 4, 40, 27, 25, 17, 26, 15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50, 51, 52, 53, 54, 55, 56, 58, 101, 102, 103, 104, 105, 107, 109, 110, + 111, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { @@ -412,6 +414,7 @@ export const TYPE_TO_KEY_PROMPT: Record = { 50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)', 51: 'Format: Access Key ID|Secret Access Key', 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', + 111: 'API key from the provider', } export const CHANNEL_TYPE_WARNINGS: Record = { diff --git a/web/default/src/features/channels/lib/channel-type-config.ts b/web/default/src/features/channels/lib/channel-type-config.ts index 7eabdbaafc0..05a10516bf1 100644 --- a/web/default/src/features/channels/lib/channel-type-config.ts +++ b/web/default/src/features/channels/lib/channel-type-config.ts @@ -206,6 +206,18 @@ export const CHANNEL_TYPE_CONFIGS: Record = { 'Async video-to-music channel. The gateway reserves by declared duration and serves completed audio through a Flatkey proxy.', }, }, + 111: { + id: 111, + name: CHANNEL_TYPES[111], + icon: 'doubao', + defaultBaseUrl: 'https://api.modelapi.co', + supportedModels: ['doubao-seedance-2-5-260628'], + hints: { + baseUrl: 'Default: https://api.modelapi.co', + key: 'API key from the provider', + models: 'doubao-seedance-2-5-260628', + }, + }, } /** diff --git a/web/default/src/features/channels/lib/channel-utils.ts b/web/default/src/features/channels/lib/channel-utils.ts index 3dac65798e6..30a1e50e661 100644 --- a/web/default/src/features/channels/lib/channel-utils.ts +++ b/web/default/src/features/channels/lib/channel-utils.ts @@ -104,6 +104,7 @@ export function getChannelTypeIcon(type: number): string { 58: 'Doubao', // KuaiziLizhen (proxies Seedance, reuse Doubao icon) 105: 'Doubao', // TechMobiVideo (Seedance-compatible video) 107: 'Doubao', // BytePlus (Ark Seedance-compatible video) + 111: 'Doubao', // ModelAPISeedance (Seedance-compatible video) 109: 'Suno', // Sonilo video-to-music 56: 'Replicate', // Replicate From 8e5e4bff475f72aca1ca10834eecfccf30f9f438 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:06:11 +0800 Subject: [PATCH 06/32] Keep Flatkey as the only public video address while Google serves the bytes Constraint: ModelAPI never falls back upstream Confidence: high Scope-risk: moderate Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -p 1 ./controller -run 'ArchivedModelAPI|ModelAPI.*WithoutArchive|LegacyTechMobi' -count=1 Tested: $env:GOCACHE=$PWD\.tmp-gocache; go test -p 1 ./controller -run 'VideoProxy|ArchivedTechMobi|ArchivedModelAPI|LegacyTechMobi' -count=1 --- controller/video_proxy.go | 25 ++- controller/video_proxy_video_result_test.go | 163 ++++++++++++++++++++ 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 430b4ed7a26..14fe2dfdfa9 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -88,7 +88,12 @@ func VideoProxy(c *gin.Context) { baseURL = "https://api.openai.com" } - if tryRedirectArchivedTechMobiVideo(c, task, channel) { + if tryRedirectArchivedVideoResult(c, task, channel) { + return + } + if channel.Type == constant.ChannelTypeModelAPISeedance { + perfmetrics.RecordVideoResultRedirect("modelapi", "unavailable") + videoProxyError(c, http.StatusBadGateway, "server_error", "video result is unavailable") return } @@ -226,13 +231,21 @@ func VideoProxy(c *gin.Context) { } func tryRedirectArchivedTechMobiVideo(c *gin.Context, task *model.Task, channel *model.Channel) bool { - if c == nil || task == nil || channel == nil || channel.Type != constant.ChannelTypeTechMobiVideo || task.PrivateData.VideoResult == nil { + return tryRedirectArchivedVideoResult(c, task, channel) +} + +func tryRedirectArchivedVideoResult(c *gin.Context, task *model.Task, channel *model.Channel) bool { + if c == nil || task == nil || channel == nil || task.PrivateData.VideoResult == nil { + return false + } + channelLabel := service.VideoResultChannelLabel(channel.Type) + if channelLabel == "" { return false } signedURL, err := signArchivedVideoResultDownload(c.Request.Context(), c.Param("task_id"), task.PrivateData.VideoResult) if err == nil { - perfmetrics.RecordVideoResultRedirect("techmobi", "success") + perfmetrics.RecordVideoResultRedirect(channelLabel, "success") c.Writer.Header().Set("Location", signedURL) c.Writer.Header().Set("Cache-Control", "no-store") c.Writer.Header().Set("Pragma", "no-cache") @@ -243,13 +256,13 @@ func tryRedirectArchivedTechMobiVideo(c *gin.Context, task *model.Task, channel switch { case errors.Is(err, service.ErrVideoResultExpired): - perfmetrics.RecordVideoResultRedirect("techmobi", "expired") + perfmetrics.RecordVideoResultRedirect(channelLabel, "expired") videoProxyError(c, http.StatusGone, "invalid_request_error", "video result has expired") case errors.Is(err, service.ErrVideoResultUnavailable): - perfmetrics.RecordVideoResultRedirect("techmobi", "unavailable") + perfmetrics.RecordVideoResultRedirect(channelLabel, "unavailable") videoProxyError(c, http.StatusBadGateway, "server_error", "video result is unavailable") default: - perfmetrics.RecordVideoResultRedirect("techmobi", "signing-or-other") + perfmetrics.RecordVideoResultRedirect(channelLabel, "signing-or-other") videoProxyError(c, http.StatusServiceUnavailable, "server_error", "video result is temporarily unavailable") } return true diff --git a/controller/video_proxy_video_result_test.go b/controller/video_proxy_video_result_test.go index 830f35fded0..a6d9d5fd0bc 100644 --- a/controller/video_proxy_video_result_test.go +++ b/controller/video_proxy_video_result_test.go @@ -8,13 +8,16 @@ import ( "testing" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics" tasktechmobi "github.com/QuantumNous/new-api/relay/channel/task/techmobi" "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestArchivedTechMobiVideoRedirect(t *testing.T) { @@ -124,6 +127,123 @@ func TestArchivedTechMobiVideoRedirect(t *testing.T) { }) } +func TestArchivedModelAPIVideoRedirect(t *testing.T) { + t.Run("success returns private redirect without cache", func(t *testing.T) { + resetVideoResultMetricsForControllerTest(t) + recorder, c := newArchivedVideoProxyContext("task_modelapi") + channel := &model.Channel{Type: constant.ChannelTypeModelAPISeedance} + task := archivedModelAPITask(time.Now().Add(time.Hour).Unix()) + installArchivedVideoResultSigner(t, func(_ context.Context, taskID string, result *model.VideoResult) (string, error) { + require.Equal(t, "task_modelapi", taskID) + require.Same(t, task.PrivateData.VideoResult, result) + return "https://signed.example/download?X-Goog-Signature=secret", nil + }) + + require.True(t, tryRedirectArchivedVideoResult(c, task, channel)) + require.Equal(t, http.StatusFound, recorder.Code) + require.Equal(t, "https://signed.example/download?X-Goog-Signature=secret", recorder.Header().Get("Location")) + require.Equal(t, "no-store", recorder.Header().Get("Cache-Control")) + require.Equal(t, "no-cache", recorder.Header().Get("Pragma")) + require.Empty(t, recorder.Body.String()) + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, `newapi_video_result_redirect_total{channel="modelapi",outcome="success"} 1`) + }) + + errorCases := []struct { + name string + signErr error + wantStatus int + wantBody string + wantMetric string + secretSnippet string + }{ + { + name: "expired returns 410 sanitized error", + signErr: service.ErrVideoResultExpired, + wantStatus: http.StatusGone, + wantBody: "video result has expired", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="expired"} 1`, + }, + { + name: "unavailable returns 502 sanitized error", + signErr: service.ErrVideoResultUnavailable, + wantStatus: http.StatusBadGateway, + wantBody: "video result is unavailable", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="unavailable"} 1`, + }, + { + name: "signing returns 503 sanitized error", + signErr: errors.New("secret https://storage.googleapis.com/video-bucket/object?X-Goog-Signature=abc"), + wantStatus: http.StatusServiceUnavailable, + wantBody: "video result is temporarily unavailable", + wantMetric: `newapi_video_result_redirect_total{channel="modelapi",outcome="signing-or-other"} 1`, + secretSnippet: "storage.googleapis.com", + }, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + resetVideoResultMetricsForControllerTest(t) + recorder, c := newArchivedVideoProxyContext("task_modelapi") + channel := &model.Channel{Type: constant.ChannelTypeModelAPISeedance} + installArchivedVideoResultSigner(t, func(context.Context, string, *model.VideoResult) (string, error) { + return "", tc.signErr + }) + + require.True(t, tryRedirectArchivedVideoResult(c, archivedModelAPITask(time.Now().Add(time.Hour).Unix()), channel)) + require.Equal(t, tc.wantStatus, recorder.Code) + require.Contains(t, recorder.Body.String(), tc.wantBody) + require.NotContains(t, recorder.Body.String(), "video-bucket") + if tc.secretSnippet != "" { + require.NotContains(t, recorder.Body.String(), tc.secretSnippet) + } + text, err := perfmetrics.BuildPrometheusText(context.Background()) + require.NoError(t, err) + require.Contains(t, text, tc.wantMetric) + }) + } +} + +func TestModelAPIVideoProxyWithoutArchiveDoesNotFetchUpstream(t *testing.T) { + restore := useVideoProxyDBForTest(t) + defer restore() + gin.SetMode(gin.TestMode) + + var upstreamHits int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("upstream bytes")) + })) + defer upstream.Close() + + channel := &model.Channel{ + Id: 11101, + Type: constant.ChannelTypeModelAPISeedance, + Key: "modelapi-key", + Name: "modelapi", + BaseURL: common.GetPointer(upstream.URL), + } + require.NoError(t, model.DB.Create(channel).Error) + require.NoError(t, model.DB.Create(&model.Task{ + TaskID: "task_modelapi_no_archive", + Status: model.TaskStatusSuccess, + ChannelId: channel.Id, + Data: []byte(`{"status":"succeeded","content":[{"type":"video_url","video_url":{"url":"` + upstream.URL + `/output.mp4"}}]}`), + }).Error) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Params = gin.Params{{Key: "task_id", Value: "task_modelapi_no_archive"}} + c.Request = httptest.NewRequest(http.MethodGet, "/v1/videos/task_modelapi_no_archive/content", nil) + + VideoProxy(c) + + require.Equal(t, http.StatusBadGateway, recorder.Code) + require.Contains(t, recorder.Body.String(), "video result is unavailable") + require.Equal(t, 0, upstreamHits) +} + func TestLegacyTechMobiVideoProxyUsesExtractorWhenMetadataNil(t *testing.T) { _, c := newArchivedVideoProxyContext("task_legacy") channel := &model.Channel{Type: constant.ChannelTypeTechMobiVideo} @@ -177,9 +297,52 @@ func archivedTechMobiTask(expiresAt int64) *model.Task { } } +func archivedModelAPITask(expiresAt int64) *model.Task { + return &model.Task{ + TaskID: "task_modelapi", + Status: model.TaskStatusSuccess, + PrivateData: model.TaskPrivateData{ + VideoResult: &model.VideoResult{ + Bucket: "video-bucket", + Object: "video-results/20260806/task_modelapi.mp4", + Generation: 7, + ContentType: "video/mp4", + Size: 42, + ExpiresAt: expiresAt, + }, + }, + } +} + func installArchivedVideoResultSigner(t *testing.T, signer func(context.Context, string, *model.VideoResult) (string, error)) { t.Helper() original := signArchivedVideoResultDownload signArchivedVideoResultDownload = signer t.Cleanup(func() { signArchivedVideoResultDownload = original }) } + +func useVideoProxyDBForTest(t *testing.T) func() { + t.Helper() + originalDB := model.DB + originalMemoryCacheEnabled := common.MemoryCacheEnabled + originalUsingSQLite := common.UsingSQLite + originalUsingMySQL := common.UsingMySQL + originalUsingPostgreSQL := common.UsingPostgreSQL + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Task{})) + model.DB = db + common.MemoryCacheEnabled = false + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + + return func() { + model.DB = originalDB + common.MemoryCacheEnabled = originalMemoryCacheEnabled + common.UsingSQLite = originalUsingSQLite + common.UsingMySQL = originalUsingMySQL + common.UsingPostgreSQL = originalUsingPostgreSQL + } +} From b9488fc5255cf83e3b06c1f68859ecbd5c966305 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:28:37 +0800 Subject: [PATCH 07/32] Translate the shared Seedance contract without leaking supplier semantics Constraint: explicit false/zero survive Rejected: provider-specific client input | the channel reuses the shared Seedance content contract and maps internally Confidence: high Scope-risk: moderate Directive: keep ModelAPI upstream task IDs and asset URLs internal; expose only public task IDs and proxy result URLs Tested: $env:GOCACHE="$PWD\.tmp-gocache"; go test -p 1 ./relay/channel/task/modelapiseedance ./dto -run 'ModelAPI|AudioOnly|Build|Validate|Parse|Fetch|Response|Convert' -count=1; $env:GOCACHE="$PWD\.tmp-gocache"; go test -p 1 ./relay/channel/task/modelapiseedance ./dto -count=1 Not-tested: live ModelAPI submission/polling against external service --- dto/video_seedance.go | 6 +- dto/video_seedance_test.go | 10 +- .../channel/task/modelapiseedance/adaptor.go | 366 ++++++++++++++- .../task/modelapiseedance/adaptor_test.go | 420 +++++++++++++++++- .../task/modelapiseedance/constants.go | 3 +- 5 files changed, 780 insertions(+), 25 deletions(-) diff --git a/dto/video_seedance.go b/dto/video_seedance.go index 4e421891fd9..c7c835dfa5e 100644 --- a/dto/video_seedance.go +++ b/dto/video_seedance.go @@ -125,10 +125,10 @@ func (r *SeedanceVideoRequest) HasFirstLastFrame() bool { } // Validate enforces the minimal seedance contract: a text prompt OR at least -// one image/video reference must be present. +// one media reference must be present. func (r *SeedanceVideoRequest) Validate() error { - if strings.TrimSpace(r.PromptText()) == "" && len(r.Images()) == 0 && len(r.Videos()) == 0 { - return errors.New("seedance request requires a text prompt or at least one image/video") + if strings.TrimSpace(r.PromptText()) == "" && len(r.Images()) == 0 && len(r.Videos()) == 0 && len(r.Audios()) == 0 { + return errors.New("seedance request requires a text prompt or at least one image/video/audio") } return nil } diff --git a/dto/video_seedance_test.go b/dto/video_seedance_test.go index c733809def5..1d85cbc45b7 100644 --- a/dto/video_seedance_test.go +++ b/dto/video_seedance_test.go @@ -1,8 +1,9 @@ package dto import ( - "encoding/json" "testing" + + "github.com/QuantumNous/new-api/common" ) func TestSeedanceVideoRequest_JSONTags(t *testing.T) { @@ -17,7 +18,7 @@ func TestSeedanceVideoRequest_JSONTags(t *testing.T) { "return_last_frame":true,"callback_url":"https://cb" }` var r SeedanceVideoRequest - if err := json.Unmarshal([]byte(raw), &r); err != nil { + if err := common.Unmarshal([]byte(raw), &r); err != nil { t.Fatalf("unmarshal: %v", err) } if r.Model != "kuaizi-lizhen-pro" || len(r.Content) != 2 { @@ -85,6 +86,11 @@ func TestSeedanceVideoRequest_Validate(t *testing.T) { req: SeedanceVideoRequest{Content: []SeedanceContentItem{{Type: SeedanceContentImage, ImageURL: &SeedanceURLObject{URL: "https://a/i.jpg"}}}}, wantErr: false, }, + { + name: "audio only ok", + req: SeedanceVideoRequest{Content: []SeedanceContentItem{{Type: SeedanceContentAudio, AudioURL: &SeedanceURLObject{URL: "https://a/a.mp3"}}}}, + wantErr: false, + }, { name: "empty fails", req: SeedanceVideoRequest{}, diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index 0cc02aacf01..750fbb51427 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -1,14 +1,22 @@ package modelapiseedance import ( + "bytes" "fmt" "io" "net/http" + "net/url" + "strings" + "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/gin-gonic/gin" ) @@ -21,36 +29,96 @@ type TaskAdaptor struct { } func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + if info == nil { + a.ChannelType = constant.ChannelTypeModelAPISeedance + a.baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + return + } a.ChannelType = info.ChannelType a.apiKey = info.ApiKey - a.baseURL = info.ChannelBaseUrl + a.baseURL = strings.TrimRight(strings.TrimSpace(info.ChannelBaseUrl), "/") + if a.baseURL == "" { + a.baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + } + info.UpstreamModelName = UpstreamModel + if info.ChannelMeta != nil { + info.ChannelMeta.UpstreamModelName = UpstreamModel + } } func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { - if _, err := taskcommon.BindSeedanceRequest(c, info, constant.TaskActionGenerate); err != nil { + seedReq, err := taskcommon.BindSeedanceRequest(c, info, constant.TaskActionGenerate) + if err != nil { return taskError(err, "invalid_request", http.StatusBadRequest) } + if err := validateModelAPISeedanceRequest(seedReq); err != nil { + return taskError(err, "invalid_request", http.StatusBadRequest) + } + info.UpstreamModelName = UpstreamModel + if info.ChannelMeta != nil { + info.ChannelMeta.UpstreamModelName = UpstreamModel + } return nil } func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { - return "", notImplemented("BuildRequestURL") + return a.baseURL + "/v1/tasks", nil } -func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, _ *http.Request, _ *relaycommon.RelayInfo) error { - return notImplemented("BuildRequestHeader") +func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + return nil } -func (a *TaskAdaptor) BuildRequestBody(_ *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { - return nil, notImplemented("BuildRequestBody") +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (io.Reader, error) { + var seedReq dto.SeedanceVideoRequest + if err := common.UnmarshalBodyReusable(c, &seedReq); err != nil { + return nil, err + } + if err := validateModelAPISeedanceRequest(&seedReq); err != nil { + return nil, err + } + body := buildModelAPICreateRequest(&seedReq) + data, err := common.MarshalNoHTMLEscape(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil } -func (a *TaskAdaptor) DoRequest(_ *gin.Context, _ *relaycommon.RelayInfo, _ io.Reader) (*http.Response, error) { - return nil, notImplemented("DoRequest") +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(_ *gin.Context, _ *http.Response, _ *relaycommon.RelayInfo) (string, []byte, *dto.TaskError) { - return "", nil, taskError(notImplemented("DoResponse"), "not_implemented", http.StatusNotImplemented) +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, taskError(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var submit modelAPISubmitResponse + if err := common.Unmarshal(responseBody, &submit); err != nil { + return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) + } + if strings.TrimSpace(submit.TaskID) == "" { + return "", nil, taskError(fmt.Errorf("upstream response missing task_id"), "invalid_response", http.StatusBadGateway) + } + if submit.Status == modelAPIStatusFailed { + return "", nil, taskError(fmt.Errorf("%s", taskcommon.ScrubBrandedText(submit.Error.Message)), "upstream_error", http.StatusBadGateway) + } + + ov := dto.NewOpenAIVideo() + if info != nil { + ov.ID = info.PublicTaskID + ov.TaskID = info.PublicTaskID + ov.Model = info.OriginModelName + } + ov.CreatedAt = time.Now().Unix() + c.JSON(http.StatusOK, ov) + return submit.TaskID, responseBody, nil } func (a *TaskAdaptor) GetModelList() []string { @@ -61,24 +129,286 @@ func (a *TaskAdaptor) GetChannelName() string { return ChannelName } -func (a *TaskAdaptor) FetchTask(_ string, _ string, _ map[string]any, _ string) (*http.Response, error) { - return nil, notImplemented("FetchTask") +func (a *TaskAdaptor) FetchTask(baseURL string, 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") + } + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] + } + req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/tasks/"+url.PathEscape(taskID), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + client, err := service.GetHttpClientWithProxy(proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + return client.Do(req) } -func (a *TaskAdaptor) ParseTaskResult(_ []byte) (*relaycommon.TaskInfo, error) { - return nil, notImplemented("ParseTaskResult") +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var result modelAPITaskResponse + if err := common.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("invalid task result response") + } + info := &relaycommon.TaskInfo{Code: 0, TaskID: result.TaskID} + switch result.Status { + case modelAPIStatusPending: + info.Status = model.TaskStatusQueued + info.Progress = taskcommon.ProgressQueued + case modelAPIStatusPolling, modelAPIStatusRunning: + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + case modelAPIStatusSucceeded: + videoURL := firstModelAPIVideoURL(result.Result.Assets) + if videoURL == "" { + return nil, fmt.Errorf("succeeded task is missing video asset") + } + info.Status = model.TaskStatusSuccess + info.Progress = taskcommon.ProgressComplete + info.Url = videoURL + case modelAPIStatusFailed: + info.Status = model.TaskStatusFailure + info.Progress = taskcommon.ProgressComplete + info.Reason = taskcommon.ScrubBrandedText(result.Error.Message) + default: + info.Status = model.TaskStatusInProgress + info.Progress = taskcommon.ProgressInProgress + } + return info, nil } -func notImplemented(method string) error { - return fmt.Errorf("modelapi seedance task adaptor %s is not implemented", method) +func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { + ov := dto.NewOpenAIVideo() + ov.ID = originTask.TaskID + ov.TaskID = originTask.TaskID + ov.Status = originTask.Status.ToVideoStatus() + ov.SetProgressStr(originTask.Progress) + ov.CreatedAt = originTask.CreatedAt + ov.CompletedAt = originTask.UpdatedAt + ov.Model = originTask.Properties.OriginModelName + if originTask.Status == model.TaskStatusSuccess { + ov.SetMetadata("url", originTask.GetResultURL()) + } + if originTask.Status == model.TaskStatusFailure { + ov.Error = &dto.OpenAIVideoError{ + Message: taskcommon.ScrubBrandedText(originTask.FailReason), + } + } + return common.Marshal(ov) } func taskError(err error, code string, statusCode int) *dto.TaskError { + message := "" + if err != nil { + message = err.Error() + } return &dto.TaskError{ Code: code, - Message: err.Error(), + Message: message, StatusCode: statusCode, LocalError: true, Error: err, } } + +type modelAPIInputItem struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + URL string `json:"url,omitempty"` +} + +type modelAPIParams struct { + Duration *int `json:"duration,omitempty"` + Resolution string `json:"resolution,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + Seed *int `json:"seed,omitempty"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + ReturnLastFrame *bool `json:"return_last_frame,omitempty"` +} + +type modelAPICreateRequest struct { + Model string `json:"model"` + Input []modelAPIInputItem `json:"input"` + Params *modelAPIParams `json:"params,omitempty"` +} + +type modelAPIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type modelAPIAsset struct { + Type string `json:"type"` + URL string `json:"url"` +} + +type modelAPIResult struct { + Assets []modelAPIAsset `json:"assets"` +} + +type modelAPISubmitResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Error modelAPIError `json:"error"` +} + +type modelAPITaskResponse struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Result modelAPIResult `json:"result"` + Error modelAPIError `json:"error"` +} + +const ( + modelAPIStatusPending = "pending" + modelAPIStatusPolling = "polling" + modelAPIStatusRunning = "running" + modelAPIStatusSucceeded = "succeeded" + modelAPIStatusFailed = "failed" +) + +func buildModelAPICreateRequest(seedReq *dto.SeedanceVideoRequest) modelAPICreateRequest { + body := modelAPICreateRequest{Model: UpstreamModel} + if prompt := strings.TrimSpace(seedReq.PromptText()); prompt != "" { + body.Input = append(body.Input, modelAPIInputItem{Role: "prompt", Content: prompt}) + } + for _, m := range seedReq.Images() { + body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIImageRole(m.Role), URL: m.URL}) + } + for _, m := range seedReq.Videos() { + body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + } + for _, m := range seedReq.Audios() { + body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + } + params := modelAPIParams{ + Duration: seedReq.Duration, + Resolution: seedReq.Resolution, + AspectRatio: seedReq.Ratio, + Seed: seedReq.Seed, + GenerateAudio: seedReq.GenerateAudio, + Watermark: seedReq.Watermark, + ReturnLastFrame: seedReq.ReturnLastFrame, + } + if params.hasAny() { + body.Params = ¶ms + } + return body +} + +const modelAPIReferenceRole = "reference" + +func modelAPIImageRole(role string) string { + switch role { + case dto.SeedanceRoleFirstFrame, dto.SeedanceRoleLastFrame: + return role + default: + return modelAPIReferenceRole + } +} + +func (p modelAPIParams) hasAny() bool { + return p.Duration != nil || + p.Resolution != "" || + p.AspectRatio != "" || + p.Seed != nil || + p.GenerateAudio != nil || + p.Watermark != nil || + p.ReturnLastFrame != nil +} + +var supportedModelAPIResolutions = map[string]struct{}{ + "480p": {}, + "720p": {}, +} + +var supportedModelAPIAspectRatios = map[string]struct{}{ + "16:9": {}, + "4:3": {}, + "1:1": {}, + "3:4": {}, + "9:16": {}, + "adaptive": {}, +} + +func validateModelAPISeedanceRequest(seedReq *dto.SeedanceVideoRequest) error { + if seedReq.Duration != nil && (*seedReq.Duration < 4 || *seedReq.Duration > 30) { + return fmt.Errorf("duration must be between 4 and 30") + } + if seedReq.Resolution != "" { + if _, ok := supportedModelAPIResolutions[seedReq.Resolution]; !ok { + return fmt.Errorf("unsupported resolution") + } + } + if seedReq.Ratio != "" { + if _, ok := supportedModelAPIAspectRatios[seedReq.Ratio]; !ok { + return fmt.Errorf("unsupported aspect_ratio") + } + } + + imageCount, videoCount, audioCount := 0, 0, 0 + firstFrameCount, lastFrameCount := 0, 0 + for _, m := range seedReq.Images() { + imageCount++ + switch m.Role { + case "", dto.SeedanceRoleReferenceImage: + case dto.SeedanceRoleFirstFrame: + firstFrameCount++ + case dto.SeedanceRoleLastFrame: + lastFrameCount++ + default: + return fmt.Errorf("unsupported image role") + } + } + for _, m := range seedReq.Videos() { + videoCount++ + if m.Role != "" && m.Role != dto.SeedanceRoleReferenceVideo { + return fmt.Errorf("unsupported video role") + } + } + for _, m := range seedReq.Audios() { + audioCount++ + if m.Role != "" && m.Role != dto.SeedanceRoleReferenceAudio { + return fmt.Errorf("unsupported audio role") + } + } + + if imageCount > 30 { + return fmt.Errorf("image references exceed limit") + } + if videoCount > 10 { + return fmt.Errorf("video references exceed limit") + } + if audioCount > 10 { + return fmt.Errorf("audio references exceed limit") + } + if imageCount+videoCount+audioCount > 50 { + return fmt.Errorf("media references exceed limit") + } + if firstFrameCount > 1 { + return fmt.Errorf("first_frame supports at most one image") + } + if lastFrameCount > 1 { + return fmt.Errorf("last_frame supports at most one image") + } + if lastFrameCount > 0 && firstFrameCount == 0 { + return fmt.Errorf("last_frame requires first_frame") + } + return nil +} + +func firstModelAPIVideoURL(assets []modelAPIAsset) string { + for _, asset := range assets { + if asset.Type == "video" && strings.TrimSpace(asset.URL) != "" { + return asset.URL + } + } + return "" +} diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index 64957d144f1..224837378ca 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -1,12 +1,27 @@ package modelapiseedance import ( + "io" + "net/http" + "net/http/httptest" + "strings" "testing" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/relay/channel" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + + "github.com/gin-gonic/gin" ) -var _ channel.TaskAdaptor = (*TaskAdaptor)(nil) +var ( + _ channel.TaskAdaptor = (*TaskAdaptor)(nil) + _ channel.OpenAIVideoConverter = (*TaskAdaptor)(nil) +) func TestModelAPISeedanceAdaptorIdentity(t *testing.T) { adaptor := &TaskAdaptor{} @@ -18,3 +33,406 @@ func TestModelAPISeedanceAdaptorIdentity(t *testing.T) { t.Fatalf("GetModelList() = %v, want [doubao-seedance-2-5-260628]", models) } } + +func modelAPIPtrInt(v int) *int { return &v } +func modelAPIPtrBool(v bool) *bool { return &v } + +func newModelAPITestContext(body string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, w +} + +func newModelAPIRelayInfo(baseURL, key string) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: "client-seedance", + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeModelAPISeedance, + ChannelBaseUrl: baseURL, + ApiKey: key, + UpstreamModelName: "client-configured-model", + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + } +} + +func TestBuildModelAPICreateRequestMapsTextMediaRolesAndVideoAssetSelection(t *testing.T) { + seedReq := &dto.SeedanceVideoRequest{ + Model: "client-model", + Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentText, Text: "make it cinematic"}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.png"}}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/first.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://cdn.example/last.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.mp4"}}, + {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://cdn.example/ref.mp3"}}, + }, + Ratio: "16:9", + Resolution: "720p", + Duration: modelAPIPtrInt(5), + Seed: modelAPIPtrInt(42), + GenerateAudio: modelAPIPtrBool(true), + Watermark: modelAPIPtrBool(false), + ReturnLastFrame: modelAPIPtrBool(false), + } + + body := buildModelAPICreateRequest(seedReq) + if body.Model != UpstreamModel { + t.Fatalf("model = %q, want fixed upstream model %q", body.Model, UpstreamModel) + } + if len(body.Input) != 6 { + t.Fatalf("input length = %d, want 6: %+v", len(body.Input), body.Input) + } + if body.Input[0].Role != "prompt" || body.Input[0].Content != "make it cinematic" { + t.Fatalf("text input = %+v", body.Input[0]) + } + wantRoles := []string{"reference", "first_frame", "last_frame", "reference", "reference"} + for i, want := range wantRoles { + if got := body.Input[i+1].Role; got != want { + t.Fatalf("input[%d].role = %q, want %q", i+1, got, want) + } + } + if body.Params == nil || body.Params.AspectRatio != "16:9" || body.Params.Resolution != "720p" { + t.Fatalf("params not mapped: %+v", body.Params) + } + + info, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{ + "task_id":"upstream", + "status":"succeeded", + "result":{"assets":[ + {"type":"thumbnail","url":"https://cdn.example/thumb.jpg"}, + {"type":"video","url":"https://cdn.example/final.mp4"} + ]} + }`)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Url != "https://cdn.example/final.mp4" { + t.Fatalf("selected url = %q, want non-first video asset", info.Url) + } +} + +func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[{"type":"text","text":"x"},{"type":"image_url","image_url":{"url":"https://x/i.png?a=1&b=2"}}], + "seed":0, + "generate_audio":false, + "watermark":false, + "return_last_frame":false + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + endToEndRaw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + if !strings.Contains(string(endToEndRaw), "a=1&b=2") { + t.Fatalf("BuildRequestBody escaped URL query: %s", endToEndRaw) + } + + seedReq := &dto.SeedanceVideoRequest{ + Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentText, Text: "x"}}, + Seed: modelAPIPtrInt(0), + GenerateAudio: modelAPIPtrBool(false), + Watermark: modelAPIPtrBool(false), + ReturnLastFrame: modelAPIPtrBool(false), + } + body := buildModelAPICreateRequest(seedReq) + raw, err := common.MarshalNoHTMLEscape(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + text := string(raw) + for _, want := range []string{`"seed":0`, `"generate_audio":false`, `"watermark":false`, `"return_last_frame":false`} { + if !strings.Contains(text, want) { + t.Fatalf("body missing %s: %s", want, text) + } + } + for _, omitted := range []string{"duration", "resolution", "aspect_ratio"} { + if strings.Contains(text, omitted) { + t.Fatalf("body should omit %s when absent: %s", omitted, text) + } + } +} + +func TestValidateModelAPISeedanceValues(t *testing.T) { + valid := dto.SeedanceVideoRequest{ + Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentText, Text: "x"}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/ref.png"}}, + {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/ref.mp4"}}, + {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/ref.mp3"}}, + }, + Duration: modelAPIPtrInt(4), + Resolution: "480p", + Ratio: "adaptive", + } + if err := validateModelAPISeedanceRequest(&valid); err != nil { + t.Fatalf("valid request rejected: %v", err) + } + + tests := []struct { + name string + req dto.SeedanceVideoRequest + }{ + {name: "duration low", req: dto.SeedanceVideoRequest{Duration: modelAPIPtrInt(3)}}, + {name: "duration high", req: dto.SeedanceVideoRequest{Duration: modelAPIPtrInt(31)}}, + {name: "resolution", req: dto.SeedanceVideoRequest{Resolution: "1080p"}}, + {name: "aspect", req: dto.SeedanceVideoRequest{Ratio: "3:2"}}, + {name: "image role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}, Role: "cover"}}}}, + {name: "video role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}, Role: "first_frame"}}}}, + {name: "audio role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}, Role: "narration"}}}}, + {name: "last without first", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last.png"}, Role: dto.SeedanceRoleLastFrame}}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateModelAPISeedanceRequest(&tt.req); err == nil { + t.Fatal("expected validation error") + } + }) + } + + countReq := dto.SeedanceVideoRequest{} + for i := 0; i < 31; i++ { + countReq.Content = append(countReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}}) + } + if err := validateModelAPISeedanceRequest(&countReq); err == nil { + t.Fatal("expected image count error") + } + + videoCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 11; i++ { + videoCountReq.Content = append(videoCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}}) + } + if err := validateModelAPISeedanceRequest(&videoCountReq); err == nil { + t.Fatal("expected video count error") + } + + audioCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 11; i++ { + audioCountReq.Content = append(audioCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}}) + } + if err := validateModelAPISeedanceRequest(&audioCountReq); err == nil { + t.Fatal("expected audio count error") + } + + totalCountReq := dto.SeedanceVideoRequest{} + for i := 0; i < 30; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}}) + } + for i := 0; i < 10; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}}) + } + for i := 0; i < 11; i++ { + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}}) + } + if err := validateModelAPISeedanceRequest(&totalCountReq); err == nil { + t.Fatal("expected total media count error") + } + + firstFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first-a.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first-b.png"}, Role: dto.SeedanceRoleFirstFrame}, + }} + if err := validateModelAPISeedanceRequest(&firstFrameReq); err == nil { + t.Fatal("expected first_frame max-one error") + } + + lastFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last-a.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last-b.png"}, Role: dto.SeedanceRoleLastFrame}, + }} + if err := validateModelAPISeedanceRequest(&lastFrameReq); err == nil { + t.Fatal("expected last_frame max-one error") + } +} + +func TestValidateRequestAndSetActionAcceptsAudioOnlyAndSetsFixedUpstreamModel(t *testing.T) { + c, _ := newModelAPITestContext(`{"model":"client-model","content":[{"type":"audio_url","audio_url":{"url":"https://x/a.mp3"}}]}`) + info := newModelAPIRelayInfo("", "") + a := &TaskAdaptor{} + if taskErr := a.ValidateRequestAndSetAction(c, info); taskErr != nil { + t.Fatalf("audio-only request rejected: %+v", taskErr) + } + if info.UpstreamModelName != UpstreamModel { + t.Fatalf("UpstreamModelName = %q, want %q", info.UpstreamModelName, UpstreamModel) + } + if info.Action != constant.TaskActionGenerate { + t.Fatalf("Action = %q, want generate", info.Action) + } +} + +func TestBuildAndFetchPathsHeadersAndEscaping(t *testing.T) { + service.InitHttpClient() + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("https://api.modelapi.co///", "secret") + a.Init(info) + if a.baseURL != "https://api.modelapi.co" { + t.Fatalf("baseURL = %q, want trimmed", a.baseURL) + } + if got, err := a.BuildRequestURL(info); err != nil || got != "https://api.modelapi.co/v1/tasks" { + t.Fatalf("BuildRequestURL = %q, %v", got, err) + } + req := httptest.NewRequest(http.MethodPost, "/upstream", nil) + if err := a.BuildRequestHeader(nil, req, info); err != nil { + t.Fatalf("BuildRequestHeader error: %v", err) + } + if req.Header.Get("Authorization") != "Bearer secret" { + t.Fatalf("Authorization = %q", req.Header.Get("Authorization")) + } + + var gotPath, gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + resp, err := a.FetchTask(server.URL, "fetch-key", map[string]any{"task_id": "task/a b"}, "") + if err != nil { + t.Fatalf("FetchTask error: %v", err) + } + _ = resp.Body.Close() + if gotPath != "/v1/tasks/task%2Fa%20b" { + t.Fatalf("fetch path = %q", gotPath) + } + if gotAuth != "Bearer fetch-key" { + t.Fatalf("fetch auth = %q", gotAuth) + } +} + +func TestInitFallsBackToDefaultBaseURL(t *testing.T) { + a := &TaskAdaptor{} + a.Init(newModelAPIRelayInfo("", "key")) + if a.baseURL != constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] { + t.Fatalf("baseURL = %q", a.baseURL) + } +} + +func TestDoResponseParsesExactTaskIDAndRejectsIDOnly(t *testing.T) { + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("", "") + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"task_id":"upstream-task","status":"pending"}`))} + taskID, taskData, taskErr := a.DoResponse(c, resp, info) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream-task" { + t.Fatalf("taskID = %q", taskID) + } + if !strings.Contains(string(taskData), `"task_id":"upstream-task"`) { + t.Fatalf("taskData = %s", taskData) + } + if strings.Contains(w.Body.String(), "upstream-task") || !strings.Contains(w.Body.String(), `"id":"task_public"`) { + t.Fatalf("client response leaked upstream or missed public id: %s", w.Body.String()) + } + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"id":"wrong-field","status":"pending"}`))} + taskID, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected id-only response to be rejected") + } + if taskID != "" { + t.Fatalf("taskID = %q, want empty on error", taskID) + } + if strings.Contains(taskErr.Message, "ModelAPI") || strings.Contains(taskErr.Message, "api.modelapi.co") { + t.Fatalf("task error leaked provider: %+v", taskErr) + } + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"task_id":"upstream-task","status":"failed","error":{"message":"ModelAPI api.modelapi.co failed"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected failed create response to be rejected") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("failed create message = %q", taskErr.Message) + } +} + +func TestParseTaskResultStatusMappingsAndFailureScrub(t *testing.T) { + a := &TaskAdaptor{} + tests := []struct { + name string + body string + wantStatus string + wantURL string + wantReason string + }{ + {name: "pending queued", body: `{"task_id":"up","status":"pending"}`, wantStatus: model.TaskStatusQueued}, + {name: "polling progress", body: `{"task_id":"up","status":"polling"}`, wantStatus: model.TaskStatusInProgress}, + {name: "running progress", body: `{"task_id":"up","status":"running"}`, wantStatus: model.TaskStatusInProgress}, + {name: "unknown progress", body: `{"task_id":"up","status":"mystery"}`, wantStatus: model.TaskStatusInProgress}, + {name: "succeeded video", body: `{"task_id":"up","status":"succeeded","result":{"assets":[{"type":"image","url":"https://x/i.png"},{"type":"video","url":"https://x/v.mp4"}]}}`, wantStatus: model.TaskStatusSuccess, wantURL: "https://x/v.mp4"}, + {name: "failed scrubbed", body: `{"task_id":"up","status":"failed","error":{"code":"bad","message":"ModelAPI seedance host api.modelapi.co failed"}}`, wantStatus: model.TaskStatusFailure, wantReason: "task failed at upstream provider"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := a.ParseTaskResult([]byte(tt.body)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Status != tt.wantStatus || info.Url != tt.wantURL || info.Reason != tt.wantReason { + t.Fatalf("TaskInfo = %+v", info) + } + }) + } + if _, err := a.ParseTaskResult([]byte(`{"task_id":"up","status":"succeeded","result":{"assets":[{"type":"image","url":"https://x/i.png"}]}}`)); err == nil { + t.Fatal("expected missing video asset to be retryable error") + } +} + +func TestConvertToOpenAIVideoUsesPublicResultURLAndScrubsFailure(t *testing.T) { + a := &TaskAdaptor{} + success := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusSuccess, + Progress: "100%", + CreatedAt: 10, + UpdatedAt: 20, + Properties: model.Properties{OriginModelName: "client-model"}, + PrivateData: model.TaskPrivateData{ + ResultURL: "https://flatkey.example/v1/videos/task_public/content", + }, + Data: []byte(`{"result":{"assets":[{"type":"video","url":"https://cdn.modelapi.co/private.mp4"}]}}`), + } + raw, err := a.ConvertToOpenAIVideo(success) + if err != nil { + t.Fatalf("ConvertToOpenAIVideo success error: %v", err) + } + var got dto.OpenAIVideo + if err := common.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal video: %v", err) + } + if got.Metadata["url"] != "https://flatkey.example/v1/videos/task_public/content" { + t.Fatalf("metadata.url = %v", got.Metadata["url"]) + } + if strings.Contains(string(raw), "cdn.modelapi.co") { + t.Fatalf("success leaked upstream asset URL: %s", raw) + } + + failure := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusFailure, + FailReason: "ModelAPI seedance failed", + } + raw, err = a.ConvertToOpenAIVideo(failure) + if err != nil { + t.Fatalf("ConvertToOpenAIVideo failure error: %v", err) + } + if err := common.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal failure video: %v", err) + } + if got.Error == nil || got.Error.Message != "task failed at upstream provider" { + t.Fatalf("failure error = %+v", got.Error) + } +} diff --git a/relay/channel/task/modelapiseedance/constants.go b/relay/channel/task/modelapiseedance/constants.go index 58f6faa3a3d..0ab73d289dc 100644 --- a/relay/channel/task/modelapiseedance/constants.go +++ b/relay/channel/task/modelapiseedance/constants.go @@ -1,7 +1,8 @@ package modelapiseedance const ChannelName = "modelapi-seedance" +const UpstreamModel = "doubao-seedance-2-5-260628" var ModelList = []string{ - "doubao-seedance-2-5-260628", + UpstreamModel, } From b4af9425daa065938b8330bc1282173bbd323ed9 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:17:33 +0800 Subject: [PATCH 08/32] Protect ModelAPI Seedance wire contract and white-label failures Constraint: ModelAPI create JSON requires grouped text/image/video/audio input arrays and customer-facing failures must not expose upstream URLs, IDs, hosts, or brands. Rejected: Reusing upstream error.message or error.code after brand-only scrubbing | arbitrary CDN URLs and upstream task IDs can still leak. Confidence: high Scope-risk: narrow Directive: Keep ModelAPI Seedance failure messages fixed unless a comprehensive safe redactor exists for arbitrary upstream identifiers. Tested: go test ./relay/channel/task/modelapiseedance -count=1; go test ./dto -count=1; gofmt; git diff --check Not-tested: full repository test suite --- .../channel/task/modelapiseedance/adaptor.go | 47 +++-- .../task/modelapiseedance/adaptor_test.go | 175 +++++++++++++++++- 2 files changed, 202 insertions(+), 20 deletions(-) diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index 750fbb51427..3f2ded3cb17 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -103,12 +103,12 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela if err := common.Unmarshal(responseBody, &submit); err != nil { return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) } + if submit.Status == modelAPIStatusFailed { + return "", nil, taskError(fmt.Errorf("%s", modelAPIFailureReason()), "upstream_error", http.StatusBadGateway) + } if strings.TrimSpace(submit.TaskID) == "" { return "", nil, taskError(fmt.Errorf("upstream response missing task_id"), "invalid_response", http.StatusBadGateway) } - if submit.Status == modelAPIStatusFailed { - return "", nil, taskError(fmt.Errorf("%s", taskcommon.ScrubBrandedText(submit.Error.Message)), "upstream_error", http.StatusBadGateway) - } ov := dto.NewOpenAIVideo() if info != nil { @@ -175,7 +175,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e case modelAPIStatusFailed: info.Status = model.TaskStatusFailure info.Progress = taskcommon.ProgressComplete - info.Reason = taskcommon.ScrubBrandedText(result.Error.Message) + info.Reason = modelAPIFailureReason() default: info.Status = model.TaskStatusInProgress info.Progress = taskcommon.ProgressInProgress @@ -197,7 +197,7 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro } if originTask.Status == model.TaskStatusFailure { ov.Error = &dto.OpenAIVideoError{ - Message: taskcommon.ScrubBrandedText(originTask.FailReason), + Message: modelAPIFailureReason(), } } return common.Marshal(ov) @@ -223,6 +223,13 @@ type modelAPIInputItem struct { URL string `json:"url,omitempty"` } +type modelAPIInput struct { + Text []modelAPIInputItem `json:"text"` + Image []modelAPIInputItem `json:"image"` + Video []modelAPIInputItem `json:"video"` + Audio []modelAPIInputItem `json:"audio"` +} + type modelAPIParams struct { Duration *int `json:"duration,omitempty"` Resolution string `json:"resolution,omitempty"` @@ -234,9 +241,9 @@ type modelAPIParams struct { } type modelAPICreateRequest struct { - Model string `json:"model"` - Input []modelAPIInputItem `json:"input"` - Params *modelAPIParams `json:"params,omitempty"` + Model string `json:"model"` + Input modelAPIInput `json:"input"` + Params *modelAPIParams `json:"params,omitempty"` } type modelAPIError struct { @@ -272,21 +279,31 @@ const ( modelAPIStatusRunning = "running" modelAPIStatusSucceeded = "succeeded" modelAPIStatusFailed = "failed" + + modelAPIGenericFailureReason = "task failed at upstream provider" ) func buildModelAPICreateRequest(seedReq *dto.SeedanceVideoRequest) modelAPICreateRequest { - body := modelAPICreateRequest{Model: UpstreamModel} + body := modelAPICreateRequest{ + Model: UpstreamModel, + Input: modelAPIInput{ + Text: []modelAPIInputItem{}, + Image: []modelAPIInputItem{}, + Video: []modelAPIInputItem{}, + Audio: []modelAPIInputItem{}, + }, + } if prompt := strings.TrimSpace(seedReq.PromptText()); prompt != "" { - body.Input = append(body.Input, modelAPIInputItem{Role: "prompt", Content: prompt}) + body.Input.Text = append(body.Input.Text, modelAPIInputItem{Role: "prompt", Content: prompt}) } for _, m := range seedReq.Images() { - body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIImageRole(m.Role), URL: m.URL}) + body.Input.Image = append(body.Input.Image, modelAPIInputItem{Role: modelAPIImageRole(m.Role), URL: m.URL}) } for _, m := range seedReq.Videos() { - body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + body.Input.Video = append(body.Input.Video, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) } for _, m := range seedReq.Audios() { - body.Input = append(body.Input, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) + body.Input.Audio = append(body.Input.Audio, modelAPIInputItem{Role: modelAPIReferenceRole, URL: m.URL}) } params := modelAPIParams{ Duration: seedReq.Duration, @@ -303,6 +320,10 @@ func buildModelAPICreateRequest(seedReq *dto.SeedanceVideoRequest) modelAPICreat return body } +func modelAPIFailureReason() string { + return modelAPIGenericFailureReason +} + const modelAPIReferenceRole = "reference" func modelAPIImageRole(role string) string { diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index 224837378ca..ea70d2d7a6d 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -83,16 +83,23 @@ func TestBuildModelAPICreateRequestMapsTextMediaRolesAndVideoAssetSelection(t *t if body.Model != UpstreamModel { t.Fatalf("model = %q, want fixed upstream model %q", body.Model, UpstreamModel) } - if len(body.Input) != 6 { - t.Fatalf("input length = %d, want 6: %+v", len(body.Input), body.Input) + if len(body.Input.Text) != 1 || len(body.Input.Image) != 3 || len(body.Input.Video) != 1 || len(body.Input.Audio) != 1 { + t.Fatalf("input groups not mapped: %+v", body.Input) } - if body.Input[0].Role != "prompt" || body.Input[0].Content != "make it cinematic" { - t.Fatalf("text input = %+v", body.Input[0]) + if body.Input.Text[0].Role != "prompt" || body.Input.Text[0].Content != "make it cinematic" { + t.Fatalf("text input = %+v", body.Input.Text[0]) } wantRoles := []string{"reference", "first_frame", "last_frame", "reference", "reference"} + gotRoles := []string{ + body.Input.Image[0].Role, + body.Input.Image[1].Role, + body.Input.Image[2].Role, + body.Input.Video[0].Role, + body.Input.Audio[0].Role, + } for i, want := range wantRoles { - if got := body.Input[i+1].Role; got != want { - t.Fatalf("input[%d].role = %q, want %q", i+1, got, want) + if got := gotRoles[i]; got != want { + t.Fatalf("input role[%d] = %q, want %q", i, got, want) } } if body.Params == nil || body.Params.AspectRatio != "16:9" || body.Params.Resolution != "720p" { @@ -115,6 +122,54 @@ func TestBuildModelAPICreateRequestMapsTextMediaRolesAndVideoAssetSelection(t *t } } +func TestBuildRequestBodyUsesModelAPIGroupedInputWireShape(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[ + {"type":"text","text":"make it cinematic"}, + {"type":"image_url","image_url":{"url":"https://cdn.example/ref.png"},"role":"reference_image"}, + {"type":"image_url","image_url":{"url":"https://cdn.example/first.png"},"role":"first_frame"}, + {"type":"image_url","image_url":{"url":"https://cdn.example/last.png"},"role":"last_frame"}, + {"type":"video_url","video_url":{"url":"https://cdn.example/ref.mp4"}}, + {"type":"audio_url","audio_url":{"url":"https://cdn.example/ref.mp3"}} + ] + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + + var wire map[string]any + if err := common.Unmarshal(raw, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + input, ok := wire["input"].(map[string]any) + if !ok { + t.Fatalf("input wire type = %T, want object: %s", wire["input"], raw) + } + if _, flattened := wire["input"].([]any); flattened { + t.Fatalf("input must not be a flat array: %s", raw) + } + assertModelAPIWireItems(t, input, "text", []map[string]string{ + {"role": "prompt", "content": "make it cinematic"}, + }) + assertModelAPIWireItems(t, input, "image", []map[string]string{ + {"role": "reference", "url": "https://cdn.example/ref.png"}, + {"role": "first_frame", "url": "https://cdn.example/first.png"}, + {"role": "last_frame", "url": "https://cdn.example/last.png"}, + }) + assertModelAPIWireItems(t, input, "video", []map[string]string{ + {"role": "reference", "url": "https://cdn.example/ref.mp4"}, + }) + assertModelAPIWireItems(t, input, "audio", []map[string]string{ + {"role": "reference", "url": "https://cdn.example/ref.mp3"}, + }) +} + func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testing.T) { c, _ := newModelAPITestContext(`{ "model":"client-model", @@ -161,6 +216,28 @@ func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testi } } +func assertModelAPIWireItems(t *testing.T, input map[string]any, key string, want []map[string]string) { + t.Helper() + items, ok := input[key].([]any) + if !ok { + t.Fatalf("input.%s wire type = %T, want array", key, input[key]) + } + if len(items) != len(want) { + t.Fatalf("input.%s length = %d, want %d: %+v", key, len(items), len(want), items) + } + for i, item := range items { + got, ok := item.(map[string]any) + if !ok { + t.Fatalf("input.%s[%d] wire type = %T, want object", key, i, item) + } + for field, wantValue := range want[i] { + if gotValue, _ := got[field].(string); gotValue != wantValue { + t.Fatalf("input.%s[%d].%s = %q, want %q", key, i, field, gotValue, wantValue) + } + } + } +} + func TestValidateModelAPISeedanceValues(t *testing.T) { valid := dto.SeedanceVideoRequest{ Content: []dto.SeedanceContentItem{ @@ -359,6 +436,49 @@ func TestDoResponseParsesExactTaskIDAndRejectsIDOnly(t *testing.T) { } } +func TestDoResponseReturnsFailedStatusBeforeMissingTaskIDAndUsesErrorCodeFallback(t *testing.T) { + a := &TaskAdaptor{} + info := newModelAPIRelayInfo("", "") + c, _ := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"message":"ModelAPI api.modelapi.co rejected https://api.modelapi.co/v1/tasks/real"}}`))} + _, _, taskErr := a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected failed create response to be rejected") + } + if taskErr.Code != "upstream_error" { + t.Fatalf("taskErr.Code = %q, want upstream_error", taskErr.Code) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("failed-without-task_id message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"code":"rate_limit_exceeded"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected code-only failed create response to be rejected") + } + if taskErr.Message == "" { + t.Fatal("code-only failed create response returned empty message") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("code-only failed create message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) + + c, _ = newModelAPITestContext(`{}`) + resp = &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"status":"failed","error":{"message":"download failed for https://cdn.example/private.mp4 upstream-task-123"}}`))} + _, _, taskErr = a.DoResponse(c, resp, info) + if taskErr == nil { + t.Fatal("expected unbranded failed create response to be rejected") + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("unbranded failed create message = %q", taskErr.Message) + } + assertNoModelAPILeak(t, taskErr.Message) +} + func TestParseTaskResultStatusMappingsAndFailureScrub(t *testing.T) { a := &TaskAdaptor{} tests := []struct { @@ -391,6 +511,38 @@ func TestParseTaskResultStatusMappingsAndFailureScrub(t *testing.T) { } } +func TestParseTaskResultUsesErrorCodeFallbackForFailedTasks(t *testing.T) { + info, err := (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"code":"quota_exceeded"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult error: %v", err) + } + if info.Status != model.TaskStatusFailure { + t.Fatalf("Status = %q, want failure", info.Status) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("code-only failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) + + info, err = (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"code":"ModelAPI api.modelapi.co https://api.modelapi.co/v1/tasks/real"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult branded code error: %v", err) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("branded code-only failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) + + info, err = (&TaskAdaptor{}).ParseTaskResult([]byte(`{"task_id":"up","status":"failed","error":{"message":"download failed for https://cdn.example/private.mp4 upstream-task-123"}}`)) + if err != nil { + t.Fatalf("ParseTaskResult unbranded URL error: %v", err) + } + if info.Reason != "task failed at upstream provider" { + t.Fatalf("unbranded URL failure reason = %q", info.Reason) + } + assertNoModelAPILeak(t, info.Reason) +} + func TestConvertToOpenAIVideoUsesPublicResultURLAndScrubsFailure(t *testing.T) { a := &TaskAdaptor{} success := &model.Task{ @@ -423,7 +575,7 @@ func TestConvertToOpenAIVideoUsesPublicResultURLAndScrubsFailure(t *testing.T) { failure := &model.Task{ TaskID: "task_public", Status: model.TaskStatusFailure, - FailReason: "ModelAPI seedance failed", + FailReason: "download failed for https://cdn.example/private.mp4 upstream-task-123", } raw, err = a.ConvertToOpenAIVideo(failure) if err != nil { @@ -436,3 +588,12 @@ func TestConvertToOpenAIVideoUsesPublicResultURLAndScrubsFailure(t *testing.T) { t.Fatalf("failure error = %+v", got.Error) } } + +func assertNoModelAPILeak(t *testing.T, s string) { + t.Helper() + for _, leaked := range []string{"ModelAPI", "modelapi", "api.modelapi.co", "https://api.modelapi.co/v1/tasks/real", "https://cdn.example/private.mp4", "upstream-task-123"} { + if strings.Contains(s, leaked) { + t.Fatalf("message leaked %q: %q", leaked, s) + } + } +} From bd51d6a9c33f4cd87f8dd4348cf92e73029c6c8f Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:25:19 +0800 Subject: [PATCH 09/32] Keep ModelAPISeedance classic channel presentation provider-neutral Constraint: Scope limited to web/classic channel 111 key prompt and icon mapping; no web/default or backend changes. Rejected: Importing JSX helpers directly in tests | would require heavier UI/runtime setup for a two-branch regression. Confidence: high Scope-risk: narrow Directive: Keep channel 111 provider-facing copy generic and reuse Doubao iconography in classic console. Tested: bun test src/components/table/channels/modals/modelapi-seedance-classic.test.js; bun run build Not-tested: Browser-rendered classic channel drawer interaction. --- .../channels/modals/EditChannelModal.jsx | 2 ++ .../modals/modelapi-seedance-classic.test.js | 28 +++++++++++++++++++ web/classic/src/helpers/render.jsx | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index 82d14eb1654..43dcbd67d6c 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -155,6 +155,8 @@ function type2secretPrompt(type) { return '按照如下格式输入: AccessKey|SecretAccessKey'; case 57: return '请输入 JSON 格式的 OAuth 凭据(必须包含 access_token 和 account_id)'; + case 111: + return 'API key from the provider'; default: return '请输入渠道对应的鉴权密钥'; } diff --git a/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js new file mode 100644 index 00000000000..e930ad843f2 --- /dev/null +++ b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const editChannelModalSource = readFileSync( + join(currentDir, 'EditChannelModal.jsx'), + 'utf8', +); +const renderHelperSource = readFileSync( + join(currentDir, '../../../../helpers/render.jsx'), + 'utf8', +); + +describe('ModelAPISeedance classic channel metadata', () => { + test('uses the generic provider API key prompt for channel 111', () => { + expect(editChannelModalSource).toMatch( + /case 111:\s*return 'API key from the provider';/, + ); + }); + + test('renders the Doubao icon for channel 111', () => { + expect(renderHelperSource).toMatch( + /case 111:[\s\S]*?return ;/, + ); + }); +}); diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx index b7e065c867f..43f8844da86 100644 --- a/web/classic/src/helpers/render.jsx +++ b/web/classic/src/helpers/render.jsx @@ -405,6 +405,8 @@ export function getChannelIcon(channelType) { return ; case 58: // 筷子科技 丽帧(封装 Seedance) return ; + case 111: // ModelAPISeedance + return ; case 56: // Replicate return ; case 8: // 自定义渠道 From 905775918a33092fb2d2257485c8c8ba4443e895 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:36:07 +0800 Subject: [PATCH 10/32] Align ModelAPI Seedance input groups with official optional shape Constraint: Official ModelAPI create requests require grouped input object branches, and text-only generation should send only input.text. Rejected: Always serializing empty image/video/audio arrays | optional oneOf branches should be omitted when not used. Confidence: high Scope-risk: narrow Directive: Keep ModelAPI input media groups omitted when empty; do not make optional branches required without upstream contract evidence. Tested: go test ./relay/channel/task/modelapiseedance -count=1; gofmt; git diff --check Not-tested: full repository test suite; dto package unchanged in this narrow follow-up --- .../channel/task/modelapiseedance/adaptor.go | 8 ++--- .../task/modelapiseedance/adaptor_test.go | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index 3f2ded3cb17..b4e1bd38dfb 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -224,10 +224,10 @@ type modelAPIInputItem struct { } type modelAPIInput struct { - Text []modelAPIInputItem `json:"text"` - Image []modelAPIInputItem `json:"image"` - Video []modelAPIInputItem `json:"video"` - Audio []modelAPIInputItem `json:"audio"` + Text []modelAPIInputItem `json:"text,omitempty"` + Image []modelAPIInputItem `json:"image,omitempty"` + Video []modelAPIInputItem `json:"video,omitempty"` + Audio []modelAPIInputItem `json:"audio,omitempty"` } type modelAPIParams struct { diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index ea70d2d7a6d..2132ca30a81 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -170,6 +170,41 @@ func TestBuildRequestBodyUsesModelAPIGroupedInputWireShape(t *testing.T) { }) } +func TestBuildRequestBodyOmitsEmptyModelAPIInputGroups(t *testing.T) { + c, _ := newModelAPITestContext(`{ + "model":"client-model", + "content":[{"type":"text","text":"make it cinematic"}] + }`) + reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) + if err != nil { + t.Fatalf("BuildRequestBody error: %v", err) + } + raw, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read BuildRequestBody: %v", err) + } + + var wire map[string]any + if err := common.Unmarshal(raw, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + input, ok := wire["input"].(map[string]any) + if !ok { + t.Fatalf("input wire type = %T, want object: %s", wire["input"], raw) + } + if len(input) != 1 { + t.Fatalf("input keys = %v, want only text: %s", input, raw) + } + assertModelAPIWireItems(t, input, "text", []map[string]string{ + {"role": "prompt", "content": "make it cinematic"}, + }) + for _, emptyGroup := range []string{"image", "video", "audio"} { + if _, ok := input[emptyGroup]; ok { + t.Fatalf("input.%s should be omitted for text-only request: %s", emptyGroup, raw) + } + } +} + func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testing.T) { c, _ := newModelAPITestContext(`{ "model":"client-model", From df909fd270384d7ad713ade4ce0e842318cd8a7b Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:39:19 +0800 Subject: [PATCH 11/32] Prevent branded video polling leaks Constrain archived video polling errors and logs to public task IDs plus generic phase/status context while preserving ModelAPI metrics labels and archive ordering. Constraint: White-label polling output must not expose internal ModelAPI branding, upstream IDs, private URLs, or failure reasons. Rejected: Logging sanitized failure reasons | Archived-channel logs may only expose public task IDs and status/phase style fields. Confidence: high Scope-risk: narrow Directive: Keep channel=modelapi metrics labels stable even when user-visible/log output is scrubbed. Tested: go test ./service -run Test(UpdateVideoSingleTask|RedactTechMobiVideoResponseBody); go test ./service -run TestUpdateVideoSingleTask(ModelAPIFailureRedactsDBAndLogs|ArchiveFailurePayloadRedactsDBAndLogs|ModelAPIArchiveFailureNoUpstreamLeaks|ModelAPIArchiveErrorDoesNotFinalizeOrSettle|ModelAPIArchivesAndSetsProxyURL|ModelAPIEmptySuccessURLDoesNotFinalizeOrSettle|ModelAPIRedactsStoredDataAndLogs|ModelAPIUnknownErrorFormatDoesNotLogRawResponse)$; git diff --check -- service/task_polling.go service/task_polling_video_result_test.go Not-tested: Full repository test suite. --- service/task_polling.go | 54 +++++++++++++------ service/task_polling_video_result_test.go | 64 +++++++++++++++++++++-- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/service/task_polling.go b/service/task_polling.go index 19d9b8974c7..cde1e0fee61 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -361,7 +361,7 @@ func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, chann adaptor.Init(info) for _, taskId := range taskIds { 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())) + logger.LogError(ctx, fmt.Sprintf("Failed to update video task: %s", err.Error())) } // sleep 1 second between each task to avoid hitting rate limits of upstream platforms time.Sleep(1 * time.Second) @@ -380,8 +380,8 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task := taskM[taskId] if task == nil { - logger.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) - return fmt.Errorf("task %s not found", taskId) + logger.LogError(ctx, "Task not found in taskM") + return errors.New("task not found") } key := ch.Key @@ -389,21 +389,22 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * if privateData.Key != "" { key = privateData.Key } + upstreamTaskID := task.GetUpstreamTaskID() resp, err := FetchTaskWithContext(ctx, adaptor, baseURL, key, map[string]any{ - "task_id": task.GetUpstreamTaskID(), + "task_id": upstreamTaskID, "action": task.Action, }, proxy) if err != nil { - return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) + return fmt.Errorf("fetchTask failed for task %s: %w", task.TaskID, err) } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("readAll failed for task %s: %w", taskId, err) + return fmt.Errorf("readAll failed for task %s: %w", task.TaskID, err) } if VideoResultChannelLabel(ch.Type) != "" { - logger.LogDebug(ctx, "updateVideoSingleTask response received: task_id=%s upstream_task_id=%s phase=fetched bytes=%d", task.TaskID, task.GetUpstreamTaskID(), len(responseBody)) + logger.LogDebug(ctx, "updateVideoSingleTask response received: task_id=%s phase=fetched bytes=%d", task.TaskID, len(responseBody)) } else { logger.LogDebug(ctx, "updateVideoSingleTask response: %s", responseBody) } @@ -415,7 +416,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * var responseItems dto.TaskResponse[model.Task] if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { if VideoResultChannelLabel(ch.Type) != "" { - logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s upstream_task_id=%s phase=parsed status=%s", task.TaskID, task.GetUpstreamTaskID(), responseItems.Data.Status) + logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s phase=parsed status=%s", task.TaskID, responseItems.Data.Status) } else { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems) } @@ -427,13 +428,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult.Reason = t.FailReason task.Data = t.Data } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { - return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + return fmt.Errorf("parseTaskResult failed for task %s: %w", task.TaskID, err) } task.Data = redactVideoResponseForChannel(ch.Type, responseBody) if VideoResultChannelLabel(ch.Type) != "" { - logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s upstream_task_id=%s phase=parsed status=%s progress=%s", task.TaskID, task.GetUpstreamTaskID(), taskResult.Status, taskResult.Progress) + logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s phase=parsed status=%s progress=%s", task.TaskID, taskResult.Status, taskResult.Progress) } else { logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) } @@ -456,9 +457,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } else { // unknown error format, log original response if VideoResultChannelLabel(ch.Type) != "" { - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format", taskId)) + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format", task.TaskID)) } else { - logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", taskId, string(responseBody))) + logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, response: %s", task.TaskID, string(responseBody))) } taskResult = relaycommon.FailTaskInfo("upstream returned unrecognized message") } @@ -468,10 +469,27 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * archiveChannelLabel := VideoResultChannelLabel(ch.Type) if (returnSourceURL || (archiveChannelLabel != "" && !returnSourceURL)) && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { + phase := task.Progress + if phase == "" { + phase = "unknown" + } + status := taskResult.Status + if status == "" { + status = "unknown" + } if archiveChannelLabel != "" { - return fmt.Errorf("%s task %s missing source URL", archiveChannelLabel, task.TaskID) + return fmt.Errorf("task %s missing source URL: phase=%s status=%s", task.TaskID, phase, status) } - return fmt.Errorf("techmobi task %s missing source URL", task.TaskID) + return fmt.Errorf("task %s missing source URL: phase=%s status=%s", task.TaskID, phase, status) + } + + phase := task.Progress + if phase == "" { + phase = "unknown" + } + status := taskResult.Status + if status == "" { + status = "unknown" } if archiveChannelLabel != "" && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { @@ -490,7 +508,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } if archiveErr != nil { perfmetrics.RecordVideoResultArchiveRetry(archiveChannelLabel, "archive_failure") - return fmt.Errorf("archive %s video result failed for task %s: %s", archiveChannelLabel, task.TaskID, sanitizeVideoResultArchiveError(archiveErr)) + return fmt.Errorf("video archive failed for task %s: phase=%s status=%s: %s", task.TaskID, phase, status, sanitizeVideoResultArchiveError(archiveErr)) } task.PrivateData.VideoResult = videoResult } @@ -541,7 +559,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * shouldSettle = true case model.TaskStatusFailure: if VideoResultChannelLabel(ch.Type) != "" { - logger.LogInfo(ctx, fmt.Sprintf("Archived video task failed: task_id=%s channel_id=%d status=%s reason=%s", task.TaskID, ch.Id, taskResult.Status, sanitizeArchivedVideoLogText(ch.Type, taskResult.Reason))) + logger.LogInfo(ctx, fmt.Sprintf("Archived video task failed: task_id=%s status=%s", task.TaskID, taskResult.Status)) } else { logger.LogJson(ctx, fmt.Sprintf("Task %s failed", taskId), task) } @@ -553,8 +571,10 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * task.FailReason = taskResult.Reason if VideoResultChannelLabel(ch.Type) != "" { task.FailReason = sanitizeArchivedVideoLogText(ch.Type, task.FailReason) + logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: status=%s", task.TaskID, task.Status)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) } - logger.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) taskResult.Progress = taskcommon.ProgressComplete if quota != 0 { shouldRefund = true diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index fc6f456cd3d..9046a8eccfa 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -277,7 +277,10 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), techMobiTaskMap(task)) require.Error(t, err) - require.Contains(t, err.Error(), "archive techmobi video result failed") + require.Contains(t, err.Error(), "video archive failed for task") + require.Contains(t, err.Error(), "phase=50%") + require.Contains(t, err.Error(), "status=SUCCESS") + require.Contains(t, err.Error(), "archive unavailable") require.NotContains(t, err.Error(), "secret.example") require.Equal(t, 0, adaptor.adjustCalls) @@ -328,6 +331,8 @@ func TestUpdateVideoSingleTaskModelAPIArchivesAndSetsProxyURL(t *testing.T) { require.Equal(t, "task_modelapi_success", publicTaskID) require.Equal(t, "https://secret.example/video.mp4?token=secret", upstreamURL) require.Equal(t, "http://proxy.internal:8080", proxy) + require.EqualValues(t, model.TaskStatusInProgress, task.Status, "archive must run before final success status mutation") + require.Zero(t, task.FinishTime, "archive must run before final finish time mutation") return expected, nil } @@ -374,7 +379,10 @@ func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *tes err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) require.Error(t, err) - require.Contains(t, err.Error(), "archive modelapi video result failed") + require.Contains(t, err.Error(), "video archive failed for task") + require.Contains(t, err.Error(), "phase=50%") + require.Contains(t, err.Error(), "status=SUCCESS") + require.Contains(t, err.Error(), "archive unavailable") require.NotContains(t, err.Error(), "secret.example") require.Equal(t, 0, adaptor.adjustCalls) @@ -392,6 +400,47 @@ func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *tes require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="modelapi",reason="archive_failure"} 1`) } +func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 927, 1000) + seedToken(t, 927, 927, "sk-modelapi-archive-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_archive_leak", 927, 947, 100, 927) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + archiveError := errors.New("download failed from https://secret.example/video.mp4?token=secret") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: modelAPIArchiveResponseBody(), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + } + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + return nil, archiveError + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, err.Error(), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "secret.example") + + logText := logs.String() + require.NotContains(t, logText, "modelapi") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, "secret.example") +} + func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -472,9 +521,13 @@ func TestUpdateVideoSingleTaskModelAPIRedactsStoredDataAndLogs(t *testing.T) { require.NotContains(t, strings.ToLower(storedData), "modelapi") logText := logs.String() + upstreamTaskID := "upstream-modelapi-success" + require.NotContains(t, logText, upstreamTaskID) require.NotContains(t, logText, upstreamURL) require.NotContains(t, logText, "https://") require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, logText, "channel_id=") + require.NotContains(t, logText, "reason=") require.NotContains(t, strings.ToLower(logText), "modelapi") } @@ -512,6 +565,10 @@ func TestUpdateVideoSingleTaskModelAPIFailureRedactsDBAndLogs(t *testing.T) { require.NotContains(t, strings.ToLower(logs.String()), "modelapi") require.NotContains(t, logs.String(), "https://") require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, logs.String(), "channel_id=") + require.NotContains(t, logs.String(), "reason=") + require.NotContains(t, logs.String(), "render failed") + require.NotContains(t, logs.String(), "upstream-modelapi-success") } func TestUpdateVideoSingleTaskModelAPIUnknownErrorFormatDoesNotLogRawResponse(t *testing.T) { @@ -534,6 +591,7 @@ func TestUpdateVideoSingleTaskModelAPIUnknownErrorFormatDoesNotLogRawResponse(t require.NotContains(t, strings.ToLower(logs.String()), "modelapi") require.NotContains(t, logs.String(), "https://") require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, logs.String(), "upstream-modelapi-success") } func TestUpdateVideoSingleTaskModelAPICASLoserDoesNotSettleTwice(t *testing.T) { @@ -714,7 +772,7 @@ func TestUpdateVideoSingleTaskArchiveFailurePayloadRedactsDBAndLogs(t *testing.T require.NotContains(t, logs.String(), "secret.example") require.NotContains(t, logs.String(), "token=secret") require.Contains(t, logs.String(), "task_archive_success") - require.Contains(t, logs.String(), "render failed") + require.NotContains(t, logs.String(), "render failed") } func TestRedactTechMobiVideoResponseBodyRemovesUpstreamURLsAndKeepsPublicFields(t *testing.T) { From 93cbcffcbf0aae6a852f4e5c2ddbdcd900f21d35 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:43:13 +0800 Subject: [PATCH 12/32] Prevent unsafe video archive fetch paths Constraint: Untrusted video asset URLs must not be delegated to proxy clients that resolve and connect outside dial-time SSRF controls. Rejected: Proxy-side URL text validation only | it cannot prove the proxy will not resolve or connect to unsafe destinations. Confidence: high Scope-risk: narrow Directive: Keep archive object keys task-stable; preserve historical dated object signing for existing records. Tested: go test ./service -run 'VideoResult|Archived' -count=1; git diff --check -- service/video_result_storage.go service/video_result_storage_test.go Not-tested: Full repository test suite. --- service/video_result_storage.go | 11 ++- service/video_result_storage_test.go | 115 ++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 13 deletions(-) diff --git a/service/video_result_storage.go b/service/video_result_storage.go index bb650a53ac5..a4230618e46 100644 --- a/service/video_result_storage.go +++ b/service/video_result_storage.go @@ -157,6 +157,10 @@ func ArchiveVideoResultForChannel(ctx context.Context, channel, publicTaskID, up recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent } + if strings.TrimSpace(proxy) != "" { + recordArchive("failure", 0) + return nil, ErrVideoResultInvalidContent + } client, err := newVideoResultFetchHTTPClient(cfg, proxy, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) if err != nil { recordArchive("failure", 0) @@ -325,11 +329,11 @@ func videoResultCheckRedirect(req *http.Request, via []*http.Request) error { return nil } -func buildVideoResultObjectKey(taskID string, archiveStart time.Time) (string, error) { +func buildVideoResultObjectKey(taskID string, _ time.Time) (string, error) { if !videoResultTaskIDPattern.MatchString(taskID) { return "", ErrVideoResultInvalidTaskID } - return "video-results/" + archiveStart.UTC().Format("20060102") + "/" + taskID + ".mp4", nil + return "video-results/tasks/" + taskID + ".mp4", nil } func videoResultObjectBelongsToTask(objectKey, taskID string) bool { @@ -338,6 +342,9 @@ func videoResultObjectBelongsToTask(objectKey, taskID string) bool { return false } remainder := strings.TrimPrefix(objectKey, prefix) + if remainder == "tasks/"+taskID+".mp4" { + return true + } if len(remainder) <= 9 || remainder[8] != '/' { return false } diff --git a/service/video_result_storage_test.go b/service/video_result_storage_test.go index eb772c551a9..3d0589d1d73 100644 --- a/service/video_result_storage_test.go +++ b/service/video_result_storage_test.go @@ -73,7 +73,11 @@ func TestVideoResultObjectKey(t *testing.T) { now := time.Date(2026, 8, 6, 23, 59, 0, 0, time.FixedZone("CST", 8*3600)) key, err := buildVideoResultObjectKey("task_Abc-123_ok", now) require.NoError(t, err) - require.Equal(t, "video-results/20260806/task_Abc-123_ok.mp4", key) + require.Equal(t, "video-results/tasks/task_Abc-123_ok.mp4", key) + + nextDayKey, err := buildVideoResultObjectKey("task_Abc-123_ok", now.Add(10*time.Hour)) + require.NoError(t, err) + require.Equal(t, key, nextDayKey) for _, taskID := range []string{"", "abc", "task_", "task_../x", "task_a/b", "../task_a", "task_ space"} { _, err := buildVideoResultObjectKey(taskID, now) @@ -115,7 +119,7 @@ func TestArchiveVideoResult(t *testing.T) { require.NoError(t, err) require.Equal(t, &model.VideoResult{ Bucket: "video-bucket", - Object: "video-results/20260806/task_archive-1.mp4", + Object: "video-results/tasks/task_archive-1.mp4", Generation: 1, ContentType: "video/mp4", Size: int64(len(payload)), @@ -123,7 +127,7 @@ func TestArchiveVideoResult(t *testing.T) { ExpiresAt: start.Add(time.Hour).Unix(), }, result) - created := store.created["video-bucket/video-results/20260806/task_archive-1.mp4"] + created := store.created["video-bucket/video-results/tasks/task_archive-1.mp4"] require.Equal(t, payload, created.body) require.Equal(t, "video/mp4", created.options.ContentType) require.Equal(t, "private, max-age=0, no-store", created.options.CacheControl) @@ -149,8 +153,8 @@ func TestArchiveVideoResult(t *testing.T) { result, err := ArchiveVideoResultForChannel(context.Background(), "modelapi", "task_modelapi_archive", server.URL, "") require.NoError(t, err) - require.Equal(t, "video-results/20260806/task_modelapi_archive.mp4", result.Object) - require.Contains(t, store.created, "video-bucket/video-results/20260806/task_modelapi_archive.mp4") + require.Equal(t, "video-results/tasks/task_modelapi_archive.mp4", result.Object) + require.Contains(t, store.created, "video-bucket/video-results/tasks/task_modelapi_archive.mp4") text, err := perfmetrics.BuildPrometheusText(context.Background()) require.NoError(t, err) @@ -490,6 +494,58 @@ func TestArchiveVideoResult(t *testing.T) { require.ErrorIs(t, err, ErrVideoResultConfig) }) + t.Run("rejects configured proxy before fetching archive source", func(t *testing.T) { + start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + sourceHits := 0 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(minimalMP4Fixture()) + })) + defer source.Close() + proxyHits := 0 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(minimalMP4Fixture()) + })) + defer proxy.Close() + + _, err := ArchiveVideoResult(context.Background(), "task_proxy_rejected", source.URL, proxy.URL) + require.ErrorIs(t, err, ErrVideoResultInvalidContent) + require.Equal(t, 0, proxyHits) + require.Equal(t, 0, sourceHits) + require.Empty(t, store.created) + }) + + t.Run("uses the same object key across archive start dates", func(t *testing.T) { + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + server := newVideoResultTestServer(t, http.StatusOK, "video/mp4", string(payload)) + defer server.Close() + + storeBeforeMidnight := newFakeVideoResultStore() + restoreBefore := installVideoResultArchiveTestHooks(t, storeBeforeMidnight, time.Date(2026, 8, 6, 23, 59, 59, 0, time.UTC)) + resultBefore, err := ArchiveVideoResult(context.Background(), "task_cross_date", server.URL, "") + require.NoError(t, err) + restoreBefore() + + storeAfterMidnight := newFakeVideoResultStore() + restoreAfter := installVideoResultArchiveTestHooks(t, storeAfterMidnight, time.Date(2026, 8, 7, 0, 0, 1, 0, time.UTC)) + resultAfter, err := ArchiveVideoResult(context.Background(), "task_cross_date", server.URL, "") + require.NoError(t, err) + restoreAfter() + + require.Equal(t, "video-results/tasks/task_cross_date.mp4", resultBefore.Object) + require.Equal(t, resultBefore.Object, resultAfter.Object) + require.Contains(t, storeBeforeMidnight.created, "video-bucket/video-results/tasks/task_cross_date.mp4") + require.Contains(t, storeAfterMidnight.created, "video-bucket/video-results/tasks/task_cross_date.mp4") + }) + t.Run("reuses valid existing object after create conflict", func(t *testing.T) { resetVideoResultMetricsForServiceTest(t) start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) @@ -499,7 +555,7 @@ func TestArchiveVideoResult(t *testing.T) { defer restore() t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") t.Setenv("VIDEO_RESULT_RETENTION_SECONDS", "7200") - key := "video-bucket/video-results/20260806/task_conflict.mp4" + key := "video-bucket/video-results/tasks/task_conflict.mp4" store.createErr = ErrVideoResultAlreadyExists store.attrs[key] = VideoResultObjectAttrs{ ContentType: "video/mp4", @@ -529,7 +585,7 @@ func TestArchiveVideoResult(t *testing.T) { restore := installVideoResultArchiveTestHooks(t, store, start) defer restore() t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") - key := "video-bucket/video-results/20260806/task_invalid_existing.mp4" + key := "video-bucket/video-results/tasks/task_invalid_existing.mp4" store.createErr = ErrVideoResultAlreadyExists store.attrs[key] = VideoResultObjectAttrs{ContentType: "application/octet-stream", Size: 42, Generation: 7, Created: start} @@ -601,19 +657,19 @@ func TestSignVideoResultDownload(t *testing.T) { t.Setenv("VIDEO_RESULT_SERVICE_ACCOUNT_EMAIL", "video-signer@example.iam.gserviceaccount.com") result := &model.VideoResult{ Bucket: "video-bucket", - Object: "video-results/20260806/task_signed.mp4", + Object: "video-results/tasks/task_signed.mp4", Generation: 7, ContentType: "video/mp4; charset=binary", Size: 42, ExpiresAt: now.Add(5 * time.Minute).Unix(), } - store.attrs["video-bucket/video-results/20260806/task_signed.mp4"] = VideoResultObjectAttrs{ + store.attrs["video-bucket/video-results/tasks/task_signed.mp4"] = VideoResultObjectAttrs{ ContentType: "video/mp4; charset=binary", Size: 42, Generation: 7, Created: now.Add(-time.Minute), } - store.signedURL = "https://storage.googleapis.com/video-bucket/video-results/20260806/task_signed.mp4?X-Goog-Signature=secret" + store.signedURL = "https://storage.googleapis.com/video-bucket/video-results/tasks/task_signed.mp4?X-Goog-Signature=secret" signed, err := SignVideoResultDownload(context.Background(), "task_signed", result) require.NoError(t, err) @@ -631,6 +687,36 @@ func TestSignVideoResultDownload(t *testing.T) { require.Equal(t, "video/mp4", req.QueryParameters.Get("response-content-type")) }) + t.Run("signs a historical date object path for existing archived results", func(t *testing.T) { + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, now) + defer restore() + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + t.Setenv("VIDEO_RESULT_SIGNED_URL_TTL_SECONDS", "900") + result := &model.VideoResult{ + Bucket: "video-bucket", + Object: "video-results/20260806/task_signed.mp4", + Generation: 7, + ContentType: "video/mp4", + Size: 42, + ExpiresAt: now.Add(5 * time.Minute).Unix(), + } + store.attrs["video-bucket/video-results/20260806/task_signed.mp4"] = VideoResultObjectAttrs{ + ContentType: "video/mp4", + Size: 42, + Generation: 7, + Created: now.Add(-time.Minute), + } + + signed, err := SignVideoResultDownload(context.Background(), "task_signed", result) + require.NoError(t, err) + require.Equal(t, "https://signed.example/video", signed) + require.Equal(t, "video-bucket", store.signedBucket) + require.Equal(t, "video-results/20260806/task_signed.mp4", store.signedObject) + require.Equal(t, 1, store.attrsCalls) + require.Equal(t, 1, store.signCalls) + }) + t.Run("requires attrs content type to match persisted media type", func(t *testing.T) { store := newFakeVideoResultStore() installVideoResultArchiveTestHooks(t, store, now) @@ -671,6 +757,9 @@ func TestSignVideoResultDownload(t *testing.T) { t.Run("rejects object paths not bound to the requested task before object access", func(t *testing.T) { for _, objectKey := range []string{ + "video-results/tasks/task_other.mp4", + "video-results/tasks/task_signed.webm", + "video-results/tasks/nested/task_signed.mp4", "video-results/20260806/task_other.mp4", "video-results/20260806/task_signed.webm", "video-results/2026080/task_signed.mp4", @@ -918,6 +1007,8 @@ type fakeVideoResultStore struct { attrsCalls int signCalls int signRequests []VideoResultSignedURLRequest + signedBucket string + signedObject string nextAttrs VideoResultObjectAttrs closedWithError bool validatedURLs map[string]bool @@ -973,9 +1064,11 @@ func (f *fakeVideoResultStore) Attrs(_ context.Context, bucket, objectKey string return attrs, nil } -func (f *fakeVideoResultStore) SignURL(_ context.Context, _ string, _ string, request VideoResultSignedURLRequest) (string, error) { +func (f *fakeVideoResultStore) SignURL(_ context.Context, bucket, objectKey string, request VideoResultSignedURLRequest) (string, error) { f.signCalls++ f.signRequests = append(f.signRequests, request) + f.signedBucket = bucket + f.signedObject = objectKey if f.signErr != nil { return "", f.signErr } From 71d2a3a824a135bcd88eed2eda67af43a3e529a2 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:44:49 +0800 Subject: [PATCH 13/32] Keep classic ModelAPISeedance regression test lintable Constraint: Only the classic ModelAPISeedance test file may change; leave service/backend and test logic untouched. Rejected: Addressing regex-test review minor | explicitly out of scope for this lint-blocker fix. Confidence: high Scope-risk: narrow Directive: Preserve the standard AGPL header on classic JS/JSX tests. Tested: bun x eslint src/components/table/channels/modals/modelapi-seedance-classic.test.js; bun test src/components/table/channels/modals/modelapi-seedance-classic.test.js; bun run build Not-tested: Browser UI, unchanged by this header-only fix. --- .../modals/modelapi-seedance-classic.test.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js index e930ad843f2..2f0114d2437 100644 --- a/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js +++ b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js @@ -1,3 +1,22 @@ +/* +Copyright (C) 2025 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 { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; From adfc24a51fd57bce48ab4e61ae974aad67902d64 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:49:27 +0800 Subject: [PATCH 14/32] Constrain video polling diagnostic exposure Return fixed-phase errors for archived polling fetch/read/parse failures and remove internal channel IDs from video polling batch logs. Constraint: White-label and archived-channel output may expose only public task IDs plus generic phase/status/bytes context; ModelAPI metrics labels remain unchanged. Rejected: Wrapping upstream fetch/read/parse errors | outer polling logs would serialize private URLs, branded hosts, and upstream IDs. Rejected: Logging video channel IDs in batch polling | ModelAPI channel IDs are internal routing details. Confidence: high Scope-risk: narrow Directive: Do not log unvalidated upstream task status or failure reason text on archived video polling paths. Tested: go test ./service -run TestUpdateVideo(SingleTaskModelAPI(FetchErrorDoesNotLeakUpstreamDetails|ReadErrorDoesNotLeakUpstreamDetails|ParseErrorDoesNotLeakUpstreamDetails|UnknownStatusDoesNotLeakUpstreamDetails)|TasksModelAPIDoesNotLogChannelID)$ -count=1; go test ./service -run 'Test(UpdateVideoTasks|UpdateVideoSingleTask|RedactTechMobiVideoResponseBody)' -count=1; git diff --check -- service/task_polling.go service/task_polling_video_result_test.go Not-tested: Full repository test suite. --- service/task_polling.go | 33 +++- service/task_polling_video_result_test.go | 189 +++++++++++++++++++++- 2 files changed, 217 insertions(+), 5 deletions(-) diff --git a/service/task_polling.go b/service/task_polling.go index cde1e0fee61..2ae7d4042f9 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -319,14 +319,14 @@ func taskNeedsUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool { func UpdateVideoTasks(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { for channelId, taskIds := range taskChannelM { if err := updateVideoTasks(ctx, platform, channelId, taskIds, taskM); err != nil { - logger.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) + logger.LogError(ctx, fmt.Sprintf("Failed to update video async tasks: %s", err.Error())) } } return nil } func updateVideoTasks(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error { - logger.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) + logger.LogInfo(ctx, fmt.Sprintf("Pending video tasks: %d", len(taskIds))) if len(taskIds) == 0 { return nil } @@ -395,11 +395,17 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * "action": task.Action, }, proxy) if err != nil { + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "fetch") + } return fmt.Errorf("fetchTask failed for task %s: %w", task.TaskID, err) } defer resp.Body.Close() responseBody, err := io.ReadAll(resp.Body) if err != nil { + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "read") + } return fmt.Errorf("readAll failed for task %s: %w", task.TaskID, err) } @@ -416,7 +422,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * var responseItems dto.TaskResponse[model.Task] if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { if VideoResultChannelLabel(ch.Type) != "" { - logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s phase=parsed status=%s", task.TaskID, responseItems.Data.Status) + logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s phase=parsed status=%s", task.TaskID, archivedVideoPollingStatus(string(responseItems.Data.Status))) } else { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: %+v", responseItems) } @@ -428,13 +434,16 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult.Reason = t.FailReason task.Data = t.Data } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { + if VideoResultChannelLabel(ch.Type) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "parse") + } return fmt.Errorf("parseTaskResult failed for task %s: %w", task.TaskID, err) } task.Data = redactVideoResponseForChannel(ch.Type, responseBody) if VideoResultChannelLabel(ch.Type) != "" { - logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s phase=parsed status=%s progress=%s", task.TaskID, taskResult.Status, taskResult.Progress) + logger.LogDebug(ctx, "updateVideoSingleTask task result parsed: task_id=%s phase=parsed status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) } else { logger.LogDebug(ctx, "updateVideoSingleTask taskResult: %+v", taskResult) } @@ -580,6 +589,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * shouldRefund = true } default: + if VideoResultChannelLabel(ch.Type) != "" { + return fmt.Errorf("unknown task status for task %s: phase=status status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) + } return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, task.TaskID) } if taskResult.Progress != "" { @@ -740,6 +752,19 @@ func sanitizeVideoResultArchiveError(err error) string { return "archive unavailable" } +func archivedVideoPollingPhaseError(taskID, phase string) error { + return fmt.Errorf("task %s polling failed: phase=%s", taskID, phase) +} + +func archivedVideoPollingStatus(status string) string { + switch model.TaskStatus(status) { + case model.TaskStatusSubmitted, model.TaskStatusQueued, model.TaskStatusInProgress, model.TaskStatusSuccess, model.TaskStatusFailure: + return status + default: + return "unknown" + } +} + func sanitizeArchivedVideoLogText(channelType int, text string) string { if strings.TrimSpace(text) == "" { return "" diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index 9046a8eccfa..d852a9ea2e3 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -7,6 +7,7 @@ import ( "errors" "io" "net/http" + "net/url" "strings" "testing" "time" @@ -441,6 +442,167 @@ func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T require.NotContains(t, logText, "secret.example") } +func TestUpdateVideoSingleTaskModelAPIFetchErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 928, 1000) + seedToken(t, 928, 928, "sk-modelapi-fetch-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_fetch_error", 928, 948, 100, 928) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + fetchErr: &url.Error{ + Op: "Get", + URL: "https://api.modelapi.co/v1/tasks/upstream-secret-id", + Err: errors.New("dial upstream-secret-id failed"), + }, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_fetch_error") + require.Contains(t, err.Error(), "phase=fetch") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") + require.NotContains(t, logs.String(), "https://") + require.NotContains(t, logs.String(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(logs.String()), "modelapi") + require.NotContains(t, logs.String(), upstreamTaskID) + require.NotContains(t, logs.String(), "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIReadErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 929, 1000) + seedToken(t, 929, 929, "sk-modelapi-read-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_read_error", 929, 949, 100, 929) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + body: errReadCloser{err: errors.New("read https://api.modelapi.co/v1/tasks/upstream-secret-id failed")}, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_read_error") + require.Contains(t, err.Error(), "phase=read") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIParseErrorDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 930, 1000) + seedToken(t, 930, 930, "sk-modelapi-parse-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_parse_error", 930, 950, 100, 930) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"status":"not-new-api"}`), + parseErr: errors.New("parse ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id failed"), + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_parse_error") + require.Contains(t, err.Error(), "phase=parse") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") +} + +func TestUpdateVideoTasksModelAPIDoesNotLogChannelID(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 931, 1000) + seedToken(t, 931, 931, "sk-modelapi-channel-log", 500) + task := newModelAPIPollingTaskWithID(t, "task_channel_log", 931, 951, 100, 931) + ch := newModelAPIPollingChannel("") + ch.Id = 951 + require.NoError(t, model.DB.Create(ch).Error) + originalAdaptorFunc := GetTaskAdaptorFunc + GetTaskAdaptorFunc = func(platform constant.TaskPlatform) TaskPollingAdaptor { + require.Equal(t, constant.TaskPlatform("111"), platform) + return &fakeVideoPollingAdaptor{ + fetchErr: errors.New("fetch failed from https://api.modelapi.co/v1/tasks/upstream-secret-id"), + } + } + t.Cleanup(func() { + GetTaskAdaptorFunc = originalAdaptorFunc + }) + + require.NoError(t, UpdateVideoTasks(ctx, constant.TaskPlatform("111"), map[int][]string{ch.Id: []string{task.GetUpstreamTaskID()}}, modelAPITaskMap(task))) + + logText := logs.String() + require.NotContains(t, logText, "Channel #") + require.NotContains(t, logText, "951") + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, strings.ToLower(logText), "modelapi") + require.NotContains(t, logText, task.GetUpstreamTaskID()) + require.NotContains(t, logText, "upstream-secret-id") +} + +func TestUpdateVideoSingleTaskModelAPIUnknownStatusDoesNotLeakUpstreamDetails(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + logs := capturePollingLogs(t) + ctx := context.Background() + + seedUser(t, 932, 1000) + seedToken(t, 932, 932, "sk-modelapi-status-leak", 500) + task := newModelAPIPollingTaskWithID(t, "task_status_error", 932, 952, 100, 932) + upstreamTaskID := task.GetUpstreamTaskID() + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{"status":"not-new-api"}`), + taskResult: &relaycommon.TaskInfo{ + TaskID: "upstream-modelapi-success", + Status: "ModelAPI failed at https://api.modelapi.co/v1/tasks/upstream-secret-id", + Reason: "reason from https://api.modelapi.co/v1/tasks/upstream-secret-id", + Progress: "100%", + }, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_status_error") + require.Contains(t, err.Error(), "phase=status") + require.Contains(t, err.Error(), "status=unknown") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") + + logText := logs.String() + require.NotContains(t, logText, "https://") + require.NotContains(t, logText, "api.modelapi.co") + require.NotContains(t, strings.ToLower(logText), "modelapi") + require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, "upstream-secret-id") +} + func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -1035,18 +1197,31 @@ type fakeVideoPollingAdaptor struct { taskResult *relaycommon.TaskInfo actualQuota int adjustCalls int + fetchErr error + parseErr error + body io.ReadCloser } func (a *fakeVideoPollingAdaptor) Init(*relaycommon.RelayInfo) {} func (a *fakeVideoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + if a.fetchErr != nil { + return nil, a.fetchErr + } + body := a.body + if body == nil { + body = io.NopCloser(bytes.NewReader(a.responseBody)) + } return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(a.responseBody)), + Body: body, }, nil } func (a *fakeVideoPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + if a.parseErr != nil { + return nil, a.parseErr + } return a.taskResult, nil } @@ -1054,3 +1229,15 @@ func (a *fakeVideoPollingAdaptor) AdjustBillingOnComplete(*model.Task, *relaycom a.adjustCalls++ return a.actualQuota } + +type errReadCloser struct { + err error +} + +func (r errReadCloser) Read([]byte) (int, error) { + return 0, r.err +} + +func (r errReadCloser) Close() error { + return nil +} From d5437d59c7186d4bbeca7f22f5ad6b57a01e2963 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:08:07 +0800 Subject: [PATCH 15/32] Keep video archive fetches on guarded direct clients Constraint: Archive fetch proxy input is already fail-closed before client construction. Rejected: Retaining dormant GetHttpClientWithProxy branch | future call-site drift could reintroduce proxy-side SSRF bypass. Confidence: high Scope-risk: narrow Directive: Do not add proxy support to video result archival unless it preserves dial-time SSRF enforcement. Tested: go test ./service -run 'VideoResult|Archived' -count=1; git diff --check -- service/video_result_storage.go; rg confirmed no GetHttpClientWithProxy reference in service/video_result_storage.go and helper signature has no proxy parameter. Not-tested: Full repository test suite. --- service/video_result_storage.go | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/service/video_result_storage.go b/service/video_result_storage.go index a4230618e46..40f02387a91 100644 --- a/service/video_result_storage.go +++ b/service/video_result_storage.go @@ -161,7 +161,7 @@ func ArchiveVideoResultForChannel(ctx context.Context, channel, publicTaskID, up recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent } - client, err := newVideoResultFetchHTTPClient(cfg, proxy, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) + client, err := newVideoResultFetchHTTPClient(cfg, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) if err != nil { recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent @@ -298,23 +298,12 @@ func completeVideoResultMetadata(result *model.VideoResult) bool { result.ExpiresAt > 0 } -func newVideoResultFetchHTTPClient(cfg VideoResultStorageConfig, proxy string, resolver assetFetchResolver, dialContext func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) { - var client *http.Client - if strings.TrimSpace(proxy) == "" { - client = newAssetFetchHTTPClient(assetFetchHTTPClientConfig{ - Timeout: cfg.FetchTimeout, - Resolver: resolver, - DialContext: dialContext, - }) - } else { - baseClient, err := GetHttpClientWithProxy(proxy) - if err != nil { - return nil, err - } - cloned := *baseClient - cloned.Timeout = cfg.FetchTimeout - client = &cloned - } +func newVideoResultFetchHTTPClient(cfg VideoResultStorageConfig, resolver assetFetchResolver, dialContext func(context.Context, string, string) (net.Conn, error)) (*http.Client, error) { + client := newAssetFetchHTTPClient(assetFetchHTTPClientConfig{ + Timeout: cfg.FetchTimeout, + Resolver: resolver, + DialContext: dialContext, + }) client.CheckRedirect = videoResultCheckRedirect return client, nil } From ab683a1f14188213eff3832745ad8a7d17c413d1 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:15:22 +0800 Subject: [PATCH 16/32] Keep archived video polling diagnostics provider-neutral Constraint: Archived-channel logs must expose only public task identifiers and fixed white-label phases/status values. Rejected: Reusing persisted progress text in source/archive errors | upstream-controlled values can carry provider brands, URLs, and secret task identifiers. Confidence: high Scope-risk: narrow Directive: Never interpolate upstream or persisted progress text into archived-channel errors. Tested: targeted and broad updateVideoSingleTask/updateVideoTasks service tests; gofmt; git diff --check. Not-tested: Full repository suite is delegated to final verification. --- service/task_polling.go | 24 ++----------------- service/task_polling_video_result_test.go | 28 +++++++++++++++++++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/service/task_polling.go b/service/task_polling.go index 2ae7d4042f9..a8146a1fb78 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -478,27 +478,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * archiveChannelLabel := VideoResultChannelLabel(ch.Type) if (returnSourceURL || (archiveChannelLabel != "" && !returnSourceURL)) && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess && strings.TrimSpace(taskResult.Url) == "" { - phase := task.Progress - if phase == "" { - phase = "unknown" - } - status := taskResult.Status - if status == "" { - status = "unknown" - } - if archiveChannelLabel != "" { - return fmt.Errorf("task %s missing source URL: phase=%s status=%s", task.TaskID, phase, status) - } - return fmt.Errorf("task %s missing source URL: phase=%s status=%s", task.TaskID, phase, status) - } - - phase := task.Progress - if phase == "" { - phase = "unknown" - } - status := taskResult.Status - if status == "" { - status = "unknown" + return fmt.Errorf("task %s missing source URL: phase=source status=%s", task.TaskID, archivedVideoPollingStatus(taskResult.Status)) } if archiveChannelLabel != "" && !returnSourceURL && taskResult.Status == model.TaskStatusSuccess && snap.Status != model.TaskStatusSuccess { @@ -517,7 +497,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * } if archiveErr != nil { perfmetrics.RecordVideoResultArchiveRetry(archiveChannelLabel, "archive_failure") - return fmt.Errorf("video archive failed for task %s: phase=%s status=%s: %s", task.TaskID, phase, status, sanitizeVideoResultArchiveError(archiveErr)) + return fmt.Errorf("video archive failed for task %s: phase=archive status=%s: %s", task.TaskID, archivedVideoPollingStatus(taskResult.Status), sanitizeVideoResultArchiveError(archiveErr)) } task.PrivateData.VideoResult = videoResult } diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index d852a9ea2e3..d267c029267 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -261,6 +261,7 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) seedUser(t, 902, 1000) seedToken(t, 912, 902, "sk-techmobi-archive-error", 500) task := newTechMobiPollingTask(t, 902, 932, 100, 912) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" ch := newTechMobiPollingChannel("") adaptor := &fakeVideoPollingAdaptor{ responseBody: techMobiArchiveResponseBody(), @@ -279,9 +280,13 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), techMobiTaskMap(task)) require.Error(t, err) require.Contains(t, err.Error(), "video archive failed for task") - require.Contains(t, err.Error(), "phase=50%") + require.Contains(t, err.Error(), "phase=archive") require.Contains(t, err.Error(), "status=SUCCESS") require.Contains(t, err.Error(), "archive unavailable") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") require.NotContains(t, err.Error(), "secret.example") require.Equal(t, 0, adaptor.adjustCalls) @@ -362,7 +367,8 @@ func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *tes seedUser(t, 911, 1000) seedToken(t, 921, 911, "sk-modelapi-archive-error", 500) - task := newModelAPIPollingTaskWithID(t, "task_modelapi_archive_error", 911, 941, 100, 921) + task := newModelAPIPollingTaskWithID(t, "task_archive_error_public", 911, 941, 100, 921) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" ch := newModelAPIPollingChannel("") adaptor := &fakeVideoPollingAdaptor{ responseBody: modelAPIArchiveResponseBody(), @@ -381,9 +387,13 @@ func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *tes err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) require.Error(t, err) require.Contains(t, err.Error(), "video archive failed for task") - require.Contains(t, err.Error(), "phase=50%") + require.Contains(t, err.Error(), "phase=archive") require.Contains(t, err.Error(), "status=SUCCESS") require.Contains(t, err.Error(), "archive unavailable") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") require.NotContains(t, err.Error(), "secret.example") require.Equal(t, 0, adaptor.adjustCalls) @@ -410,6 +420,7 @@ func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T seedUser(t, 927, 1000) seedToken(t, 927, 927, "sk-modelapi-archive-leak", 500) task := newModelAPIPollingTaskWithID(t, "task_archive_leak", 927, 947, 100, 927) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" upstreamTaskID := task.GetUpstreamTaskID() ch := newModelAPIPollingChannel("") archiveError := errors.New("download failed from https://secret.example/video.mp4?token=secret") @@ -428,10 +439,12 @@ func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) require.Error(t, err) + require.Contains(t, err.Error(), "phase=archive") require.NotContains(t, err.Error(), "https://") require.NotContains(t, err.Error(), "api.modelapi.co") require.NotContains(t, err.Error(), "modelapi") require.NotContains(t, err.Error(), upstreamTaskID) + require.NotContains(t, err.Error(), "upstream-secret-id") require.NotContains(t, err.Error(), "secret.example") logText := logs.String() @@ -439,6 +452,7 @@ func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T require.NotContains(t, logText, "api.modelapi.co") require.NotContains(t, logText, "https://") require.NotContains(t, logText, upstreamTaskID) + require.NotContains(t, logText, "upstream-secret-id") require.NotContains(t, logText, "secret.example") } @@ -610,7 +624,8 @@ func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t * seedUser(t, 912, 1000) seedToken(t, 922, 912, "sk-modelapi-empty-url", 500) - task := newModelAPIPollingTaskWithID(t, "task_modelapi_empty_url", 912, 942, 100, 922) + task := newModelAPIPollingTaskWithID(t, "task_empty_url_public", 912, 942, 100, 922) + task.Progress = "ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id" ch := newModelAPIPollingChannel("") adaptor := &fakeVideoPollingAdaptor{ responseBody: modelAPIArchiveResponseBody(), @@ -630,6 +645,11 @@ func TestUpdateVideoSingleTaskModelAPIEmptySuccessURLDoesNotFinalizeOrSettle(t * err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) require.Error(t, err) require.Contains(t, err.Error(), "missing source URL") + require.Contains(t, err.Error(), "phase=source") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.NotContains(t, err.Error(), "upstream-secret-id") require.Equal(t, 0, adaptor.adjustCalls) var stored model.Task From 1e30cd44011033252da85c9bfa6c835c705b4339 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:33:49 +0800 Subject: [PATCH 17/32] Make archived polling brand regression case-insensitive Constraint: White-label regression tests must catch provider brands regardless of capitalization. Rejected: Keeping the lowercase-only assertion | it could miss a brand-only leak using the canonical ModelAPI casing. Confidence: high Scope-risk: narrow Directive: Normalize untrusted diagnostic text before asserting provider-brand absence. Tested: TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks; gofmt; git diff --check. Not-tested: Full repository suite pending final verification. --- service/task_polling_video_result_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index d267c029267..65cc4a51b10 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -442,7 +442,7 @@ func TestUpdateVideoSingleTaskModelAPIArchiveFailureNoUpstreamLeaks(t *testing.T require.Contains(t, err.Error(), "phase=archive") require.NotContains(t, err.Error(), "https://") require.NotContains(t, err.Error(), "api.modelapi.co") - require.NotContains(t, err.Error(), "modelapi") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") require.NotContains(t, err.Error(), upstreamTaskID) require.NotContains(t, err.Error(), "upstream-secret-id") require.NotContains(t, err.Error(), "secret.example") From ee76660abf44ff703c281cf6b4d5e202abdb453b Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:13:53 +0800 Subject: [PATCH 18/32] Prevent upstream task identifiers from entering persisted public task data Constraint: ModelAPI tasks must retain the upstream identifier only in PrivateData for polling Rejected: Persisting full submit or poll payloads | They expose provider identifiers beyond the private polling field Confidence: high Scope-risk: narrow Directive: Keep provider task identifiers out of Task.Data and all client or log surfaces Tested: ModelAPI adapter tests; targeted polling and content controller tests; scoped go vet; go build ./... Not-tested: Live ModelAPI to GCS staging round trip --- relay/channel/task/modelapiseedance/adaptor.go | 8 +++++++- relay/channel/task/modelapiseedance/adaptor_test.go | 7 +++++-- service/task_polling.go | 9 +++++++++ service/task_polling_video_result_test.go | 3 +++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index b4e1bd38dfb..729c0a11485 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -118,7 +118,13 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } ov.CreatedAt = time.Now().Unix() c.JSON(http.StatusOK, ov) - return submit.TaskID, responseBody, nil + taskData, err = common.Marshal(struct { + Status string `json:"status,omitempty"` + }{Status: submit.Status}) + if err != nil { + return "", nil, taskError(fmt.Errorf("failed to persist submit status"), "invalid_response", http.StatusBadGateway) + } + return submit.TaskID, taskData, nil } func (a *TaskAdaptor) GetModelList() []string { diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index 2132ca30a81..488255c9df3 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -440,8 +440,11 @@ func TestDoResponseParsesExactTaskIDAndRejectsIDOnly(t *testing.T) { if taskID != "upstream-task" { t.Fatalf("taskID = %q", taskID) } - if !strings.Contains(string(taskData), `"task_id":"upstream-task"`) { - t.Fatalf("taskData = %s", taskData) + if strings.Contains(string(taskData), "upstream-task") || strings.Contains(string(taskData), "task_id") { + t.Fatalf("persisted taskData leaked upstream task id: %s", taskData) + } + if !strings.Contains(string(taskData), `"status":"pending"`) { + t.Fatalf("persisted taskData missed safe submit status: %s", taskData) } if strings.Contains(w.Body.String(), "upstream-task") || !strings.Contains(w.Body.String(), `"id":"task_public"`) { t.Fatalf("client response leaked upstream or missed public id: %s", w.Body.String()) diff --git a/service/task_polling.go b/service/task_polling.go index a8146a1fb78..895902c015d 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -670,6 +670,10 @@ func redactArchivedVideoValue(v any, scrubBrand bool) any { switch value := v.(type) { case map[string]any: for key, child := range value { + if scrubBrand && isArchivedVideoPrivateIdentifierKey(key) { + delete(value, key) + continue + } if isArchivedVideoURLKey(key) { value[key] = redactArchivedVideoURLValue(child, scrubBrand) continue @@ -689,6 +693,11 @@ func redactArchivedVideoValue(v any, scrubBrand bool) any { } } +func isArchivedVideoPrivateIdentifierKey(key string) bool { + normalized := strings.ToLower(strings.ReplaceAll(key, "_", "")) + return normalized == "id" || normalized == "taskid" +} + func redactArchivedVideoURLValue(v any, scrubBrand bool) any { return redactArchivedVideoValue(v, scrubBrand) } diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index 65cc4a51b10..f07354f6188 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -698,6 +698,8 @@ func TestUpdateVideoSingleTaskModelAPIRedactsStoredDataAndLogs(t *testing.T) { require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) storedData := string(stored.Data) require.NotContains(t, storedData, upstreamURL) + require.NotContains(t, storedData, "opaque-upstream-task-123") + require.NotContains(t, storedData, "task_id") require.NotContains(t, storedData, "https://") require.NotContains(t, storedData, "api.modelapi.co") require.NotContains(t, strings.ToLower(storedData), "modelapi") @@ -1144,6 +1146,7 @@ func modelAPIArchiveResponseBody() []byte { func modelAPIRedactionResponseBody() []byte { return []byte(`{ "id":"upstream-modelapi-success", + "task_id":"opaque-upstream-task-123", "status":"succeeded", "result":{ "assets":[ From fe5f937bf05e4ca2ea9296888694009179608ded Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:17:26 +0800 Subject: [PATCH 19/32] Close runtime safety gaps for ModelAPI Seedance upstream Constraint: ModelAPI Seedance must remain whitelabel and store completed video downloads through Google while exposing Flatkey content URLs. Rejected: Allowing proxy for type 111 | stale DB/import proxy values could route sensitive upstream media paths outside the intended channel contract. Rejected: Dropping oversized non-whitelabel submit errors | it hides useful bounded diagnostics for existing channels such as Doubao. Confidence: high Scope-risk: moderate Directive: Keep ModelAPI Seedance proxy fail-closed at validation, UI payload, submit, and polling boundaries. Tested: go test ./relay -run TestTaskSubmitStatusError -count=1; go test ./relay/channel/task/... ./dto/... -count=1 -timeout 10m; go test ./relay ./service ./controller -run Test(TaskSubmitStatusError|UpdateVideoSingleTaskModelAPI|UpdateVideoTasksModelAPI|ValidateChannelRejects|DoResponse|FetchTask|ModelAPI) -count=1 -timeout 10m; go vet ./relay ./relay/channel/task/modelapiseedance ./service ./controller; go build ./...; bun test src/features/channels/lib/channel-form.test.ts; bun test src/components/table/channels/modals/modelapi-seedance-classic.test.js; web/default bun run typecheck; web/default bun run build; web/classic bun run build; git diff --check; git diff --cached --check Not-tested: Live ModelAPI submit/poll/archive flow against real credentials and production GCS IAM was not run in this workspace. --- controller/channel.go | 3 + .../channel_concurrency_validation_test.go | 17 + .../channel/task/modelapiseedance/adaptor.go | 45 ++- .../task/modelapiseedance/adaptor_test.go | 298 ++++++++++++++++-- .../task/modelapiseedance/constants.go | 1 + relay/relay_task.go | 15 +- relay/relay_task_submit_error_test.go | 138 ++++++++ service/task_polling.go | 44 ++- service/task_polling_video_result_test.go | 217 +++++++++++-- .../channels/modals/EditChannelModal.jsx | 6 +- .../modals/modelapi-seedance-classic.test.js | 18 ++ .../drawers/channel-mutate-drawer.tsx | 70 ++-- .../channels/lib/channel-form.test.ts | 27 ++ .../src/features/channels/lib/channel-form.ts | 25 +- 14 files changed, 808 insertions(+), 116 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index 63c72c3622f..55b623d1ad0 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -467,6 +467,9 @@ func validateChannel(channel *model.Channel, isAdd bool) error { if err := channel.ValidateSettings(); err != nil { return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) } + if channel.Type == constant.ChannelTypeModelAPISeedance && strings.TrimSpace(channel.GetSetting().Proxy) != "" { + return fmt.Errorf("this channel type does not support proxy") + } // 如果是添加操作,检查 channel 和 key 是否为空 if isAdd { diff --git a/controller/channel_concurrency_validation_test.go b/controller/channel_concurrency_validation_test.go index 7b09ba987e5..9b3e467cc3b 100644 --- a/controller/channel_concurrency_validation_test.go +++ b/controller/channel_concurrency_validation_test.go @@ -3,6 +3,7 @@ package controller import ( "testing" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/model" "github.com/stretchr/testify/require" ) @@ -13,3 +14,19 @@ func TestValidateChannelRejectsInvalidMaxConcurrency(t *testing.T) { err := validateChannel(&model.Channel{MaxConcurrency: -1}, true) require.ErrorContains(t, err, "channel max concurrency cannot be negative") } + +func TestValidateChannelRejectsModelAPISeedanceProxy(t *testing.T) { + setting := `{"proxy":" socks5://127.0.0.1:1080 "}` + channel := &model.Channel{ + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-test", + Models: "doubao-seedance-2-5-260628", + Setting: &setting, + } + + err := validateChannel(channel, true) + require.ErrorContains(t, err, "this channel type does not support proxy") + require.NotContains(t, err.Error(), "ModelAPI") + require.NotContains(t, err.Error(), "modelapi") + require.NotContains(t, err.Error(), "api.modelapi.co") +} diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index 729c0a11485..4a029a94eef 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -2,6 +2,7 @@ package modelapiseedance import ( "bytes" + "context" "fmt" "io" "net/http" @@ -89,15 +90,21 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) } func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + if info != nil && strings.TrimSpace(info.ChannelSetting.Proxy) != "" { + return nil, errModelAPISeedanceProxyUnsupported() + } return channel.DoTaskApiRequest(a, c, info, requestBody) } func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { - responseBody, err := io.ReadAll(resp.Body) + defer func() { _ = resp.Body.Close() }() + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxModelAPISubmitResponseBytes+1)) if err != nil { - return "", nil, taskError(err, "read_response_body_failed", http.StatusInternalServerError) + return "", nil, taskError(fmt.Errorf("failed to read upstream response"), "read_response_body_failed", http.StatusInternalServerError) + } + if len(responseBody) > maxModelAPISubmitResponseBytes { + return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) } - _ = resp.Body.Close() var submit modelAPISubmitResponse if err := common.Unmarshal(responseBody, &submit); err != nil { @@ -136,6 +143,16 @@ func (a *TaskAdaptor) GetChannelName() string { } func (a *TaskAdaptor) FetchTask(baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + return a.FetchTaskWithContext(context.Background(), baseURL, key, body, proxy) +} + +func (a *TaskAdaptor) FetchTaskWithContext(ctx context.Context, baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(proxy) != "" { + return nil, errModelAPISeedanceProxyUnsupported() + } taskID, ok := body["task_id"].(string) if !ok || strings.TrimSpace(taskID) == "" { return nil, fmt.Errorf("invalid task_id") @@ -144,7 +161,7 @@ func (a *TaskAdaptor) FetchTask(baseURL string, key string, body map[string]any, if baseURL == "" { baseURL = constant.ChannelBaseURLs[constant.ChannelTypeModelAPISeedance] } - req, err := http.NewRequest(http.MethodGet, baseURL+"/v1/tasks/"+url.PathEscape(taskID), nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/tasks/"+url.PathEscape(taskID), nil) if err != nil { return nil, err } @@ -157,6 +174,10 @@ func (a *TaskAdaptor) FetchTask(baseURL string, key string, body map[string]any, return client.Do(req) } +func errModelAPISeedanceProxyUnsupported() error { + return fmt.Errorf("this channel type does not support proxy") +} + func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { var result modelAPITaskResponse if err := common.Unmarshal(respBody, &result); err != nil { @@ -383,6 +404,9 @@ func validateModelAPISeedanceRequest(seedReq *dto.SeedanceVideoRequest) error { imageCount, videoCount, audioCount := 0, 0, 0 firstFrameCount, lastFrameCount := 0, 0 for _, m := range seedReq.Images() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } imageCount++ switch m.Role { case "", dto.SeedanceRoleReferenceImage: @@ -395,12 +419,18 @@ func validateModelAPISeedanceRequest(seedReq *dto.SeedanceVideoRequest) error { } } for _, m := range seedReq.Videos() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } videoCount++ if m.Role != "" && m.Role != dto.SeedanceRoleReferenceVideo { return fmt.Errorf("unsupported video role") } } for _, m := range seedReq.Audios() { + if err := validateModelAPIMediaURL(m.URL); err != nil { + return err + } audioCount++ if m.Role != "" && m.Role != dto.SeedanceRoleReferenceAudio { return fmt.Errorf("unsupported audio role") @@ -431,6 +461,13 @@ func validateModelAPISeedanceRequest(seedReq *dto.SeedanceVideoRequest) error { return nil } +func validateModelAPIMediaURL(raw string) error { + if err := taskcommon.ValidateRemoteMediaURL(raw); err != nil { + return fmt.Errorf("media url is not allowed") + } + return nil +} + func firstModelAPIVideoURL(assets []modelAPIAsset) string { for _, asset := range assets { if asset.Type == "video" && strings.TrimSpace(asset.URL) != "" { diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index 488255c9df3..bc31f46fcb4 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -1,11 +1,14 @@ package modelapiseedance import ( + "context" + "errors" "io" "net/http" "net/http/httptest" "strings" "testing" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" @@ -14,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) @@ -127,11 +131,11 @@ func TestBuildRequestBodyUsesModelAPIGroupedInputWireShape(t *testing.T) { "model":"client-model", "content":[ {"type":"text","text":"make it cinematic"}, - {"type":"image_url","image_url":{"url":"https://cdn.example/ref.png"},"role":"reference_image"}, - {"type":"image_url","image_url":{"url":"https://cdn.example/first.png"},"role":"first_frame"}, - {"type":"image_url","image_url":{"url":"https://cdn.example/last.png"},"role":"last_frame"}, - {"type":"video_url","video_url":{"url":"https://cdn.example/ref.mp4"}}, - {"type":"audio_url","audio_url":{"url":"https://cdn.example/ref.mp3"}} + {"type":"image_url","image_url":{"url":"https://example.com/ref.png"},"role":"reference_image"}, + {"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"}, + {"type":"image_url","image_url":{"url":"https://example.com/last.png"},"role":"last_frame"}, + {"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}, + {"type":"audio_url","audio_url":{"url":"https://example.com/ref.mp3"}} ] }`) reader, err := (&TaskAdaptor{}).BuildRequestBody(c, newModelAPIRelayInfo("", "")) @@ -158,15 +162,15 @@ func TestBuildRequestBodyUsesModelAPIGroupedInputWireShape(t *testing.T) { {"role": "prompt", "content": "make it cinematic"}, }) assertModelAPIWireItems(t, input, "image", []map[string]string{ - {"role": "reference", "url": "https://cdn.example/ref.png"}, - {"role": "first_frame", "url": "https://cdn.example/first.png"}, - {"role": "last_frame", "url": "https://cdn.example/last.png"}, + {"role": "reference", "url": "https://example.com/ref.png"}, + {"role": "first_frame", "url": "https://example.com/first.png"}, + {"role": "last_frame", "url": "https://example.com/last.png"}, }) assertModelAPIWireItems(t, input, "video", []map[string]string{ - {"role": "reference", "url": "https://cdn.example/ref.mp4"}, + {"role": "reference", "url": "https://example.com/ref.mp4"}, }) assertModelAPIWireItems(t, input, "audio", []map[string]string{ - {"role": "reference", "url": "https://cdn.example/ref.mp3"}, + {"role": "reference", "url": "https://example.com/ref.mp3"}, }) } @@ -208,7 +212,7 @@ func TestBuildRequestBodyOmitsEmptyModelAPIInputGroups(t *testing.T) { func TestBuildRequestBodyPreservesExplicitZeroFalseAndOmitsAbsentParams(t *testing.T) { c, _ := newModelAPITestContext(`{ "model":"client-model", - "content":[{"type":"text","text":"x"},{"type":"image_url","image_url":{"url":"https://x/i.png?a=1&b=2"}}], + "content":[{"type":"text","text":"x"},{"type":"image_url","image_url":{"url":"https://example.com/i.png?a=1&b=2"}}], "seed":0, "generate_audio":false, "watermark":false, @@ -277,9 +281,9 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { valid := dto.SeedanceVideoRequest{ Content: []dto.SeedanceContentItem{ {Type: dto.SeedanceContentText, Text: "x"}, - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/ref.png"}}, - {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/ref.mp4"}}, - {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/ref.mp3"}}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.png"}}, + {Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp4"}}, + {Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp3"}}, }, Duration: modelAPIPtrInt(4), Resolution: "480p", @@ -297,10 +301,10 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { {name: "duration high", req: dto.SeedanceVideoRequest{Duration: modelAPIPtrInt(31)}}, {name: "resolution", req: dto.SeedanceVideoRequest{Resolution: "1080p"}}, {name: "aspect", req: dto.SeedanceVideoRequest{Ratio: "3:2"}}, - {name: "image role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}, Role: "cover"}}}}, - {name: "video role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}, Role: "first_frame"}}}}, - {name: "audio role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}, Role: "narration"}}}}, - {name: "last without first", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last.png"}, Role: dto.SeedanceRoleLastFrame}}}}, + {name: "image role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}, Role: "cover"}}}}, + {name: "video role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}, Role: "first_frame"}}}}, + {name: "audio role", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}, Role: "narration"}}}}, + {name: "last without first", req: dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last.png"}, Role: dto.SeedanceRoleLastFrame}}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -312,7 +316,7 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { countReq := dto.SeedanceVideoRequest{} for i := 0; i < 31; i++ { - countReq.Content = append(countReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}}) + countReq.Content = append(countReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}}) } if err := validateModelAPISeedanceRequest(&countReq); err == nil { t.Fatal("expected image count error") @@ -320,7 +324,7 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { videoCountReq := dto.SeedanceVideoRequest{} for i := 0; i < 11; i++ { - videoCountReq.Content = append(videoCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}}) + videoCountReq.Content = append(videoCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}}) } if err := validateModelAPISeedanceRequest(&videoCountReq); err == nil { t.Fatal("expected video count error") @@ -328,7 +332,7 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { audioCountReq := dto.SeedanceVideoRequest{} for i := 0; i < 11; i++ { - audioCountReq.Content = append(audioCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}}) + audioCountReq.Content = append(audioCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}}) } if err := validateModelAPISeedanceRequest(&audioCountReq); err == nil { t.Fatal("expected audio count error") @@ -336,38 +340,120 @@ func TestValidateModelAPISeedanceValues(t *testing.T) { totalCountReq := dto.SeedanceVideoRequest{} for i := 0; i < 30; i++ { - totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/i.png"}}) + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/i.png"}}) } for i := 0; i < 10; i++ { - totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://x/v.mp4"}}) + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/v.mp4"}}) } for i := 0; i < 11; i++ { - totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://x/a.mp3"}}) + totalCountReq.Content = append(totalCountReq.Content, dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/a.mp3"}}) } if err := validateModelAPISeedanceRequest(&totalCountReq); err == nil { t.Fatal("expected total media count error") } firstFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first-a.png"}, Role: dto.SeedanceRoleFirstFrame}, - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first-b.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first-a.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first-b.png"}, Role: dto.SeedanceRoleFirstFrame}, }} if err := validateModelAPISeedanceRequest(&firstFrameReq); err == nil { t.Fatal("expected first_frame max-one error") } lastFrameReq := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{ - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/first.png"}, Role: dto.SeedanceRoleFirstFrame}, - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last-a.png"}, Role: dto.SeedanceRoleLastFrame}, - {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://x/last-b.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/first.png"}, Role: dto.SeedanceRoleFirstFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last-a.png"}, Role: dto.SeedanceRoleLastFrame}, + {Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/last-b.png"}, Role: dto.SeedanceRoleLastFrame}, }} if err := validateModelAPISeedanceRequest(&lastFrameReq); err == nil { t.Fatal("expected last_frame max-one error") } } +func TestValidateModelAPISeedanceRequestValidatesRemoteMediaURLs(t *testing.T) { + original := *system_setting.GetFetchSetting() + t.Cleanup(func() { *system_setting.GetFetchSetting() = original }) + system_setting.GetFetchSetting().EnableSSRFProtection = true + system_setting.GetFetchSetting().AllowPrivateIp = false + system_setting.GetFetchSetting().DomainFilterMode = false + system_setting.GetFetchSetting().IpFilterMode = false + system_setting.GetFetchSetting().AllowedPorts = []string{"80", "443"} + system_setting.GetFetchSetting().ApplyIPFilterForDomain = false + + tests := []struct { + name string + content dto.SeedanceContentItem + wantErr bool + }{ + { + name: "rejects private image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.png"}}, + wantErr: true, + }, + { + name: "rejects file image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.png"}}, + wantErr: true, + }, + { + name: "allows public image url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentImage, ImageURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.png"}}, + }, + { + name: "rejects private video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.mp4"}}, + wantErr: true, + }, + { + name: "rejects file video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.mp4"}}, + wantErr: true, + }, + { + name: "allows public video url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentVideo, VideoURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp4"}}, + }, + { + name: "rejects private audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "http://127.0.0.1/private.mp3"}}, + wantErr: true, + }, + { + name: "rejects file audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "file:///tmp/private.mp3"}}, + wantErr: true, + }, + { + name: "allows public audio url", + content: dto.SeedanceContentItem{Type: dto.SeedanceContentAudio, AudioURL: &dto.SeedanceURLObject{URL: "https://example.com/ref.mp3"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := dto.SeedanceVideoRequest{Content: []dto.SeedanceContentItem{tt.content}} + err := validateModelAPISeedanceRequest(&req) + if tt.wantErr { + if err == nil { + t.Fatal("expected validation error") + } + msg := err.Error() + for _, leaked := range []string{"127.0.0.1", "file:///tmp/private"} { + if strings.Contains(msg, leaked) { + t.Fatalf("validation error leaked media URL details: %q", msg) + } + } + return + } + if err != nil { + t.Fatalf("valid public URL rejected: %v", err) + } + }) + } +} + func TestValidateRequestAndSetActionAcceptsAudioOnlyAndSetsFixedUpstreamModel(t *testing.T) { - c, _ := newModelAPITestContext(`{"model":"client-model","content":[{"type":"audio_url","audio_url":{"url":"https://x/a.mp3"}}]}`) + c, _ := newModelAPITestContext(`{"model":"client-model","content":[{"type":"audio_url","audio_url":{"url":"https://example.com/a.mp3"}}]}`) info := newModelAPIRelayInfo("", "") a := &TaskAdaptor{} if taskErr := a.ValidateRequestAndSetAction(c, info); taskErr != nil { @@ -420,6 +506,52 @@ func TestBuildAndFetchPathsHeadersAndEscaping(t *testing.T) { } } +func TestDoRequestRejectsProxyWithoutUpstreamRequest(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + info := newModelAPIRelayInfo("", "secret") + info.ChannelSetting.Proxy = "http://proxy.internal:8080" + + resp, err := a.DoRequest(c, info, strings.NewReader(`{}`)) + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("DoRequest returned response with proxy configured") + } + if err == nil { + t.Fatal("expected proxy rejection") + } + if err.Error() != "this channel type does not support proxy" { + t.Fatalf("error = %q", err.Error()) + } + assertNoModelAPILeak(t, err.Error()) +} + +func TestFetchTaskRejectsProxyWithoutUpstreamRequest(t *testing.T) { + a := &TaskAdaptor{} + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + + resp, err := a.FetchTask(server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, "http://proxy.internal:8080") + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("FetchTask returned response with proxy configured") + } + if err == nil { + t.Fatal("expected proxy rejection") + } + if err.Error() != "this channel type does not support proxy" { + t.Fatalf("error = %q", err.Error()) + } + if requestCount != 0 { + t.Fatalf("FetchTask reached upstream %d times", requestCount) + } + assertNoModelAPILeak(t, err.Error()) +} + func TestInitFallsBackToDefaultBaseURL(t *testing.T) { a := &TaskAdaptor{} a.Init(newModelAPIRelayInfo("", "key")) @@ -474,6 +606,77 @@ func TestDoResponseParsesExactTaskIDAndRejectsIDOnly(t *testing.T) { } } +func TestDoResponseRejectsOversizedSubmitBodyWithoutReadingPastLimitOrLeaking(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + body := &countingReadCloser{Reader: strings.NewReader(strings.Repeat("x", (1<<20)+128) + " ModelAPI api.modelapi.co upstream-secret-id")} + resp := &http.Response{StatusCode: http.StatusOK, Body: body} + + taskID, taskData, taskErr := a.DoResponse(c, resp, newModelAPIRelayInfo("", "")) + if taskErr == nil { + t.Fatal("expected oversized submit response to be rejected") + } + if taskID != "" || taskData != nil { + t.Fatalf("oversized response returned taskID=%q taskData=%s", taskID, taskData) + } + if taskErr.Code != "invalid_response" || taskErr.StatusCode != http.StatusBadGateway { + t.Fatalf("taskErr = %+v, want invalid_response/502", taskErr) + } + if body.n > (1<<20)+1 { + t.Fatalf("DoResponse read %d bytes, want at most max+1", body.n) + } + assertNoModelAPILeak(t, taskErr.Message) +} + +func TestDoResponseClosesSubmitBodyOnReadError(t *testing.T) { + a := &TaskAdaptor{} + c, _ := newModelAPITestContext(`{}`) + body := &submitReadErrorCloser{err: errors.New("read ModelAPI api.modelapi.co upstream-secret-id failed")} + resp := &http.Response{StatusCode: http.StatusOK, Body: body} + + taskID, taskData, taskErr := a.DoResponse(c, resp, newModelAPIRelayInfo("", "")) + if taskErr == nil { + t.Fatal("expected read error") + } + if taskID != "" || taskData != nil { + t.Fatalf("read error returned taskID=%q taskData=%s", taskID, taskData) + } + if !body.closed { + t.Fatal("DoResponse did not close submit response body on read error") + } + if taskErr.Code != "read_response_body_failed" || taskErr.StatusCode != http.StatusInternalServerError { + t.Fatalf("taskErr = %+v, want read_response_body_failed/500", taskErr) + } + assertNoModelAPILeak(t, taskErr.Message) +} + +type countingReadCloser struct { + *strings.Reader + n int +} + +func (c *countingReadCloser) Read(p []byte) (int, error) { + n, err := c.Reader.Read(p) + c.n += n + return n, err +} + +func (c *countingReadCloser) Close() error { return nil } + +type submitReadErrorCloser struct { + err error + closed bool +} + +func (c *submitReadErrorCloser) Read([]byte) (int, error) { + return 0, c.err +} + +func (c *submitReadErrorCloser) Close() error { + c.closed = true + return nil +} + func TestDoResponseReturnsFailedStatusBeforeMissingTaskIDAndUsesErrorCodeFallback(t *testing.T) { a := &TaskAdaptor{} info := newModelAPIRelayInfo("", "") @@ -517,6 +720,41 @@ func TestDoResponseReturnsFailedStatusBeforeMissingTaskIDAndUsesErrorCodeFallbac assertNoModelAPILeak(t, taskErr.Message) } +func TestFetchTaskWithContextHonorsCanceledContext(t *testing.T) { + type contextTaskFetcher interface { + FetchTaskWithContext(context.Context, string, string, map[string]any, string) (*http.Response, error) + } + fetcher, ok := any(&TaskAdaptor{}).(contextTaskFetcher) + if !ok { + t.Fatal("ModelAPI Seedance adaptor does not support context-aware task polling") + } + + service.InitHttpClient() + requestStarted := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requestStarted) + <-r.Context().Done() + })) + defer server.Close() + t.Cleanup(service.ResetProxyClientCache) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + resp, err := fetcher.FetchTaskWithContext(ctx, server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, "") + if resp != nil { + _ = resp.Body.Close() + t.Fatalf("FetchTaskWithContext returned response for canceled context") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("FetchTaskWithContext error = %v, want context.Canceled", err) + } + select { + case <-requestStarted: + t.Fatal("canceled context still reached upstream server") + case <-time.After(50 * time.Millisecond): + } +} + func TestParseTaskResultStatusMappingsAndFailureScrub(t *testing.T) { a := &TaskAdaptor{} tests := []struct { diff --git a/relay/channel/task/modelapiseedance/constants.go b/relay/channel/task/modelapiseedance/constants.go index 0ab73d289dc..99164bce613 100644 --- a/relay/channel/task/modelapiseedance/constants.go +++ b/relay/channel/task/modelapiseedance/constants.go @@ -2,6 +2,7 @@ package modelapiseedance const ChannelName = "modelapi-seedance" const UpstreamModel = "doubao-seedance-2-5-260628" +const maxModelAPISubmitResponseBytes = 1 << 20 var ModelList = []string{ UpstreamModel, diff --git a/relay/relay_task.go b/relay/relay_task.go index 6fd83aef061..665fdf700f6 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -348,16 +348,29 @@ func applyTaskOtherRatios(priceData *types.PriceData) { } } +const ( + taskSubmitErrorResponseMaxBytes = 1 << 20 + taskSubmitErrorFallbackMessage = "upstream task submit failed" +) + func taskSubmitStatusError(platform constant.TaskPlatform, resp *http.Response) *dto.TaskError { statusCode := http.StatusInternalServerError if resp != nil { statusCode = resp.StatusCode } var responseBody []byte + var readErr error if resp != nil && resp.Body != nil { - responseBody, _ = io.ReadAll(resp.Body) + defer func() { _ = resp.Body.Close() }() + responseBody, readErr = io.ReadAll(io.LimitReader(resp.Body, taskSubmitErrorResponseMaxBytes+1)) + if len(responseBody) > taskSubmitErrorResponseMaxBytes { + responseBody = responseBody[:taskSubmitErrorResponseMaxBytes] + } } message := string(responseBody) + if readErr != nil || strings.TrimSpace(message) == "" { + message = taskSubmitErrorFallbackMessage + } if channelType, err := strconv.Atoi(string(platform)); err == nil && taskcommon.ShouldWhitelabelChannelType(channelType) { message = "task failed at upstream provider" } diff --git a/relay/relay_task_submit_error_test.go b/relay/relay_task_submit_error_test.go index fb108f5c41f..e1de983f280 100644 --- a/relay/relay_task_submit_error_test.go +++ b/relay/relay_task_submit_error_test.go @@ -1,6 +1,7 @@ package relay import ( + "errors" "io" "net/http" "strings" @@ -66,6 +67,143 @@ func TestTaskSubmitStatusErrorPreservesDoubaoBody(t *testing.T) { } } +func TestTaskSubmitStatusErrorScrubsModelAPICapacityNonOKBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(strings.NewReader( + `{"error":{"message":"Selected model is at capacity. Please try a different model.","task_id":"upstream-secret-id"}}`)), + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("111"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if taskErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("status = %d, want %d", taskErr.StatusCode, http.StatusTooManyRequests) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("message = %q, want fixed generic message", taskErr.Message) + } + for _, marker := range []string{"Selected model", "different model", "upstream-secret-id", "ModelAPI"} { + if strings.Contains(taskErrorText(taskErr), marker) { + t.Fatalf("non-200 submit error leaked %q in %q", marker, taskErrorText(taskErr)) + } + } +} + +func TestTaskSubmitStatusErrorClosesAndBoundsNonOKBody(t *testing.T) { + body := &countingSubmitErrorBody{Reader: strings.NewReader(strings.Repeat("x", (1<<20)+128) + " upstream-secret-id")} + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: body, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("111"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if !body.closed { + t.Fatal("taskSubmitStatusError did not close non-200 response body") + } + if body.n > (1<<20)+1 { + t.Fatalf("taskSubmitStatusError read %d bytes, want at most max+1", body.n) + } + if taskErr.Message != "task failed at upstream provider" { + t.Fatalf("message = %q, want fixed generic message", taskErr.Message) + } + if strings.Contains(taskErrorText(taskErr), "upstream-secret-id") { + t.Fatalf("oversized submit error leaked raw body in %q", taskErrorText(taskErr)) + } +} + +func TestTaskSubmitStatusErrorPreservesBoundedDoubaoOversizedBody(t *testing.T) { + const prefix = "doubao bounded prefix" + const tail = "doubao oversized tail" + bodyText := prefix + strings.Repeat("x", taskSubmitErrorResponseMaxBytes) + tail + body := &countingSubmitErrorBody{Reader: strings.NewReader(bodyText)} + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: body, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if !body.closed { + t.Fatal("taskSubmitStatusError did not close non-200 response body") + } + if body.n > taskSubmitErrorResponseMaxBytes+1 { + t.Fatalf("taskSubmitStatusError read %d bytes, want at most max+1", body.n) + } + if !strings.Contains(taskErr.Message, prefix) { + t.Fatalf("doubao submit error did not preserve bounded prefix: %q", taskErr.Message) + } + if strings.Contains(taskErr.Message, tail) { + t.Fatalf("doubao submit error leaked oversized tail in %q", taskErr.Message) + } +} + +func TestTaskSubmitStatusErrorUsesFallbackForEmptyBody(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(strings.NewReader("")), + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if strings.TrimSpace(taskErr.Message) == "" { + t.Fatal("expected non-empty fallback message for empty submit error body") + } +} + +func TestTaskSubmitStatusErrorUsesFallbackForReadError(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadGateway, + Body: &readErrorSubmitErrorBody{}, + } + + taskErr := taskSubmitStatusError(constant.TaskPlatform("52"), resp) + if taskErr == nil { + t.Fatal("expected task submit status error") + } + if strings.TrimSpace(taskErr.Message) == "" { + t.Fatal("expected non-empty fallback message for unreadable submit error body") + } +} + +type countingSubmitErrorBody struct { + *strings.Reader + n int + closed bool +} + +func (b *countingSubmitErrorBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + b.n += n + return n, err +} + +func (b *countingSubmitErrorBody) Close() error { + b.closed = true + return nil +} + +type readErrorSubmitErrorBody struct { + closed bool +} + +func (b *readErrorSubmitErrorBody) Read(_ []byte) (int, error) { + return 0, errors.New("read failed") +} + +func (b *readErrorSubmitErrorBody) Close() error { + b.closed = true + return nil +} + func taskErrorText(taskErr *dto.TaskError) string { if taskErr == nil { return "" diff --git a/service/task_polling.go b/service/task_polling.go index 895902c015d..05c3a728e35 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -58,6 +58,11 @@ var archiveModelAPIVideoResult = func(ctx context.Context, publicTaskID, upstrea return archiveVideoResultForChannel(ctx, "modelapi", publicTaskID, upstreamURL, proxy) } +const ( + modelAPIPollingRequestTimeout = 30 * time.Second + modelAPIPollingResponseMaxBytes = 1 << 20 +) + var archivedVideoLogURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) // sweepTimedOutTasks 在主轮询之前独立清理超时任务。 @@ -383,6 +388,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * logger.LogError(ctx, "Task not found in taskM") return errors.New("task not found") } + if ch.Type == constant.ChannelTypeModelAPISeedance && strings.TrimSpace(proxy) != "" { + return archivedVideoPollingPhaseError(task.TaskID, "fetch") + } key := ch.Key privateData := task.PrivateData @@ -390,7 +398,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * key = privateData.Key } upstreamTaskID := task.GetUpstreamTaskID() - resp, err := FetchTaskWithContext(ctx, adaptor, baseURL, key, map[string]any{ + pollingCtx := ctx + var cancelPolling context.CancelFunc + if ch.Type == constant.ChannelTypeModelAPISeedance { + pollingCtx, cancelPolling = context.WithTimeout(ctx, modelAPIPollingRequestTimeout) + defer cancelPolling() + } + resp, err := FetchTaskWithContext(pollingCtx, adaptor, baseURL, key, map[string]any{ "task_id": upstreamTaskID, "action": task.Action, }, proxy) @@ -401,7 +415,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * return fmt.Errorf("fetchTask failed for task %s: %w", task.TaskID, err) } defer resp.Body.Close() - responseBody, err := io.ReadAll(resp.Body) + responseBody, err := readVideoPollingResponseBody(pollingCtx, ch.Type, resp.Body) if err != nil { if VideoResultChannelLabel(ch.Type) != "" { return archivedVideoPollingPhaseError(task.TaskID, "read") @@ -420,7 +434,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch * taskResult := &relaycommon.TaskInfo{} // try parse as New API response format var responseItems dto.TaskResponse[model.Task] - if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { + if ch.Type != constant.ChannelTypeModelAPISeedance && common.Unmarshal(responseBody, &responseItems) == nil && responseItems.IsSuccess() { if VideoResultChannelLabel(ch.Type) != "" { logger.LogDebug(ctx, "updateVideoSingleTask parsed as new api response format: task_id=%s phase=parsed status=%s", task.TaskID, archivedVideoPollingStatus(string(responseItems.Data.Status))) } else { @@ -754,6 +768,30 @@ func archivedVideoPollingStatus(status string) string { } } +func readVideoPollingResponseBody(ctx context.Context, channelType int, body io.Reader) ([]byte, error) { + if channelType != constant.ChannelTypeModelAPISeedance { + return io.ReadAll(body) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + responseBody, err := io.ReadAll(io.LimitReader(body, modelAPIPollingResponseMaxBytes+1)) + if err != nil { + return nil, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if len(responseBody) > modelAPIPollingResponseMaxBytes { + return nil, errors.New("polling response too large") + } + return responseBody, nil +} + func sanitizeArchivedVideoLogText(channelType int, text string) string { if strings.TrimSpace(text) == "" { return "" diff --git a/service/task_polling_video_result_test.go b/service/task_polling_video_result_test.go index f07354f6188..38ed322af72 100644 --- a/service/task_polling_video_result_test.go +++ b/service/task_polling_video_result_test.go @@ -302,14 +302,14 @@ func TestUpdateVideoSingleTaskArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) require.Contains(t, text, `newapi_video_result_archive_retry_total{channel="techmobi",reason="archive_failure"} 1`) } -func TestUpdateVideoSingleTaskModelAPIArchivesAndSetsProxyURL(t *testing.T) { +func TestUpdateVideoSingleTaskModelAPIRejectsProxyBeforeFetchOrArchive(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) ctx := context.Background() seedUser(t, 910, 1000) seedToken(t, 920, 910, "sk-modelapi-archive-success", 500) - task := newModelAPIPollingTask(t, 910, 940, 100, 920) + task := newModelAPIPollingTaskWithID(t, "task_proxy_fail_closed", 910, 940, 100, 920) ch := newModelAPIPollingChannel("http://proxy.internal:8080") adaptor := &fakeVideoPollingAdaptor{ responseBody: modelAPIArchiveResponseBody(), @@ -322,41 +322,34 @@ func TestUpdateVideoSingleTaskModelAPIArchivesAndSetsProxyURL(t *testing.T) { }, actualQuota: 40, } - expected := &model.VideoResult{ - Bucket: "archive-bucket", - Object: "video-results/20260806/task_modelapi_success.mp4", - Generation: 12, - ContentType: "video/mp4", - Size: 2048, - StoredAt: time.Date(2026, 8, 6, 1, 2, 3, 0, time.UTC).Unix(), - ExpiresAt: time.Date(2026, 8, 7, 1, 2, 3, 0, time.UTC).Unix(), - } var archiveCalls int archiveModelAPIVideoResult = func(_ context.Context, publicTaskID, upstreamURL, proxy string) (*model.VideoResult, error) { archiveCalls++ - require.Equal(t, "task_modelapi_success", publicTaskID) - require.Equal(t, "https://secret.example/video.mp4?token=secret", upstreamURL) - require.Equal(t, "http://proxy.internal:8080", proxy) - require.EqualValues(t, model.TaskStatusInProgress, task.Status, "archive must run before final success status mutation") - require.Zero(t, task.FinishTime, "archive must run before final finish time mutation") - return expected, nil + return nil, errors.New("archive must not run") } err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) - require.NoError(t, err) - require.Equal(t, 1, archiveCalls) - require.Equal(t, 1, adaptor.adjustCalls) + require.Error(t, err) + require.Contains(t, err.Error(), "task_proxy_fail_closed") + require.Contains(t, err.Error(), "phase=fetch") + require.NotContains(t, err.Error(), "proxy.internal") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, archiveCalls) + require.Equal(t, 0, adaptor.fetchCalls) + require.Equal(t, 0, adaptor.parseCalls) + require.Equal(t, 0, adaptor.adjustCalls) var stored model.Task require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) - require.EqualValues(t, model.TaskStatusSuccess, stored.Status) - require.Equal(t, taskcommon.BuildProxyURL(task.TaskID), stored.PrivateData.ResultURL) - require.Equal(t, expected, stored.PrivateData.VideoResult) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) require.NotContains(t, string(stored.Data), "https://") require.NotContains(t, string(stored.Data), "api.modelapi.co") require.NotContains(t, strings.ToLower(string(stored.Data)), "modelapi") require.NotContains(t, string(stored.Data), "secret.example") - } func TestUpdateVideoSingleTaskModelAPIArchiveErrorDoesNotFinalizeOrSettle(t *testing.T) { @@ -516,6 +509,45 @@ func TestUpdateVideoSingleTaskModelAPIReadErrorDoesNotLeakUpstreamDetails(t *tes require.NotContains(t, err.Error(), "upstream-secret-id") } +func TestUpdateVideoSingleTaskModelAPIOverLimitBodyDoesNotPersistOrLeak(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 933, 1000) + seedToken(t, 933, 933, "sk-modelapi-read-limit", 500) + task := newModelAPIPollingTaskWithID(t, "task_read_limit", 933, 953, 100, 933) + ch := newModelAPIPollingChannel("") + secretBody := append(bytes.Repeat([]byte("a"), 1024*1024), []byte("https://api.modelapi.co/v1/tasks/upstream-secret-id")...) + adaptor := &fakeVideoPollingAdaptor{ + responseBody: secretBody, + taskResult: &relaycommon.TaskInfo{ + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + actualQuota: 40, + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_read_limit") + require.Contains(t, err.Error(), "phase=read") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Equal(t, json.RawMessage(`{"status":"processing"}`), stored.Data) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + func TestUpdateVideoSingleTaskModelAPIParseErrorDoesNotLeakUpstreamDetails(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -542,6 +574,99 @@ func TestUpdateVideoSingleTaskModelAPIParseErrorDoesNotLeakUpstreamDetails(t *te require.NotContains(t, err.Error(), "upstream-secret-id") } +func TestUpdateVideoSingleTaskModelAPISkipsGenericWrapperAndRequiresAdaptorParse(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 934, 1000) + seedToken(t, 934, 934, "sk-modelapi-wrapper-bypass", 500) + task := newModelAPIPollingTaskWithID(t, "task_wrapper_bypass", 934, 954, 100, 934) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + responseBody: []byte(`{ + "code":"success", + "data":{ + "task_id":"task_wrapper_bypass", + "status":"SUCCESS", + "fail_reason":"https://secret.example/forged.mp4?token=secret", + "progress":"100%" + } + }`), + parseErr: errors.New("parse ModelAPI https://api.modelapi.co/v1/tasks/upstream-secret-id failed"), + } + var archiveCalls int + archiveModelAPIVideoResult = func(context.Context, string, string, string) (*model.VideoResult, error) { + archiveCalls++ + return &model.VideoResult{ + Bucket: "archive-bucket", + Object: "video-results/20260806/task_wrapper_bypass.mp4", + ContentType: "video/mp4", + Size: 1, + }, nil + } + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Equal(t, 1, adaptor.parseCalls) + require.Equal(t, 0, archiveCalls) + require.Contains(t, err.Error(), "task_wrapper_bypass") + require.Contains(t, err.Error(), "phase=parse") + require.NotContains(t, err.Error(), "https://") + require.NotContains(t, err.Error(), "api.modelapi.co") + require.NotContains(t, strings.ToLower(err.Error()), "modelapi") + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + +func TestUpdateVideoSingleTaskModelAPIFetchAndReadUseHardDeadline(t *testing.T) { + truncate(t) + restoreArchiveHookForPollingTest(t) + ctx := context.Background() + + seedUser(t, 935, 1000) + seedToken(t, 935, 935, "sk-modelapi-deadline", 500) + task := newModelAPIPollingTaskWithID(t, "task_modelapi_deadline", 935, 955, 100, 935) + ch := newModelAPIPollingChannel("") + adaptor := &fakeVideoPollingAdaptor{ + taskResult: &relaycommon.TaskInfo{ + Status: model.TaskStatusSuccess, + Url: "https://secret.example/video.mp4?token=secret", + Progress: "100%", + }, + } + body := &deadlineAwareReadCloser{ctx: &adaptor.fetchCtx} + adaptor.body = body + + err := updateVideoSingleTask(ctx, adaptor, ch, task.GetUpstreamTaskID(), modelAPITaskMap(task)) + require.Error(t, err) + require.Contains(t, err.Error(), "task_modelapi_deadline") + require.Contains(t, err.Error(), "phase=read") + require.True(t, adaptor.fetchUsedContext) + require.NotNil(t, adaptor.fetchCtx) + deadline, ok := adaptor.fetchCtx.Deadline() + require.True(t, ok, "ModelAPI fetch must receive a hard deadline even with Background parent ctx") + require.WithinDuration(t, time.Now().Add(30*time.Second), deadline, time.Second) + require.True(t, body.sawDeadline, "ModelAPI body read must use the same deadline-bound context") + require.Equal(t, 0, adaptor.parseCalls) + require.Equal(t, 0, adaptor.adjustCalls) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusInProgress, stored.Status) + require.Equal(t, "50%", stored.Progress) + require.Zero(t, stored.FinishTime) + require.Empty(t, stored.PrivateData.ResultURL) + require.Nil(t, stored.PrivateData.VideoResult) +} + func TestUpdateVideoTasksModelAPIDoesNotLogChannelID(t *testing.T) { truncate(t) restoreArchiveHookForPollingTest(t) @@ -1216,18 +1341,23 @@ func techMobiFailureResponseBody() []byte { } type fakeVideoPollingAdaptor struct { - responseBody []byte - taskResult *relaycommon.TaskInfo - actualQuota int - adjustCalls int - fetchErr error - parseErr error - body io.ReadCloser + responseBody []byte + taskResult *relaycommon.TaskInfo + actualQuota int + adjustCalls int + fetchErr error + parseErr error + fetchCalls int + parseCalls int + body io.ReadCloser + fetchCtx context.Context + fetchUsedContext bool } func (a *fakeVideoPollingAdaptor) Init(*relaycommon.RelayInfo) {} func (a *fakeVideoPollingAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + a.fetchCalls++ if a.fetchErr != nil { return nil, a.fetchErr } @@ -1241,7 +1371,14 @@ func (a *fakeVideoPollingAdaptor) FetchTask(string, string, map[string]any, stri }, nil } +func (a *fakeVideoPollingAdaptor) FetchTaskWithContext(ctx context.Context, baseURL string, key string, body map[string]any, proxy string) (*http.Response, error) { + a.fetchCtx = ctx + a.fetchUsedContext = true + return a.FetchTask(baseURL, key, body, proxy) +} + func (a *fakeVideoPollingAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + a.parseCalls++ if a.parseErr != nil { return nil, a.parseErr } @@ -1264,3 +1401,21 @@ func (r errReadCloser) Read([]byte) (int, error) { func (r errReadCloser) Close() error { return nil } + +type deadlineAwareReadCloser struct { + ctx *context.Context + sawDeadline bool +} + +func (r *deadlineAwareReadCloser) Read([]byte) (int, error) { + if r.ctx != nil && *r.ctx != nil { + if _, ok := (*r.ctx).Deadline(); ok { + r.sawDeadline = true + } + } + return 0, errors.New("forced read error") +} + +func (r *deadlineAwareReadCloser) Close() error { + return nil +} diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index 43dcbd67d6c..ca0b76b6e7b 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -1754,7 +1754,7 @@ const EditChannelModal = (props) => { const channelExtraSettings = { force_format: localInputs.force_format || false, thinking_to_content: localInputs.thinking_to_content || false, - proxy: localInputs.proxy || '', + proxy: localInputs.type === 111 ? '' : localInputs.proxy || '', pass_through_body_enabled: localInputs.pass_through_body_enabled || false, system_prompt: localInputs.system_prompt || '', system_prompt_override: localInputs.system_prompt_override || false, @@ -2553,7 +2553,9 @@ const EditChannelModal = (props) => { handleChannelSettingsChange('thinking_to_content', value)} extraText={t('将 reasoning_content 转换为 标签拼接到内容中')} /> handleChannelSettingsChange('pass_through_body_enabled', value)} extraText={t('启用请求体透传功能')} /> - handleChannelSettingsChange('proxy', value)} showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> + {inputs.type !== 111 && ( + handleChannelSettingsChange('proxy', value)} showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> + )} handleChannelSettingsChange('system_prompt', value)} autosize showClear extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> handleChannelSettingsChange('system_prompt_override', value)} extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')} /> diff --git a/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js index 2f0114d2437..9a29d3abe38 100644 --- a/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js +++ b/web/classic/src/components/table/channels/modals/modelapi-seedance-classic.test.js @@ -44,4 +44,22 @@ describe('ModelAPISeedance classic channel metadata', () => { /case 111:[\s\S]*?return ;/, ); }); + + test('clears proxy in type 111 submit payloads', () => { + expect(editChannelModalSource).toContain( + "proxy: localInputs.type === 111 ? '' : localInputs.proxy || '',", + ); + }); + + test('hides proxy input for type 111 channels', () => { + const guardIndex = editChannelModalSource.indexOf( + '{inputs.type !== 111 && (', + ); + const proxyFieldIndex = editChannelModalSource.indexOf( + "field='proxy'", + guardIndex, + ); + expect(guardIndex).toBeGreaterThan(-1); + expect(proxyFieldIndex).toBeGreaterThan(guardIndex); + }); }); diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index c6182422ed9..6568e9874f1 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -142,6 +142,7 @@ import { findMissingModelsInMapping, validateModelMappingJson, hasAdvancedSettingsErrors, + hasAdvancedSettingsValues, } from '../../lib' import { collectInvalidStatusCodeEntries, @@ -204,29 +205,6 @@ function readAdvancedSettingsPreference(): boolean { return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' } -function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { - return Boolean( - values.param_override?.trim() || - values.header_override?.trim() || - values.status_code_mapping?.trim() || - values.tag?.trim() || - values.remark?.trim() || - values.priority || - values.weight || - values.max_concurrency || - values.proxy?.trim() || - values.system_prompt?.trim() || - values.force_format || - values.thinking_to_content || - values.pass_through_body_enabled || - values.system_prompt_override || - values.claude_beta_query || - values.upstream_model_update_check_enabled || - values.upstream_model_update_auto_sync_enabled || - values.upstream_model_update_ignored_models?.trim() - ) -} - function parseSettingsRecord( settings: string | undefined ): Record { @@ -3234,27 +3212,31 @@ export function ChannelMutateDrawer({ /> - ( - - {t('Proxy Address')} - - - - - {t( - 'Network proxy for this channel (supports socks5 protocol)' - )} - - - - )} - /> + {currentType !== 111 && ( + ( + + {t('Proxy Address')} + + + + + {t( + 'Network proxy for this channel (supports socks5 protocol)' + )} + + + + )} + /> + )} { }) }) }) + +describe('ModelAPI Seedance proxy guard', () => { + test('clears proxy from type 111 create and update payloads', () => { + const formValues = { + ...CHANNEL_FORM_DEFAULT_VALUES, + type: 111, + proxy: 'socks5://127.0.0.1:1080', + } + + const createPayload = transformFormDataToCreatePayload(formValues) + expect(JSON.parse(createPayload.channel.setting || '{}').proxy).toBe('') + + const updatePayload = transformFormDataToUpdatePayload(formValues, 111) + expect(JSON.parse(updatePayload.setting || '{}').proxy).toBe('') + }) + + test('ignores legacy type 111 proxy values when deciding advanced defaults', () => { + expect( + hasAdvancedSettingsValues({ + ...CHANNEL_FORM_DEFAULT_VALUES, + type: 111, + proxy: 'socks5://127.0.0.1:1080', + }) + ).toBe(false) + }) +}) diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 614bdf260fe..f4c2696a5c8 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -464,7 +464,7 @@ function buildSettingJSON(formData: ChannelFormValues): string { const settingObj = { force_format: formData.force_format || false, thinking_to_content: formData.thinking_to_content || false, - proxy: formData.proxy || '', + proxy: formData.type === 111 ? '' : formData.proxy || '', pass_through_body_enabled: formData.pass_through_body_enabled || false, system_prompt: formData.system_prompt || '', system_prompt_override: formData.system_prompt_override || false, @@ -473,6 +473,29 @@ function buildSettingJSON(formData: ChannelFormValues): string { return JSON.stringify(settingObj) } +export function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { + return Boolean( + values.param_override?.trim() || + values.header_override?.trim() || + values.status_code_mapping?.trim() || + values.tag?.trim() || + values.remark?.trim() || + values.priority || + values.weight || + values.max_concurrency || + (values.type !== 111 && values.proxy?.trim()) || + values.system_prompt?.trim() || + values.force_format || + values.thinking_to_content || + values.pass_through_body_enabled || + values.system_prompt_override || + values.claude_beta_query || + values.upstream_model_update_check_enabled || + values.upstream_model_update_auto_sync_enabled || + values.upstream_model_update_ignored_models?.trim() + ) +} + /** * Build the settings JSON string (for type-specific config like vertex_key_type) */ From aa6060662e36f8e39c3cef5ba22bfdfa816ed5d0 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:30:46 +0800 Subject: [PATCH 20/32] Keep whitespace proxy settings off ModelAPI Seedance runtime paths Constraint: type 111 must fail closed on real proxy settings while treating whitespace as unset Rejected: mutating shared RelayInfo in place | would create request-scope side effects Confidence: high Scope-risk: narrow Directive: preserve proxy-free submit and poll paths for ModelAPI Seedance channels Tested: go test ./relay/channel/task/modelapiseedance -count=1; go test ./service -run 'ModelAPI|VideoResult|Archive|Sign' -count=1; go test ./controller -run 'TestValidateChannelRejectsModelAPISeedanceProxy|TestValidateChannelRejectsInvalidMaxConcurrency' -count=1; bun test src/features/channels/lib/channel-form.test.ts; bun test src/components/table/channels/modals/modelapi-seedance-classic.test.js; git diff --check --- .../channel/task/modelapiseedance/adaptor.go | 19 +++++-- .../task/modelapiseedance/adaptor_test.go | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index 4a029a94eef..b3078985345 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -90,8 +90,20 @@ func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) } func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { - if info != nil && strings.TrimSpace(info.ChannelSetting.Proxy) != "" { - return nil, errModelAPISeedanceProxyUnsupported() + if info != nil { + proxy := strings.TrimSpace(info.ChannelSetting.Proxy) + if proxy != "" { + return nil, errModelAPISeedanceProxyUnsupported() + } + if info.ChannelSetting.Proxy != "" { + scopedInfo := *info + if info.ChannelMeta != nil { + scopedMeta := *info.ChannelMeta + scopedMeta.ChannelSetting.Proxy = "" + scopedInfo.ChannelMeta = &scopedMeta + } + return channel.DoTaskApiRequest(a, c, &scopedInfo, requestBody) + } } return channel.DoTaskApiRequest(a, c, info, requestBody) } @@ -150,7 +162,8 @@ func (a *TaskAdaptor) FetchTaskWithContext(ctx context.Context, baseURL string, if ctx == nil { ctx = context.Background() } - if strings.TrimSpace(proxy) != "" { + proxy = strings.TrimSpace(proxy) + if proxy != "" { return nil, errModelAPISeedanceProxyUnsupported() } taskID, ok := body["task_id"].(string) diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index bc31f46fcb4..a60ca141d52 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -526,6 +526,36 @@ func TestDoRequestRejectsProxyWithoutUpstreamRequest(t *testing.T) { assertNoModelAPILeak(t, err.Error()) } +func TestDoRequestTreatsWhitespaceProxyAsEmpty(t *testing.T) { + service.InitHttpClient() + t.Cleanup(service.ResetProxyClientCache) + + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"pending"}`)) + })) + defer server.Close() + + a := &TaskAdaptor{} + info := newModelAPIRelayInfo(server.URL, "secret") + info.ChannelSetting.Proxy = " \t\n " + a.Init(info) + c, _ := newModelAPITestContext(`{}`) + + resp, err := a.DoRequest(c, info, strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("DoRequest rejected whitespace proxy: %v", err) + } + _ = resp.Body.Close() + if requestCount != 1 { + t.Fatalf("DoRequest reached upstream %d times, want 1", requestCount) + } + if info.ChannelSetting.Proxy != " \t\n " { + t.Fatalf("DoRequest mutated RelayInfo proxy to %q", info.ChannelSetting.Proxy) + } +} + func TestFetchTaskRejectsProxyWithoutUpstreamRequest(t *testing.T) { a := &TaskAdaptor{} var requestCount int @@ -552,6 +582,28 @@ func TestFetchTaskRejectsProxyWithoutUpstreamRequest(t *testing.T) { assertNoModelAPILeak(t, err.Error()) } +func TestFetchTaskWithContextTreatsWhitespaceProxyAsEmpty(t *testing.T) { + service.InitHttpClient() + t.Cleanup(service.ResetProxyClientCache) + + a := &TaskAdaptor{} + var requestCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _, _ = w.Write([]byte(`{"task_id":"ok","status":"running"}`)) + })) + defer server.Close() + + resp, err := a.FetchTaskWithContext(context.Background(), server.URL, "fetch-key", map[string]any{"task_id": "task-1"}, " \t\n ") + if err != nil { + t.Fatalf("FetchTaskWithContext rejected whitespace proxy: %v", err) + } + _ = resp.Body.Close() + if requestCount != 1 { + t.Fatalf("FetchTaskWithContext reached upstream %d times, want 1", requestCount) + } +} + func TestInitFallsBackToDefaultBaseURL(t *testing.T) { a := &TaskAdaptor{} a.Init(newModelAPIRelayInfo("", "key")) From 1ffe88249aced043ac45a7a3344c13f9a0040161 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:56:37 +0800 Subject: [PATCH 21/32] Restrict ModelAPI Seedance to Flatkey video routes Constraint: Channel type 111 must remain internally backed by ModelAPI /v1/tasks while exposing only Flatkey /v1/videos submit and fetch routes. Rejected: Public route-specific hacks or adding /v1/tasks | would broaden the public API and bypass the shared relay contract. Confidence: high Scope-risk: narrow Directive: Keep type 111 public access limited to POST /v1/videos and GET /v1/videos/:task_id; legacy task routes must stay available for other platforms. Tested: go test ./relay/channel/task/modelapiseedance -run TestValidateRequestAfterModelMappingRestrictsPublicSubmitEntrypoints -count=1; go test ./relay -run TestModelAPISeedancePrepareTaskAttemptRejectsLegacySubmitPathBeforePricing|TestModelAPISeedanceFetchRejectsLegacyRoutesAfterTaskLookup|TestNonModelAPISeedanceLegacyFetchRoutesRemainUsable -count=1; go test ./relay/channel/task/modelapiseedance -count=1; go test ./relay -count=1; git diff --check Not-tested: Full repository test suite --- .../channel/task/modelapiseedance/adaptor.go | 7 + .../task/modelapiseedance/adaptor_test.go | 47 +++++ relay/relay_task.go | 11 ++ relay/relay_task_usage_test.go | 181 ++++++++++++++++++ 4 files changed, 246 insertions(+) diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index b3078985345..bdec8dcc9de 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -62,6 +62,13 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom return nil } +func (a *TaskAdaptor) ValidateRequestAfterModelMapping(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + if c == nil || c.Request == nil || c.Request.URL == nil || c.Request.Method != http.MethodPost || c.Request.URL.Path != "/v1/videos" { + return taskError(fmt.Errorf("this channel type is only available on /v1/videos"), "invalid_request", http.StatusBadRequest) + } + return a.ValidateRequestAndSetAction(c, info) +} + func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { return a.baseURL + "/v1/tasks", nil } diff --git a/relay/channel/task/modelapiseedance/adaptor_test.go b/relay/channel/task/modelapiseedance/adaptor_test.go index a60ca141d52..d53f17b6317 100644 --- a/relay/channel/task/modelapiseedance/adaptor_test.go +++ b/relay/channel/task/modelapiseedance/adaptor_test.go @@ -467,6 +467,53 @@ func TestValidateRequestAndSetActionAcceptsAudioOnlyAndSetsFixedUpstreamModel(t } } +func TestValidateRequestAfterModelMappingRestrictsPublicSubmitEntrypoints(t *testing.T) { + type postMappingValidator interface { + ValidateRequestAfterModelMapping(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError + } + validator, ok := any(&TaskAdaptor{}).(postMappingValidator) + if !ok { + t.Fatal("TaskAdaptor must validate after model mapping") + } + + validBody := `{"model":"client-model","content":[{"type":"text","text":"hello"}]}` + for _, path := range []string{"/v1/video/generations", "/v1/generation/tasks"} { + t.Run("rejects "+path, func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + c.Request.URL.Path = path + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil { + t.Fatal("legacy submit path was accepted") + } + if err.StatusCode != http.StatusBadRequest || err.Code != "invalid_request" { + t.Fatalf("error = %+v, want invalid_request 400", err) + } + }) + } + + t.Run("rejects missing URL without panic", func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + c.Request.URL = nil + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil || err.Code != "invalid_request" || err.StatusCode != http.StatusBadRequest { + t.Fatalf("error = %+v, want invalid_request 400", err) + } + }) + + t.Run("allows /v1/videos and preserves payload validation", func(t *testing.T) { + c, _ := newModelAPITestContext(validBody) + if err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")); err != nil { + t.Fatalf("/v1/videos rejected: %+v", err) + } + + c, _ = newModelAPITestContext(`{"model":"client-model","duration":31,"content":[{"type":"text","text":"hello"}]}`) + err := validator.ValidateRequestAfterModelMapping(c, newModelAPIRelayInfo("", "")) + if err == nil || err.Code != "invalid_request" || !strings.Contains(err.Message, "duration") { + t.Fatalf("invalid payload error = %+v, want duration invalid_request", err) + } + }) +} + func TestBuildAndFetchPathsHeadersAndEscaping(t *testing.T) { service.InitHttpClient() a := &TaskAdaptor{} diff --git a/relay/relay_task.go b/relay/relay_task.go index 665fdf700f6..e20cb4e501a 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -499,6 +499,10 @@ func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *d isOpenAIVideoAPI := isOpenAIVideoFetchPath(c.Request.URL.Path) isVideoToMusicAPI := isVideoToMusicFetchPath(c.Request.URL.Path) isGenerationTasksAPI := isGenerationTasksFetchPath(c.Request.URL.Path) + if isModelAPISeedanceTask(originTask) && !isOpenAIVideoAPI { + taskResp = service.TaskErrorWrapperLocal(errors.New("task is not available on this endpoint"), "invalid_request", http.StatusBadRequest) + return + } // Gemini/Vertex 支持实时查询:用户 fetch 时直接从上游拉取最新状态 if realtimeResp := tryRealtimeFetch(originTask, isOpenAIVideoAPI || isGenerationTasksAPI); len(realtimeResp) > 0 { @@ -578,6 +582,13 @@ func isGenerationTasksFetchPath(path string) bool { return strings.HasPrefix(path, "/v1/generation/tasks/") } +func isModelAPISeedanceTask(task *model.Task) bool { + if task == nil { + return false + } + return task.Platform == constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance)) +} + type generationTaskVideoURL struct { URL string `json:"url"` } diff --git a/relay/relay_task_usage_test.go b/relay/relay_task_usage_test.go index 1ac3c416c81..dc4f272920c 100644 --- a/relay/relay_task_usage_test.go +++ b/relay/relay_task_usage_test.go @@ -1,11 +1,21 @@ package relay import ( + "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" ) func TestInjectUsageFromPrivateData(t *testing.T) { @@ -165,3 +175,174 @@ func TestGenerationTaskRespBodyFailureScrubsError(t *testing.T) { t.Fatalf("error = %+v", got.Error) } } + +func TestModelAPISeedancePrepareTaskAttemptRejectsLegacySubmitPathBeforePricing(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", strings.NewReader( + `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}]}`, + )) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("platform", strconv.Itoa(constant.ChannelTypeModelAPISeedance)) + common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeModelAPISeedance) + common.SetContextKey(c, constant.ContextKeyChannelKey, "test-key") + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://api.modelapi.co") + + _, taskErr := PrepareTaskAttempt(c, &relaycommon.RelayInfo{ + OriginModelName: "doubao-seedance-2-5-260628", + UsingGroup: "default", + UserGroup: "default", + TaskRelayInfo: &relaycommon.TaskRelayInfo{}, + }) + if taskErr == nil { + t.Fatal("legacy submit path was accepted") + } + if taskErr.Code != "invalid_request" || taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("taskErr = %+v, want invalid_request 400", taskErr) + } +} + +func TestModelAPISeedanceFetchRejectsLegacyRoutesAfterTaskLookup(t *testing.T) { + setupRelayTaskTestDB(t) + seedRelayTask(t, &model.Task{ + TaskID: "task_modelapi", + UserId: 123, + Platform: constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeModelAPISeedance)), + ChannelId: 111, + Status: model.TaskStatusSuccess, + FailReason: "https://api.modelapi.co/v1/tasks/upstream-secret", + Properties: model.Properties{OriginModelName: "doubao-seedance-2-5-260628", UpstreamModelName: "modelapi-secret-model"}, + PrivateData: model.TaskPrivateData{ + UpstreamTaskID: "upstream-secret", + ResultURL: "https://flatkey.example/v1/videos/task_modelapi/content", + }, + Data: []byte(`{"task_id":"upstream-secret","status":"succeeded"}`), + }) + + for _, path := range []string{ + "/v1/video/generations/task_modelapi", + "/v1/generation/tasks/task_modelapi", + } { + t.Run(path, func(t *testing.T) { + _, taskErr := fetchTaskByPath(t, path, "task_modelapi") + if taskErr == nil { + t.Fatal("legacy fetch path was accepted") + } + if taskErr.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", taskErr.StatusCode) + } + text := taskErr.Message + if taskErr.Error != nil { + text += " " + taskErr.Error.Error() + } + for _, marker := range []string{"ModelAPI", "modelapi", "api.modelapi.co", "upstream-secret", "private/result.mp4", "modelapi-secret-model"} { + if strings.Contains(text, marker) { + t.Fatalf("legacy fetch error leaked %q in %q", marker, text) + } + } + }) + } + + t.Run("allows standard OpenAI video fetch", func(t *testing.T) { + body, taskErr := fetchTaskByPath(t, "/v1/videos/task_modelapi", "task_modelapi") + if taskErr != nil { + t.Fatalf("standard fetch rejected: %+v", taskErr) + } + var got dto.OpenAIVideo + if err := common.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal OpenAI video response: %v", err) + } + if got.ID != "task_modelapi" || got.Metadata["url"] != "https://flatkey.example/v1/videos/task_modelapi/content" { + t.Fatalf("OpenAI video response = %+v", got) + } + for _, marker := range []string{"ModelAPI", "api.modelapi.co", "upstream-secret", "private/result.mp4", "modelapi-secret-model"} { + if strings.Contains(string(body), marker) { + t.Fatalf("standard fetch response leaked %q in %s", marker, body) + } + } + }) +} + +func TestNonModelAPISeedanceLegacyFetchRoutesRemainUsable(t *testing.T) { + setupRelayTaskTestDB(t) + seedRelayTask(t, &model.Task{ + TaskID: "task_doubao", + UserId: 123, + Platform: constant.TaskPlatform(strconv.Itoa(constant.ChannelTypeDoubaoVideo)), + ChannelId: constant.ChannelTypeDoubaoVideo, + Status: model.TaskStatusSuccess, + PrivateData: model.TaskPrivateData{ + ResultURL: "https://example.com/result.mp4", + }, + }) + + body, taskErr := fetchTaskByPath(t, "/v1/generation/tasks/task_doubao", "task_doubao") + if taskErr != nil { + t.Fatalf("non-111 generation task fetch rejected: %+v", taskErr) + } + var generation gotGenerationTaskResponse + if err := common.Unmarshal(body, &generation); err != nil { + t.Fatalf("unmarshal generation response: %v", err) + } + if generation.ID != "task_doubao" || generation.Status != "succeeded" { + t.Fatalf("generation response = %+v", generation) + } + + body, taskErr = fetchTaskByPath(t, "/v1/video/generations/task_doubao", "task_doubao") + if taskErr != nil { + t.Fatalf("non-111 generic fetch rejected: %+v", taskErr) + } + var generic dto.TaskResponse[any] + if err := common.Unmarshal(body, &generic); err != nil { + t.Fatalf("unmarshal generic response: %v", err) + } + if generic.Code != "success" || generic.Data == nil { + t.Fatalf("generic response = %+v", generic) + } +} + +type gotGenerationTaskResponse struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func setupRelayTaskTestDB(t *testing.T) { + t.Helper() + oldDB := model.DB + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + model.DB = db + t.Cleanup(func() { + model.DB = oldDB + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&model.Task{}, &model.Channel{}); err != nil { + t.Fatalf("migrate tasks: %v", err) + } +} + +func seedRelayTask(t *testing.T, task *model.Task) { + t.Helper() + if err := model.DB.Create(task).Error; err != nil { + t.Fatalf("seed task %s: %v", task.TaskID, err) + } +} + +func fetchTaskByPath(t *testing.T, path string, taskID string) ([]byte, *dto.TaskError) { + t.Helper() + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Set("id", 123) + c.Params = gin.Params{{Key: "task_id", Value: taskID}, {Key: "id", Value: taskID}} + + taskErr := RelayTaskFetch(c, relayconstant.RelayModeVideoFetchByID) + return w.Body.Bytes(), taskErr +} From 39b6605dabe5e0fdd7a8f9eee9ca5029462b014d Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:33:31 +0800 Subject: [PATCH 22/32] Document the approved Seedance 2.5 billing contract Constraint: Reuse the existing task billing hooks and the upstream task-price snapshot. Rejected: Synthetic public model tiers | They would leak billing choices into routing. Confidence: high Scope-risk: narrow Directive: Keep the public model and response contracts unchanged while correcting non-null submit estimates. Tested: git diff --check; GitNexus targeted impact LOW; origin/main change scan MEDIUM Not-tested: No runtime code changed in this commit. --- ...-11-modelapi-seedance-25-billing-design.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md diff --git a/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md new file mode 100644 index 00000000000..3c01cdc6616 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md @@ -0,0 +1,124 @@ +# ModelAPI Seedance 2.5 Billing Design + +## Goal + +Reuse the existing asynchronous task billing hooks so `doubao-seedance-2-5-260628` is precharged before submission and corrected from the upstream task-price snapshot immediately after a successful submit response. + +The public model name and `/v1/videos` contract remain unchanged. Billing must account for both pricing dimensions documented by the upstream: video-input presence and output resolution. + +## Official pricing contract + +Source checked on 2026-08-11: + +| Video input | Resolution | Price formula | +| --- | --- | --- | +| No | `480p` | `$0.140 * duration` | +| No | `720p` | `$0.314 * duration` | +| Yes | `480p` | `$0.084 * total_video_duration` | +| Yes | `720p` | `$0.188 * total_video_duration` | + +The documented request defaults are `duration=5` and `resolution=720p`; supported duration is 4 through 30 seconds. A successful `POST /v1/tasks` response may include `usage.estimated_usd` as either a number or `null`. The pricing note says actual billing uses the task pricing snapshot and may include valid operational or user discounts. + +The ModelAPI document does not define how it derives `total_video_duration` when media duration is omitted. Flatkey therefore must not claim that a locally calculated video-input amount is exact. + +## Selected design + +Use the existing three-stage task billing seam: + +1. `EstimateBilling` calculates a safe request-time reservation expressed as a multiplier over a `$0.14` base `ModelPrice`. +2. `DoResponse` persists a valid, positive, finite `usage.estimated_usd` inside private task submission data. +3. `AdjustBillingOnSubmit` converts the upstream dollar estimate back into billing units and lets the existing relay settlement path reconcile the reservation immediately. + +The default model price is `$0.14`. This is a calculation base, not a claim that every request costs `$0.14`. + +One `OtherRatios` entry represents the complete billable-unit multiplier: + +```text +billable_units = estimated_usd / model_price +quota = model_price * billable_units * group_ratio * quota_per_unit +``` + +Using one complete multiplier avoids compounding or rounding drift across separate duration, resolution, and video-input ratios. + +## Request-time reservation + +For requests without video input, the amount is fully determined from public request fields: + +```text +480p: 0.140 * resolved_duration +720p: 0.314 * resolved_duration +``` + +Missing duration resolves to 5. Missing resolution resolves to `720p`. + +For requests with video input, Flatkey cannot reliably inspect arbitrary remote media or reproduce the upstream definition of `total_video_duration`. The fallback reservation uses the maximum supported request duration, 30 seconds, at the matching video-input rate: + +```text +480p: 0.084 * 30 +720p: 0.188 * 30 +``` + +This is a bounded reservation fallback, not an asserted final price. A non-null upstream `estimated_usd` replaces it during submit settlement. If the upstream returns `null`, the reservation remains in place rather than guessing a lower amount or making the task free. + +## Submit-response correction + +`modelAPISubmitResponse` gains a typed usage object. `DoResponse` accepts `estimated_usd` only when it is positive and finite. It persists only the status and normalized numeric estimate required for billing; the public response remains the Flatkey task object and does not expose supplier billing metadata. + +`AdjustBillingOnSubmit` reads the private submission snapshot and returns a replacement `OtherRatios` map when all of the following are true: + +- task data is valid JSON; +- `estimated_usd` exists and is positive and finite; +- fixed-price billing is active; +- `ModelPrice` is positive and finite. + +Otherwise it returns no adjustment, preserving the request-time reservation. + +## Fail-closed price configuration + +This channel must use fixed-price billing. A channel-specific `ValidateTaskPriceData` rejects ratio fallback, zero or negative model prices, and non-finite values. This prevents an administrator setting or permissive unset-ratio mode from silently producing free or nonsensical video jobs. + +The existing group-ratio, wallet, and subscription paths remain authoritative. The adapter supplies only billable units; it does not bypass `PreConsumeBilling`, `SettleBilling`, subscription weighting, refunds, or persisted `TaskBillingContext` snapshots. + +## Alternatives considered + +### Four synthetic public model names + +Rejected because callers must continue using one official model name. Encoding price tiers into model aliases would leak billing implementation into routing and make fallback between Seedance channels unsafe. + +### Billing-expression tiers + +Rejected for this channel because the upstream already returns a per-task price snapshot and the public request does not contain a trustworthy `total_video_duration`. A new expression and metadata pipeline would duplicate the existing task billing hooks without improving accuracy. + +### Local media probing + +Rejected because fetching customer-controlled media during preflight expands SSRF, latency, bandwidth, and timeout risk. It still would not guarantee parity with the upstream's duration calculation. + +## Multi-node behavior + +No process-local state is introduced. Request parsing remains scoped to the Gin request context; the reservation and corrected billing values flow through the existing billing session and are persisted in the task billing snapshot. Every router instance performs the same deterministic calculation from the request and submit response. + +## Error and privacy behavior + +- Invalid or missing pricing configuration fails before upstream submission. +- Invalid or null `estimated_usd` does not fail an otherwise valid task; it retains the reservation. +- Upstream supplier names, hosts, internal task IDs, raw responses, and private asset URLs remain absent from public responses. +- No new client-visible endpoint or response field is introduced. + +## Test contract + +Tests must cover: + +- default `5s/720p` no-video reservation; +- explicit duration for no-video `480p` and `720p` requests; +- video-input fallback reservations for `480p` and `720p`; +- resolution and video-input detection through the shared Seedance request parser; +- valid `usage.estimated_usd` persistence and submit-time correction; +- `null`, missing, zero, negative, malformed, and non-finite estimate fallback behavior; +- fixed-price validation, including ratio fallback and invalid model prices; +- the default `$0.14` model-price entry; +- group-ratio preservation and the existing wallet/subscription settlement paths; +- no public response or persisted public task data leakage. + +## Deployment impact + +`Router deploy: required` because the change affects `/v1/videos` relay precharge and settlement. `newapi-console` also needs the same backend build so pricing configuration and model metadata stay consistent across the production split. `newapi-web`, Terraform, Cloudflare, and the decommissioned legacy service are not involved. From 94b3cfdf66659923fbedfc91f52c4edaa83dc259 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:46:29 +0800 Subject: [PATCH 23/32] Turn the approved Seedance billing design into an executable delivery path Constraint: Implementation must reuse the existing asynchronous task billing and settlement hooks. Rejected: Ad-hoc inline implementation | It would lose the required RED-GREEN and per-task review evidence. Confidence: high Scope-risk: narrow Directive: Execute each task with spec review before code-quality review. Tested: Placeholder scan; git diff --cached --check Not-tested: Plan commands are exercised by subsequent implementation tasks --- ...2026-08-11-modelapi-seedance-25-billing.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md diff --git a/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md new file mode 100644 index 00000000000..ff40e7fb47a --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-modelapi-seedance-25-billing.md @@ -0,0 +1,295 @@ +# ModelAPI Seedance 2.5 Billing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `doubao-seedance-2-5-260628` reserve and settle the documented upstream price while preserving Flatkey's existing group, wallet, and subscription accounting. + +**Architecture:** Keep the existing fixed-price asynchronous-task pipeline. The adaptor converts either request fields or the upstream `usage.estimated_usd` task snapshot into one `billable_units` multiplier over a `$0.14` base price; the relay layer remains responsible for group ratio, precharge, submit delta settlement, wallet funding, and subscription weighting. + +**Tech Stack:** Go, Gin reusable request bodies, existing `TaskAdaptor` billing hooks, Go `testing`, GitNexus. + +--- + +## File structure + +- Create `relay/channel/task/modelapiseedance/billing_test.go` for reservation, validation, usage normalization, and correction behavior. +- Modify `relay/channel/task/modelapiseedance/constants.go` for provider prices and the single billing-unit key. +- Modify `relay/channel/task/modelapiseedance/adaptor.go` for the billing hooks and private submit snapshot. +- Create `setting/ratio_setting/modelapi_seedance_price_test.go` for the default-price regression. +- Modify `setting/ratio_setting/model_ratio.go` for the `$0.14` calculation base. +- Modify `relay/relay_task_billing_test.go` for group-ratio-preserving submit recalculation. + +### Task 1: Implement adaptor reservation and submit correction + +**Files:** +- Create: `relay/channel/task/modelapiseedance/billing_test.go` +- Modify: `relay/channel/task/modelapiseedance/constants.go` +- Modify: `relay/channel/task/modelapiseedance/adaptor.go` + +- [ ] **Step 1: Write failing request-time reservation tests** + +Use the existing `newModelAPITestContext` helper, call `ValidateRequestAndSetAction`, set `types.PriceData{UsePrice: true, ModelPrice: 0.14}`, and assert that `EstimateBilling` returns exactly one ratio named `billingUnitsKey`. + +```go +tests := []struct { + name string + body string + wantUnits float64 +}{ + {"default 5s 720p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}]}`, 0.314 * 5 / 0.14}, + {"text 480p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}],"duration":8,"resolution":"480p"}`, 8}, + {"text 720p", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"text","text":"hello"}],"duration":8,"resolution":"720p"}`, 0.314 * 8 / 0.14}, + {"video 480p fallback", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"video_url","video_url":{"url":"https://example.com/input.mp4"}}],"resolution":"480p"}`, 0.084 * 30 / 0.14}, + {"video 720p fallback", `{"model":"doubao-seedance-2-5-260628","content":[{"type":"video_url","video_url":{"url":"https://example.com/input.mp4"}}],"resolution":"720p"}`, 0.188 * 30 / 0.14}, +} +``` + +- [ ] **Step 2: Verify the reservation test is RED** + +Run `go test ./relay/channel/task/modelapiseedance -run TestEstimateBillingUsesOfficialSeedanceRates -count=1`. + +Expected: compile failure because `billingUnitsKey` and custom request billing do not exist. + +- [ ] **Step 3: Write failing fixed-price validation tests** + +Assert one valid fixed price passes. Assert `UsePrice=false`, zero, negative, `math.NaN()`, and `math.Inf(1)` each return `model_price_error` with HTTP 400. + +```go +valid := &relaycommon.RelayInfo{PriceData: types.PriceData{UsePrice: true, ModelPrice: 0.14}} +if taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(valid); taskErr != nil { + t.Fatalf("valid price rejected: %+v", taskErr) +} +``` + +- [ ] **Step 4: Write failing response-snapshot tests** + +Submit a response with `"usage":{"estimated_usd":1.57}`. Assert `DoResponse` succeeds, the public response contains neither `estimated_usd` nor the upstream task id, and `AdjustBillingOnSubmit` returns `1.57 / 0.14` billable units. + +Table-test missing usage, `usage:null`, estimate `null`, zero, negative, string `"NaN"`, and overflowing `1e999`. Each must keep the task successful and return no submit adjustment. Also assert malformed private task data and invalid price data return no adjustment. + +- [ ] **Step 5: Verify all new billing tests are RED** + +Run `go test ./relay/channel/task/modelapiseedance -run 'TestEstimateBilling|TestValidateTaskPriceData|TestDoResponse.*Estimate|TestAdjustBillingOnSubmit' -count=1`. + +Expected: compile failure for missing channel-specific billing methods and types. + +- [ ] **Step 6: Add the exact provider constants** + +```go +const ( + modelAPIBasePriceUSD = 0.14 + modelAPINoVideo480PPerSecondUSD = 0.140 + modelAPINoVideo720PPerSecondUSD = 0.314 + modelAPIVideo480PPerSecondUSD = 0.084 + modelAPIVideo720PPerSecondUSD = 0.188 + modelAPIDefaultDurationSeconds = 5 + modelAPIMaxDurationSeconds = 30 + billingUnitsKey = "billable_units" +) +``` + +- [ ] **Step 7: Implement the minimal request-time hooks** + +```go +func (a *TaskAdaptor) ValidateTaskPriceData(info *relaycommon.RelayInfo) *dto.TaskError { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return taskError(errors.New("a positive fixed model price is required"), "model_price_error", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return nil + } + req, err := taskcommon.GetSeedanceRequest(c) + if err != nil { + return nil + } + resolution := req.Resolution + if resolution == "" { + resolution = "720p" + } + estimatedUSD, ok := estimateModelAPIUSD(req, resolution) + if !ok { + return nil + } + return billingUnits(estimatedUSD, info.PriceData.ModelPrice) +} +``` + +`estimateModelAPIUSD` uses duration 5 when absent, uses the explicit request duration for no-video requests, and uses 30 seconds for both video-input fallbacks. Unsupported resolutions return `(0, false)`. + +- [ ] **Step 8: Implement tolerant usage parsing and correction** + +Use `json.RawMessage` in the upstream usage type so an optional string or overflowing number does not reject an otherwise valid task. + +```go +type modelAPIUsage struct { + EstimatedUSD json.RawMessage `json:"estimated_usd"` +} + +type modelAPISubmitTaskData struct { + Status string `json:"status,omitempty"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` +} + +func normalizeEstimatedUSD(raw json.RawMessage) *float64 { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var value float64 + if err := common.Unmarshal(raw, &value); err != nil || !isPositiveFinite(value) { + return nil + } + return &value +} + +func billingUnits(estimatedUSD, modelPrice float64) map[string]float64 { + if !isPositiveFinite(estimatedUSD) || !isPositiveFinite(modelPrice) { + return nil + } + units := estimatedUSD / modelPrice + if !isPositiveFinite(units) { + return nil + } + return map[string]float64{billingUnitsKey: units} +} + +func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 { + if info == nil || !info.PriceData.UsePrice || !isPositiveFinite(info.PriceData.ModelPrice) { + return nil + } + var snapshot modelAPISubmitTaskData + if err := common.Unmarshal(taskData, &snapshot); err != nil || snapshot.EstimatedUSD == nil { + return nil + } + return billingUnits(*snapshot.EstimatedUSD, info.PriceData.ModelPrice) +} +``` + +Change `DoResponse` to marshal only `modelAPISubmitTaskData{Status: submit.Status, EstimatedUSD: normalized}` into private task data. Keep the public `OpenAIVideo` response unchanged. + +- [ ] **Step 9: Verify Task 1 is GREEN** + +Run: + +```powershell +gofmt -w relay/channel/task/modelapiseedance/constants.go relay/channel/task/modelapiseedance/adaptor.go relay/channel/task/modelapiseedance/billing_test.go +go test ./relay/channel/task/modelapiseedance -count=1 +``` + +Expected: all ModelAPI Seedance adaptor tests pass. + +- [ ] **Step 10: Commit Task 1** + +Use a Lore commit whose intent is `Make Seedance 2.5 reservations follow the upstream task snapshot`, recording the 30-second video fallback, rejection of media probing, focused test command, and live-API validation gap. + +### Task 2: Register the base price and guard generic settlement + +**Files:** +- Create: `setting/ratio_setting/modelapi_seedance_price_test.go` +- Modify: `setting/ratio_setting/model_ratio.go` +- Modify: `relay/relay_task_billing_test.go` + +- [ ] **Step 1: Add and run the failing default-price test** + +```go +func TestModelAPISeedanceDefaultPrice(t *testing.T) { + if got := GetDefaultModelPriceMap()["doubao-seedance-2-5-260628"]; got != 0.14 { + t.Fatalf("doubao-seedance-2-5-260628 default price = %v, want 0.14", got) + } +} +``` + +Run `go test ./setting/ratio_setting -run TestModelAPISeedanceDefaultPrice -count=1`. + +Expected: failure because the map lookup returns zero. + +- [ ] **Step 2: Add the fixed-price calculation base** + +Add this entry near the other video models in `defaultModelPrice`: + +```go +// ModelAPI Seedance 2.5 calculation base; the adaptor converts each +// request/task snapshot into a complete billable_units multiplier. +"doubao-seedance-2-5-260628": 0.14, +``` + +- [ ] **Step 3: Add the group-ratio replacement regression test** + +```go +func TestModelAPISeedanceSubmitAdjustmentPreservesGroupRatio(t *testing.T) { + const modelPrice, groupRatio, reservedUSD, actualUSD = 0.14, 0.8, 0.314 * 5, 1.25 + reservedUnits := reservedUSD / modelPrice + info := &relaycommon.RelayInfo{PriceData: types.PriceData{ + ModelPrice: modelPrice, UsePrice: true, + Quota: int(modelPrice * common.QuotaPerUnit * groupRatio * reservedUnits), + OtherRatios: map[string]float64{"billable_units": reservedUnits}, + GroupRatioInfo: types.GroupRatioInfo{GroupRatio: groupRatio}, + }} + got := recalcQuotaFromRatios(info, map[string]float64{"billable_units": actualUSD / modelPrice}) + want := int(actualUSD * common.QuotaPerUnit * groupRatio) + if got != want { + t.Fatalf("recalcQuotaFromRatios() = %d, want %d", got, want) + } +} +``` + +- [ ] **Step 4: Verify Task 2 is GREEN and existing funding paths remain authoritative** + +Run: + +```powershell +gofmt -w setting/ratio_setting/model_ratio.go setting/ratio_setting/modelapi_seedance_price_test.go relay/relay_task_billing_test.go +go test ./setting/ratio_setting -count=1 +go test ./relay -run 'ModelAPISeedance|Billing' -count=1 +go test ./controller -run 'TestAssetTaskWorkerAcceptedWinnerSettlesAndLogsOnce|TestAssetTaskWorkerAcceptedSubscriptionUsesSnapshotForSettlement' -count=1 +``` + +Expected: the default-price and group-ratio tests pass; the existing wallet delta and subscription-weighted settlement guards also pass. + +- [ ] **Step 5: Commit Task 2** + +Use a Lore commit whose intent is `Give Seedance 2.5 a stable fixed-price billing base`, recording that synthetic model aliases were rejected, `$0.14` remains only the calculation base, and production balances were not exercised. + +### Task 3: Review, verify, and publish + +**Files:** +- Verify all Task 1 and Task 2 files. +- Remove the temporary untracked `.gitnexusignore`. + +- [ ] **Step 1: Run a spec-compliance review, then a code-quality review, for each task commit** + +Compare actual code against this plan and `docs/superpowers/specs/2026-08-11-modelapi-seedance-25-billing-design.md`. Resolve every Critical or Important finding and repeat the relevant review. + +- [ ] **Step 2: Run full release verification** + +```powershell +go test ./relay/channel/task/modelapiseedance -count=1 +go test ./setting/ratio_setting -count=1 +go test ./relay -run 'ModelAPISeedance|Billing' -count=1 +go test ./controller -run 'TestAssetTaskWorkerAcceptedWinnerSettlesAndLogsOnce|TestAssetTaskWorkerAcceptedSubscriptionUsesSnapshotForSettlement' -count=1 +go build ./... +git diff --check +``` + +Expected: every command exits zero and `git diff --check` prints nothing. + +- [ ] **Step 3: Remove `.gitnexusignore` with `apply_patch` and run GitNexus** + +```powershell +$node='C:\Users\11247\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe' +$env:PATH='C:\Users\11247\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin;'+$env:PATH +& $node 'C:\nvm4w\nodejs\node_modules\npm\bin\npx-cli.js' -y 'gitnexus@1.6.9' detect-changes --repo new-api-modelapi-seedance-worktree --scope compare --base-ref origin/main +``` + +Expected: the billing delta introduces no new critical dependency impact. + +- [ ] **Step 4: Request final whole-feature review** + +Review `origin/main..HEAD` for pricing correctness, optional JSON tolerance, privacy, duplicate settlement, quota rounding, and fixed-price failure behavior. Resolve every Critical or Important issue and re-review. + +- [ ] **Step 5: Push and create the requested PR** + +Push `feature/modelapi-seedance-25` and create a PR with `--base main`. The PR body must include the official documentation URL, all four price formulas, fallback rationale, private `estimated_usd` correction, existing Google-backed download behavior, deployment scope, verification evidence, and the live-environment gap. From 5ba47a549089c9362d93e3a4826242e66be74329 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 11:55:28 +0800 Subject: [PATCH 24/32] Make Seedance 2.5 reservations follow the upstream task snapshot Constraint: Video input duration is not locally trustworthy, so reserve the documented 30-second bound until submit usage arrives. Rejected: Media probing | It adds SSRF, latency, and still cannot reproduce upstream billing. Confidence: high Scope-risk: moderate Directive: Keep estimated_usd private and retain fallback reservation when it is invalid or absent. Tested: go test ./relay/channel/task/modelapiseedance -count=1 Not-tested: Live ModelAPI billing response --- .../channel/task/modelapiseedance/adaptor.go | 137 +++++++++- .../task/modelapiseedance/billing_test.go | 240 ++++++++++++++++++ .../task/modelapiseedance/constants.go | 2 + 3 files changed, 371 insertions(+), 8 deletions(-) create mode 100644 relay/channel/task/modelapiseedance/billing_test.go diff --git a/relay/channel/task/modelapiseedance/adaptor.go b/relay/channel/task/modelapiseedance/adaptor.go index bdec8dcc9de..9c7598fa037 100644 --- a/relay/channel/task/modelapiseedance/adaptor.go +++ b/relay/channel/task/modelapiseedance/adaptor.go @@ -3,8 +3,10 @@ package modelapiseedance import ( "bytes" "context" + "encoding/json" "fmt" "io" + "math" "net/http" "net/url" "strings" @@ -69,6 +71,38 @@ func (a *TaskAdaptor) ValidateRequestAfterModelMapping(c *gin.Context, info *rel return a.ValidateRequestAndSetAction(c, info) } +func (a *TaskAdaptor) ValidateTaskPriceData(info *relaycommon.RelayInfo) *dto.TaskError { + if !validModelAPIPriceData(info) { + return taskError(fmt.Errorf("model price must be a positive finite fixed price"), "model_price_error", http.StatusBadRequest) + } + return nil +} + +func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 { + if c == nil || !validModelAPIPriceData(info) { + return nil + } + seedReq, err := taskcommon.GetSeedanceRequest(c) + if err != nil || seedReq == nil { + return nil + } + + duration := 5 + if seedReq.Duration != nil { + duration = *seedReq.Duration + } + resolution := seedReq.Resolution + if resolution == "" { + resolution = "720p" + } + + estimatedUSD, ok := modelAPIEstimatedUSD(resolution, duration, len(seedReq.Videos()) > 0) + if !ok { + return nil + } + return modelAPIBillingUnits(info.PriceData.ModelPrice, estimatedUSD) +} + func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { return a.baseURL + "/v1/tasks", nil } @@ -125,8 +159,8 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) } - var submit modelAPISubmitResponse - if err := common.Unmarshal(responseBody, &submit); err != nil { + submit, estimatedUSD, err := parseModelAPISubmitResponse(responseBody) + if err != nil { return "", nil, taskError(fmt.Errorf("invalid upstream response"), "invalid_response", http.StatusBadGateway) } if submit.Status == modelAPIStatusFailed { @@ -144,15 +178,31 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela } ov.CreatedAt = time.Now().Unix() c.JSON(http.StatusOK, ov) - taskData, err = common.Marshal(struct { - Status string `json:"status,omitempty"` - }{Status: submit.Status}) + snapshot := modelAPISubmitTaskData{Status: submit.Status} + if estimatedUSD != nil { + snapshot.EstimatedUSD = estimatedUSD + } + taskData, err = common.Marshal(snapshot) if err != nil { return "", nil, taskError(fmt.Errorf("failed to persist submit status"), "invalid_response", http.StatusBadGateway) } return submit.TaskID, taskData, nil } +func (a *TaskAdaptor) AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 { + if !validModelAPIPriceData(info) { + return nil + } + var snapshot modelAPISubmitTaskData + if err := common.Unmarshal(taskData, &snapshot); err != nil { + return nil + } + if snapshot.EstimatedUSD == nil || !validPositiveFinite(*snapshot.EstimatedUSD) { + return nil + } + return modelAPIBillingUnits(info.PriceData.ModelPrice, *snapshot.EstimatedUSD) +} + func (a *TaskAdaptor) GetModelList() []string { return ModelList } @@ -308,9 +358,10 @@ type modelAPIResult struct { } type modelAPISubmitResponse struct { - TaskID string `json:"task_id"` - Status string `json:"status"` - Error modelAPIError `json:"error"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Usage json.RawMessage `json:"usage"` + Error modelAPIError `json:"error"` } type modelAPITaskResponse struct { @@ -320,6 +371,11 @@ type modelAPITaskResponse struct { Error modelAPIError `json:"error"` } +type modelAPISubmitTaskData struct { + Status string `json:"status,omitempty"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` +} + const ( modelAPIStatusPending = "pending" modelAPIStatusPolling = "polling" @@ -392,6 +448,71 @@ func (p modelAPIParams) hasAny() bool { p.ReturnLastFrame != nil } +func parseModelAPISubmitResponse(data []byte) (modelAPISubmitResponse, *float64, error) { + var submit modelAPISubmitResponse + if err := common.Unmarshal(data, &submit); err != nil { + return submit, nil, err + } + estimatedUSD := parseModelAPIEstimatedUSD(submit.Usage) + return submit, estimatedUSD, nil +} + +func parseModelAPIEstimatedUSD(usage json.RawMessage) *float64 { + if len(bytes.TrimSpace(usage)) == 0 || bytes.Equal(bytes.TrimSpace(usage), []byte("null")) { + return nil + } + var parsed struct { + EstimatedUSD *float64 `json:"estimated_usd"` + } + if err := common.Unmarshal(usage, &parsed); err != nil { + return nil + } + if parsed.EstimatedUSD == nil || !validPositiveFinite(*parsed.EstimatedUSD) { + return nil + } + return parsed.EstimatedUSD +} + +func validModelAPIPriceData(info *relaycommon.RelayInfo) bool { + return info != nil && info.PriceData.UsePrice && validPositiveFinite(info.PriceData.ModelPrice) +} + +func validPositiveFinite(value float64) bool { + return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} + +func modelAPIEstimatedUSD(resolution string, duration int, hasVideo bool) (float64, bool) { + if hasVideo { + switch resolution { + case "480p": + return 0.084 * 30, true + case "720p": + return 0.188 * 30, true + default: + return 0, false + } + } + switch resolution { + case "480p": + return 0.140 * float64(duration), true + case "720p": + return 0.314 * float64(duration), true + default: + return 0, false + } +} + +func modelAPIBillingUnits(modelPrice, estimatedUSD float64) map[string]float64 { + if !validPositiveFinite(modelPrice) || !validPositiveFinite(estimatedUSD) { + return nil + } + units := estimatedUSD / modelPrice + if !validPositiveFinite(units) { + return nil + } + return map[string]float64{modelAPIBillingUnitsKey: units} +} + var supportedModelAPIResolutions = map[string]struct{}{ "480p": {}, "720p": {}, diff --git a/relay/channel/task/modelapiseedance/billing_test.go b/relay/channel/task/modelapiseedance/billing_test.go new file mode 100644 index 00000000000..3946ae6f13b --- /dev/null +++ b/relay/channel/task/modelapiseedance/billing_test.go @@ -0,0 +1,240 @@ +package modelapiseedance + +import ( + "io" + "math" + "net/http" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" +) + +func modelAPIBillingInfo(modelPrice float64) *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + OriginModelName: "client-seedance", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: UpstreamModel, + }, + PriceData: types.PriceData{ + UsePrice: true, + ModelPrice: modelPrice, + }, + TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}, + } +} + +func assertModelAPIBillableUnits(t *testing.T, got map[string]float64, want float64) { + t.Helper() + if len(got) != 1 { + t.Fatalf("EstimateBilling() = %#v, want only billable_units", got) + } + if math.Abs(got[modelAPIBillingUnitsKey]-want) > 1e-9 { + t.Fatalf("billable_units = %.12f, want %.12f", got[modelAPIBillingUnitsKey], want) + } +} + +func TestEstimateBillingUsesSeedanceDefaultsAndResolutionPricing(t *testing.T) { + tests := []struct { + name string + body string + wantUSD float64 + }{ + { + name: "defaults to 5s 720p without video", + body: `{"model":"client","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.314 * 5, + }, + { + name: "explicit 480p without video", + body: `{"model":"client","duration":4,"resolution":"480p","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.140 * 4, + }, + { + name: "explicit 720p without video", + body: `{"model":"client","duration":10,"resolution":"720p","content":[{"type":"text","text":"make it cinematic"}]}`, + wantUSD: 0.314 * 10, + }, + { + name: "explicit 480p with video uses 30s fallback", + body: `{"model":"client","duration":4,"resolution":"480p","content":[{"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}]}`, + wantUSD: 0.084 * 30, + }, + { + name: "explicit 720p with video uses 30s fallback", + body: `{"model":"client","duration":4,"resolution":"720p","content":[{"type":"video_url","video_url":{"url":"https://example.com/ref.mp4"}}]}`, + wantUSD: 0.188 * 30, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, _ := newModelAPITestContext(test.body) + got := (&TaskAdaptor{}).EstimateBilling(c, modelAPIBillingInfo(modelAPIBaseModelPrice)) + assertModelAPIBillableUnits(t, got, test.wantUSD/modelAPIBaseModelPrice) + }) + } +} + +func TestValidateTaskPriceDataRequiresFiniteFixedModelPrice(t *testing.T) { + tests := []struct { + name string + info *relaycommon.RelayInfo + }{ + {name: "nil info", info: nil}, + {name: "not fixed price", info: func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }()}, + {name: "zero model price", info: modelAPIBillingInfo(0)}, + {name: "negative model price", info: modelAPIBillingInfo(-0.14)}, + {name: "nan model price", info: modelAPIBillingInfo(math.NaN())}, + {name: "infinite model price", info: modelAPIBillingInfo(math.Inf(1))}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(test.info) + if taskErr == nil { + t.Fatal("ValidateTaskPriceData() = nil, want local model_price_error") + } + if taskErr.Code != "model_price_error" || taskErr.StatusCode != http.StatusBadRequest || !taskErr.LocalError { + t.Fatalf("TaskError = %+v, want local model_price_error/400", taskErr) + } + }) + } + + if taskErr := (&TaskAdaptor{}).ValidateTaskPriceData(modelAPIBillingInfo(modelAPIBaseModelPrice)); taskErr != nil { + t.Fatalf("valid fixed price rejected: %+v", taskErr) + } +} + +func TestEstimateBillingReturnsNilForInvalidFixedPriceData(t *testing.T) { + c, _ := newModelAPITestContext(`{"model":"client","content":[{"type":"text","text":"make it cinematic"}]}`) + tests := []*relaycommon.RelayInfo{ + nil, + func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }(), + modelAPIBillingInfo(0), + modelAPIBillingInfo(-0.14), + modelAPIBillingInfo(math.NaN()), + modelAPIBillingInfo(math.Inf(1)), + } + for i, info := range tests { + if got := (&TaskAdaptor{}).EstimateBilling(c, info); len(got) != 0 { + t.Fatalf("case %d EstimateBilling() = %#v, want nil", i, got) + } + } +} + +func TestDoResponsePersistsPrivateEstimateAndAdjustsSubmitBilling(t *testing.T) { + a := &TaskAdaptor{} + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{ + "task_id":"upstream-secret", + "status":"pending", + "usage":{"estimated_usd":1.57}, + "result":{"assets":[{"type":"video","url":"https://cdn.modelapi.co/private.mp4"}]} + }`))} + + taskID, taskData, taskErr := a.DoResponse(c, resp, info) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream-secret" { + t.Fatalf("taskID = %q, want upstream id returned only internally", taskID) + } + var snapshot struct { + Status string `json:"status"` + EstimatedUSD *float64 `json:"estimated_usd,omitempty"` + } + if err := common.Unmarshal(taskData, &snapshot); err != nil { + t.Fatalf("taskData is invalid JSON: %v", err) + } + if snapshot.Status != "pending" || snapshot.EstimatedUSD == nil || *snapshot.EstimatedUSD != 1.57 { + t.Fatalf("taskData snapshot = %s, want private status and estimated_usd", taskData) + } + public := w.Body.String() + for _, leaked := range []string{"estimated_usd", "upstream-secret", "cdn.modelapi.co", "private.mp4"} { + if strings.Contains(public, leaked) { + t.Fatalf("public response leaked %q: %s", leaked, public) + } + } + + adjusted := a.AdjustBillingOnSubmit(info, taskData) + assertModelAPIBillableUnits(t, adjusted, 1.57/modelAPIBaseModelPrice) +} + +func TestDoResponseKeepsFallbackReservationWhenEstimateIsInvalidOrAbsent(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "missing usage", body: `{"task_id":"upstream","status":"pending"}`}, + {name: "usage null", body: `{"task_id":"upstream","status":"pending","usage":null}`}, + {name: "estimate null", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":null}}`}, + {name: "estimate zero", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":0}}`}, + {name: "estimate negative", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":-1}}`}, + {name: "estimate string nan", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":"NaN"}}`}, + {name: "estimate overflow", body: `{"task_id":"upstream","status":"pending","usage":{"estimated_usd":1e999}}`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + c, w := newModelAPITestContext(`{}`) + resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.body))} + taskID, taskData, taskErr := (&TaskAdaptor{}).DoResponse(c, resp, modelAPIBillingInfo(modelAPIBaseModelPrice)) + if taskErr != nil { + t.Fatalf("DoResponse error: %+v", taskErr) + } + if taskID != "upstream" { + t.Fatalf("taskID = %q, want upstream", taskID) + } + if strings.Contains(string(taskData), "estimated_usd") { + t.Fatalf("invalid estimate persisted in taskData: %s", taskData) + } + if got := (&TaskAdaptor{}).AdjustBillingOnSubmit(modelAPIBillingInfo(modelAPIBaseModelPrice), taskData); len(got) != 0 { + t.Fatalf("AdjustBillingOnSubmit() = %#v, want nil fallback", got) + } + if strings.Contains(w.Body.String(), "estimated_usd") { + t.Fatalf("public response exposed invalid estimate: %s", w.Body.String()) + } + }) + } +} + +func TestAdjustBillingOnSubmitKeepsReservationForInvalidSnapshotOrPrice(t *testing.T) { + validData := []byte(`{"status":"pending","estimated_usd":1.57}`) + tests := []struct { + name string + info *relaycommon.RelayInfo + data []byte + }{ + {name: "malformed taskData", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":`)}, + {name: "missing estimate", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending"}`)}, + {name: "estimate zero", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending","estimated_usd":0}`)}, + {name: "estimate negative", info: modelAPIBillingInfo(modelAPIBaseModelPrice), data: []byte(`{"status":"pending","estimated_usd":-1}`)}, + {name: "nil info", info: nil, data: validData}, + {name: "non fixed price", info: func() *relaycommon.RelayInfo { + info := modelAPIBillingInfo(modelAPIBaseModelPrice) + info.PriceData.UsePrice = false + return info + }(), data: validData}, + {name: "invalid model price", info: modelAPIBillingInfo(math.Inf(1)), data: validData}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := (&TaskAdaptor{}).AdjustBillingOnSubmit(test.info, test.data); len(got) != 0 { + t.Fatalf("AdjustBillingOnSubmit() = %#v, want nil fallback", got) + } + }) + } +} diff --git a/relay/channel/task/modelapiseedance/constants.go b/relay/channel/task/modelapiseedance/constants.go index 99164bce613..7feb229cb35 100644 --- a/relay/channel/task/modelapiseedance/constants.go +++ b/relay/channel/task/modelapiseedance/constants.go @@ -3,6 +3,8 @@ package modelapiseedance const ChannelName = "modelapi-seedance" const UpstreamModel = "doubao-seedance-2-5-260628" const maxModelAPISubmitResponseBytes = 1 << 20 +const modelAPIBaseModelPrice = 0.14 +const modelAPIBillingUnitsKey = "billable_units" var ModelList = []string{ UpstreamModel, From 1203e043f95fbb02e30eedbb681ecf63be2bb4ad Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:09:42 +0800 Subject: [PATCH 25/32] Give Seedance 2.5 a stable fixed-price billing base Constraint: One public model must represent all documented request tiers. Rejected: Synthetic model aliases | They leak upstream billing tiers into routing. Confidence: high Scope-risk: narrow Directive: Keep 0.14 as the calculation base and express full request cost through billable_units. Tested: go test ./setting/ratio_setting -count=1; go test ./relay -run 'ModelAPISeedance|Billing' -count=1 Not-tested: Production account balances --- relay/relay_task_billing_test.go | 37 +++++++++++++++++++ setting/ratio_setting/model_ratio.go | 3 ++ .../modelapi_seedance_price_test.go | 12 ++++++ 3 files changed, 52 insertions(+) create mode 100644 setting/ratio_setting/modelapi_seedance_price_test.go diff --git a/relay/relay_task_billing_test.go b/relay/relay_task_billing_test.go index 17b631e113f..ccea3867d84 100644 --- a/relay/relay_task_billing_test.go +++ b/relay/relay_task_billing_test.go @@ -6,10 +6,12 @@ import ( "strings" "testing" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/relay/channel/task/byteplus" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -112,3 +114,38 @@ func TestBytePlusModelRatiosApplyTierRatios(t *testing.T) { }) } } + +func TestModelAPISeedanceSubmitAdjustmentPreservesGroupRatio(t *testing.T) { + const ( + modelPrice = 0.14 + groupRatio = 0.8 + reservedUSD = 0.314 * 5 + actualUSD = 1.25 + ) + + reservedBillableUnits := reservedUSD / modelPrice + actualBillableUnits := actualUSD / modelPrice + baseQuotaWithGroupRatio := int(modelPrice * common.QuotaPerUnit * groupRatio) + + info := &relaycommon.RelayInfo{ + PriceData: types.PriceData{ + ModelPrice: modelPrice, + UsePrice: true, + Quota: int(float64(baseQuotaWithGroupRatio) * reservedBillableUnits), + OtherRatios: map[string]float64{ + "billable_units": reservedBillableUnits, + }, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: groupRatio, + }, + }, + } + + got := recalcQuotaFromRatios(info, map[string]float64{ + "billable_units": actualBillableUnits, + }) + want := int(actualUSD * common.QuotaPerUnit * groupRatio) + if got != want { + t.Fatalf("adjusted quota = %d, want %d", got, want) + } +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 508918a2e9e..fd9b530eb42 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -324,6 +324,9 @@ var defaultModelPrice = map[string]float64{ "veo-3.0-fast-generate-001": 0.15, "veo-3.1-generate-preview": 0.4, "veo-3.1-fast-generate-preview": 0.15, + // ModelAPI Seedance 2.5 calculation base; the adaptor converts each + // request/task snapshot into a complete billable_units multiplier. + "doubao-seedance-2-5-260628": 0.14, // MiniMax H3 international 768P base rate ($0.08/s). // The task adaptor applies the 1.625 multiplier for 2K output. "MiniMax-H3": 0.08, diff --git a/setting/ratio_setting/modelapi_seedance_price_test.go b/setting/ratio_setting/modelapi_seedance_price_test.go new file mode 100644 index 00000000000..f7d50162df5 --- /dev/null +++ b/setting/ratio_setting/modelapi_seedance_price_test.go @@ -0,0 +1,12 @@ +package ratio_setting + +import "testing" + +func TestModelAPISeedanceDefaultPrice(t *testing.T) { + const model = "doubao-seedance-2-5-260628" + + got := GetDefaultModelPriceMap()[model] + if got != 0.14 { + t.Fatalf("default model price for %s = %v, want 0.14", model, got) + } +} From 47eb68f72a977718ec0e47444dab74a436d31862 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:45:40 +0800 Subject: [PATCH 26/32] Prove submit-time ModelAPI Seedance billing reaches settlement Constraint: characterization coverage only; production code stayed unchanged because the focused integration passed. Rejected: production billing changes | existing ExecutePreparedTaskSubmit and controller settlement already propagate the adjusted quota and ratios. Confidence: high Scope-risk: narrow Directive: Preserve the real ModelAPI submit boundary in this regression when changing Seedance task billing. Tested: go test ./controller -run TestModelAPISeedanceSubmitEstimatedUSDSettlesAndPersistsAdjustedBilling -count=1 -v Not-tested: full controller package and repo-wide suite. --- controller/asset_task_worker_test.go | 154 +++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/controller/asset_task_worker_test.go b/controller/asset_task_worker_test.go index 9c86beada71..648dddfefed 100644 --- a/controller/asset_task_worker_test.go +++ b/controller/asset_task_worker_test.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/http/httptest" + "strconv" "strings" "sync/atomic" "testing" @@ -240,6 +241,133 @@ func TestNonAssetRelayTaskSubmitsSynchronously(t *testing.T) { require.Equal(t, "upstream-sync", submitted.PrivateData.UpstreamTaskID) } +func TestModelAPISeedanceSubmitEstimatedUSDSettlesAndPersistsAdjustedBilling(t *testing.T) { + const ( + userID = 57 + tokenID = 58 + channelID = 159 + subID = 60 + planID = 61 + modelPrice = 0.14 + estimatedUSD = 1.25 + ) + expectedUnits := estimatedUSD / modelPrice + // Shared fixed-price task billing recalculates from integer quota units and + // intentionally preserves truncation at the submit-time adjustment boundary. + expectedQuota := 624999 + + tests := []struct { + name string + userQuota int + userSetting dto.UserSetting + seedSub bool + wantSource string + wantUserQuota int + wantSubUsed int64 + }{ + { + name: "wallet", + userQuota: 2000000, + userSetting: dto.UserSetting{BillingPreference: "wallet_only"}, + wantSource: service.BillingSourceWallet, + wantUserQuota: 2000000 - expectedQuota, + }, + { + name: "subscription", + userQuota: 0, + userSetting: dto.UserSetting{}, + seedSub: true, + wantSource: service.BillingSourceSubscription, + wantUserQuota: 0, + wantSubUsed: int64(expectedQuota), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"seedance-2.0":0.14}`)) + service.InitHttpClient() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/v1/tasks", r.URL.Path) + require.Equal(t, "Bearer sk-modelapi-provider", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"task_id":"upstream-modelapi-` + tt.name + `","status":"pending","usage":{"estimated_usd":1.25}}`)) + })) + defer server.Close() + + seedControllerRelayUserToken(t, userID, tokenID, tt.userQuota, 2000000) + seedControllerModelAPISeedanceChannel(t, channelID, server.URL) + if tt.seedSub { + require.NoError(t, model.DB.Create(&model.SubscriptionPlan{ + Id: planID, + Title: "ModelAPI Seedance submit billing plan", + DurationUnit: "month", + DurationValue: 1, + TotalAmount: 2000000, + Window5hAmount: 2000000, + WindowWeekAmount: 2000000, + }).Error) + require.NoError(t, model.DB.Create(&model.UserSubscription{ + Id: subID, + UserId: userID, + PlanId: planID, + AmountTotal: 2000000, + AmountUsed: 0, + Status: "active", + StartTime: time.Now().Add(-time.Hour).Unix(), + EndTime: time.Now().Add(time.Hour).Unix(), + }).Error) + } + model.InitChannelCache() + + c, recorder := newControllerRelayTaskContext(`{"model":"seedance-2.0","content":[{"type":"text","text":"cinematic tea ad"}]}`) + common.SetContextKey(c, constant.ContextKeyUserId, userID) + common.SetContextKey(c, constant.ContextKeyUserGroup, "default") + common.SetContextKey(c, constant.ContextKeyTokenId, tokenID) + common.SetContextKey(c, constant.ContextKeyTokenKey, "sk-task-token-58") + common.SetContextKey(c, constant.ContextKeyTokenGroup, "default") + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(c, constant.ContextKeyUserSetting, tt.userSetting) + common.SetContextKey(c, constant.ContextKeyOriginalModel, "seedance-2.0") + common.SetContextKey(c, constant.ContextKeyChannelId, channelID) + common.SetContextKey(c, constant.ContextKeyChannelType, constant.ChannelTypeModelAPISeedance) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, server.URL) + common.SetContextKey(c, constant.ContextKeyChannelKey, "sk-modelapi-provider") + c.Set("platform", strconv.Itoa(constant.ChannelTypeModelAPISeedance)) + c.Set("token_name", "task-token") + c.Set("token_quota", 2000000) + + RelayTask(c) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + var response dto.OpenAIVideo + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotEmpty(t, response.TaskID) + + var task model.Task + require.NoError(t, model.DB.Where("task_id = ?", response.TaskID).First(&task).Error) + require.Equal(t, "upstream-modelapi-"+tt.name, task.PrivateData.UpstreamTaskID) + require.Equal(t, expectedQuota, task.Quota) + require.Equal(t, tt.wantSource, task.PrivateData.BillingSource) + require.NotNil(t, task.PrivateData.BillingContext) + require.True(t, task.PrivateData.BillingContext.PerCallBilling) + require.InDelta(t, expectedUnits, task.PrivateData.BillingContext.OtherRatios["billable_units"], 1e-9) + + require.Equal(t, tt.wantUserQuota, getControllerUserQuota(t, userID)) + require.Equal(t, 2000000-expectedQuota, getControllerTokenRemain(t, tokenID)) + if tt.seedSub { + require.Equal(t, tt.wantSubUsed, getControllerSubscriptionUsed(t, subID)) + } + }) + } +} + func TestAssetTaskWorkerFallbackBeforeAcceptancePinsWinningChannel(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -2470,6 +2598,32 @@ func seedControllerTaskChannelTypeWithPriority(t *testing.T, id int, channelType }).Error) } +func seedControllerModelAPISeedanceChannel(t *testing.T, id int, baseURL string) { + t.Helper() + priority := int64(100) + weight := uint(1) + require.NoError(t, model.DB.Create(&model.Channel{ + Id: id, + Type: constant.ChannelTypeModelAPISeedance, + Key: "sk-modelapi-provider", + Status: common.ChannelStatusEnabled, + Name: fmt.Sprintf("modelapi-seedance-%d", id), + Group: "default", + Models: "seedance-2.0", + BaseURL: common.GetPointer(baseURL), + Priority: &priority, + Weight: &weight, + }).Error) + require.NoError(t, model.DB.Create(&model.Ability{ + Group: "default", + Model: "seedance-2.0", + ChannelId: id, + Enabled: true, + Priority: &priority, + Weight: weight, + }).Error) +} + func seedControllerTechMobiTaskChannel(t *testing.T, id int) { t.Helper() seedControllerTaskChannelTypeWithPriority(t, id, constant.ChannelTypeTechMobiVideo, "techmobi-key-a\ntechmobi-key-b", 100, 1) From b98aa39790be3b80f1dc407059a3c0fb0674b316 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:59:16 +0800 Subject: [PATCH 27/32] Preserve submit-selected keys for ModelAPI polling ModelAPI Seedance task polling authenticates with the submit-time Bearer key, so queued and direct submits must persist the selected channel key just like TechMobi. Constraint: ModelAPI Seedance has no materializer; the queued regression seeds an active binding to exercise the worker acceptance path without adding service proxy or frontend changes. Rejected: Reusing current channel key during polling | rotated or multi-key channels can select a different key after submit. Confidence: high Scope-risk: narrow Directive: Keep polling-key persistence confined to private task data; do not surface provider keys in logs or API responses. Tested: go test ./model -run TestInitTaskPersists(TechMobi|ModelAPISeedance)SelectedKeyForPolling|TestTechMobiSubmittingFencePreservesSelectedKeyAfterExpiry|TestTaskPollingKeyPersistenceTrimsAndIgnoresBlankValues -count=1 Tested: go test ./controller -run Test(TechMobi|ModelAPISeedance)AssetTaskWorkerPersistsSelectedKeyAfterAcceptance|TestTechMobiAssetTaskWorkerPersistsSelectedKeyForUnknownSubmission -count=1 Tested: git diff --check Not-tested: full repository test suite --- controller/asset_task_worker.go | 9 ++- controller/asset_task_worker_test.go | 107 +++++++++++++++++++++++++++ model/task.go | 3 +- model/task_key_test.go | 12 +++ 4 files changed, 128 insertions(+), 3 deletions(-) diff --git a/controller/asset_task_worker.go b/controller/asset_task_worker.go index 79d4cb238cb..aa9987244a0 100644 --- a/controller/asset_task_worker.go +++ b/controller/asset_task_worker.go @@ -729,10 +729,15 @@ func quarantineLeasedAssetTaskSubmissionUnknown(task *model.Task, lease *taskPre } func taskPollingKey(channel *model.Channel, info *relaycommon.RelayInfo) string { - if channel == nil || channel.Type != constant.ChannelTypeTechMobiVideo || info == nil || info.ChannelMeta == nil { + if channel == nil || info == nil || info.ChannelMeta == nil { + return "" + } + switch channel.Type { + case constant.ChannelTypeTechMobiVideo, constant.ChannelTypeModelAPISeedance: + return strings.TrimSpace(info.ChannelMeta.ApiKey) + default: return "" } - return strings.TrimSpace(info.ChannelMeta.ApiKey) } func acceptLeasedAssetTask(c *gin.Context, info *relaycommon.RelayInfo, task *model.Task, owner string, lease *taskPreparationLease, channel *model.Channel, result *relay.TaskSubmitResult) error { diff --git a/controller/asset_task_worker_test.go b/controller/asset_task_worker_test.go index 648dddfefed..30945517e55 100644 --- a/controller/asset_task_worker_test.go +++ b/controller/asset_task_worker_test.go @@ -474,6 +474,52 @@ func TestTechMobiAssetTaskWorkerPersistsSelectedKeyAfterAcceptance(t *testing.T) require.Equal(t, "techmobi-key-b", stored.PrivateData.Key) } +func TestModelAPISeedanceAssetTaskWorkerPersistsSelectedKeyAfterAcceptance(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + restoreHooks := useAssetTaskWorkerHooksForTest(t, 100, func() int64 { return assetTaskWorkerTestNow }) + defer restoreHooks() + oldRetryTimes := common.RetryTimes + common.RetryTimes = 0 + defer func() { common.RetryTimes = oldRetryTimes }() + + adaptor := &controllerFakeTaskAdaptor{upstreamTaskID: "modelapi-upstream-task"} + restoreAdaptor := registerTaskAdaptorForTest(constant.TaskPlatform(fmt.Sprint(constant.ChannelTypeModelAPISeedance)), adaptor) + defer restoreAdaptor() + + publicID := "ast_8234567890abcdefABCDEF1234567890" + seedControllerRelayUserToken(t, 7, 11, 10000, 10000) + seedControllerModelAPISeedanceMultiKeyChannel(t, 49) + seedControllerAsset(t, 7, publicID, time.Now().Add(time.Hour).Unix()) + var asset model.Asset + require.NoError(t, model.DB.Where("public_id = ?", publicID).First(&asset).Error) + require.NoError(t, model.DB.Create(&model.AssetBinding{ + AssetId: asset.Id, + ChannelId: 49, + BindingScope: "", + Status: model.AssetStatusActive, + UpstreamAssetId: "modelapi-bound-" + publicID, + }).Error) + task := seedControllerQueuedAssetTask(t, "task_modelapi_selected_key", model.TaskPreparationStatusPreparingAssets, "", 0) + task.ChannelId = 0 + task.NormalizedRequestPayload = []byte(seedanceTaskBody(publicID)) + require.NoError(t, model.DB.Save(task).Error) + model.InitChannelCache() + + assetTaskWorkerTestNow = 1000 + processed, err := RunAssetTaskWorkerOnce(context.Background(), "node-a", 10) + require.NoError(t, err) + require.Equal(t, 1, processed) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusSubmitted, stored.Status) + require.Equal(t, 49, stored.ChannelId) + require.Equal(t, "modelapi-key-b", stored.PrivateData.Key) +} + func TestTechMobiAssetTaskWorkerRequeuesProcessingBindingThenSubmitsWhenActive(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -680,6 +726,52 @@ func TestTechMobiAssetTaskWorkerPersistsSelectedKeyForUnknownSubmission(t *testi require.Equal(t, "techmobi-key-b", stored.PrivateData.Key) } +func TestModelAPISeedanceAssetTaskWorkerPersistsSelectedKeyForUnknownSubmission(t *testing.T) { + restoreDB := useControllerAssetTaskDBForTest(t) + defer restoreDB() + restorePricing := useControllerAssetTaskPricingForTest(t) + defer restorePricing() + restoreHooks := useAssetTaskWorkerHooksForTest(t, 100, func() int64 { return assetTaskWorkerTestNow }) + defer restoreHooks() + oldRetryTimes := common.RetryTimes + common.RetryTimes = 0 + defer func() { common.RetryTimes = oldRetryTimes }() + + adaptor := &controllerFakeTaskAdaptor{failByChannel: map[int]error{49: assertErr("connection reset after request write")}} + restoreAdaptor := registerTaskAdaptorForTest(constant.TaskPlatform(fmt.Sprint(constant.ChannelTypeModelAPISeedance)), adaptor) + defer restoreAdaptor() + + publicID := "ast_9334567890abcdefABCDEF1234567890" + seedControllerRelayUserToken(t, 7, 11, 10000, 10000) + seedControllerModelAPISeedanceMultiKeyChannel(t, 49) + seedControllerAsset(t, 7, publicID, time.Now().Add(time.Hour).Unix()) + var asset model.Asset + require.NoError(t, model.DB.Where("public_id = ?", publicID).First(&asset).Error) + require.NoError(t, model.DB.Create(&model.AssetBinding{ + AssetId: asset.Id, + ChannelId: 49, + BindingScope: "", + Status: model.AssetStatusActive, + UpstreamAssetId: "modelapi-bound-" + publicID, + }).Error) + task := seedControllerQueuedAssetTask(t, "task_modelapi_unknown_key", model.TaskPreparationStatusPreparingAssets, "", 0) + task.ChannelId = 0 + task.NormalizedRequestPayload = []byte(seedanceTaskBody(publicID)) + require.NoError(t, model.DB.Save(task).Error) + model.InitChannelCache() + + assetTaskWorkerTestNow = 1000 + processed, err := RunAssetTaskWorkerOnce(context.Background(), "node-a", 10) + require.NoError(t, err) + require.Equal(t, 1, processed) + + var stored model.Task + require.NoError(t, model.DB.Where("task_id = ?", task.TaskID).First(&stored).Error) + require.EqualValues(t, model.TaskStatusUnknown, stored.Status) + require.Equal(t, model.TaskPreparationStatusUnknownOutcome, stored.PreparationStatus) + require.Equal(t, "modelapi-key-b", stored.PrivateData.Key) +} + func TestAssetTaskWorkerCrossTypeFallbackUsesSelectedAdaptorAndPricing(t *testing.T) { restoreDB := useControllerAssetTaskDBForTest(t) defer restoreDB() @@ -2624,6 +2716,21 @@ func seedControllerModelAPISeedanceChannel(t *testing.T, id int, baseURL string) }).Error) } +func seedControllerModelAPISeedanceMultiKeyChannel(t *testing.T, id int) { + t.Helper() + seedControllerTaskChannelTypeWithPriority(t, id, constant.ChannelTypeModelAPISeedance, "modelapi-key-a\nmodelapi-key-b", 100, 1) + channelInfo := model.ChannelInfo{ + IsMultiKey: true, + MultiKeySize: 2, + MultiKeyMode: constant.MultiKeyModePolling, + MultiKeyPollingIndex: 1, + MultiKeyStatusList: map[int]int{ + 0: common.ChannelStatusManuallyDisabled, + }, + } + require.NoError(t, model.DB.Model(&model.Channel{}).Where("id = ?", id).Update("channel_info", channelInfo).Error) +} + func seedControllerTechMobiTaskChannel(t *testing.T, id int) { t.Helper() seedControllerTaskChannelTypeWithPriority(t, id, constant.ChannelTypeTechMobiVideo, "techmobi-key-a\ntechmobi-key-b", 100, 1) diff --git a/model/task.go b/model/task.go index c600f8ce2f0..de304fe996a 100644 --- a/model/task.go +++ b/model/task.go @@ -278,7 +278,8 @@ func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.RelayInfo) if relayInfo != nil && relayInfo.ChannelMeta != nil { if relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeGemini || relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeVertexAi || - relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeTechMobiVideo { + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeTechMobiVideo || + relayInfo.ChannelMeta.ChannelType == constant.ChannelTypeModelAPISeedance { privateData.Key = relayInfo.ChannelMeta.ApiKey } if relayInfo.UpstreamModelName != "" { diff --git a/model/task_key_test.go b/model/task_key_test.go index fbf5d9511eb..42b1ef12281 100644 --- a/model/task_key_test.go +++ b/model/task_key_test.go @@ -21,6 +21,18 @@ func TestInitTaskPersistsTechMobiSelectedKeyForPolling(t *testing.T) { require.Equal(t, "techmobi-selected-key", task.PrivateData.Key) } +func TestInitTaskPersistsModelAPISeedanceSelectedKeyForPolling(t *testing.T) { + task := InitTask(constant.TaskPlatform("49"), &relaycommon.RelayInfo{ + UserId: 7, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelType: constant.ChannelTypeModelAPISeedance, + ApiKey: "modelapi-selected-key", + }, + }) + + require.Equal(t, "modelapi-selected-key", task.PrivateData.Key) +} + func TestTechMobiSubmittingFencePreservesSelectedKeyAfterExpiry(t *testing.T) { truncateTables(t) From e720838444933b7a32d3603a5f3f9c38745d8cb0 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:16:12 +0800 Subject: [PATCH 28/32] Preserve channel-specific video archive proxy boundaries TechMobi archive fetches historically honor channel proxy settings, but ModelAPI archive fetches must remain fail-closed and direct-only. Constraint: ModelAPI Seedance proxy use is already blocked in controller, polling, and adaptor paths and must remain blocked before upstream/proxy contact. Rejected: Keep the storage-layer proxy rejection unconditional | it breaks TechMobi channels that rely on configured archive proxies. Confidence: high Scope-risk: narrow Directive: Do not route ModelAPI archive downloads through GetHttpClientWithProxy; keep non-empty proxy rejection before client construction. Tested: go test ./service -run 'TestArchiveVideoResult|TestVideoResultDirectFetchClientRejectsDialTimePrivateIP|TestUpdateVideoSingleTask(ArchivePersistsMetadataBeforeSuccessSettlement|ArchiveErrorDoesNotFinalizeOrSettle|ModelAPIRejectsProxyBeforeFetchOrArchive|ModelAPIArchiveErrorDoesNotFinalizeOrSettle|ModelAPIArchiveFailureNoUpstreamLeaks)' -count=1; go test ./controller -run 'TestValidateChannelRejectsModelAPISeedanceProxy|TestModelAPIVideoProxyWithoutArchiveDoesNotFetchUpstream|TestArchivedTechMobiVideoRedirect' -count=1; go test ./relay/channel/task/modelapiseedance -run 'TestDoRequestRejectsProxyWithoutUpstreamRequest|TestDoRequestTreatsWhitespaceProxyAsEmpty|TestFetchTaskRejectsProxyWithoutUpstreamRequest|TestFetchTaskWithContextTreatsWhitespaceProxyAsEmpty' -count=1 Not-tested: repo-wide go test ./... skipped per instruction due shared DB conflicts and low C: space. --- service/video_result_storage.go | 14 +++++-- service/video_result_storage_test.go | 57 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/service/video_result_storage.go b/service/video_result_storage.go index 40f02387a91..5398c761906 100644 --- a/service/video_result_storage.go +++ b/service/video_result_storage.go @@ -157,11 +157,17 @@ func ArchiveVideoResultForChannel(ctx context.Context, channel, publicTaskID, up recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent } - if strings.TrimSpace(proxy) != "" { - recordArchive("failure", 0) - return nil, ErrVideoResultInvalidContent + proxy = strings.TrimSpace(proxy) + var client *http.Client + if proxy != "" { + if strings.EqualFold(strings.TrimSpace(channel), "modelapi") { + recordArchive("failure", 0) + return nil, ErrVideoResultInvalidContent + } + client, err = GetHttpClientWithProxy(proxy) + } else { + client, err = newVideoResultFetchHTTPClient(cfg, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) } - client, err := newVideoResultFetchHTTPClient(cfg, videoResultDirectFetchResolver, videoResultDirectFetchDialContext) if err != nil { recordArchive("failure", 0) return nil, ErrVideoResultInvalidContent diff --git a/service/video_result_storage_test.go b/service/video_result_storage_test.go index 3d0589d1d73..4f19be81d57 100644 --- a/service/video_result_storage_test.go +++ b/service/video_result_storage_test.go @@ -494,7 +494,7 @@ func TestArchiveVideoResult(t *testing.T) { require.ErrorIs(t, err, ErrVideoResultConfig) }) - t.Run("rejects configured proxy before fetching archive source", func(t *testing.T) { + t.Run("modelapi rejects configured proxy before fetching archive source", func(t *testing.T) { start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) store := newFakeVideoResultStore() restore := installVideoResultArchiveTestHooks(t, store, start) @@ -515,13 +515,66 @@ func TestArchiveVideoResult(t *testing.T) { })) defer proxy.Close() - _, err := ArchiveVideoResult(context.Background(), "task_proxy_rejected", source.URL, proxy.URL) + _, err := ArchiveVideoResultForChannel(context.Background(), "modelapi", "task_proxy_rejected", source.URL, proxy.URL) require.ErrorIs(t, err, ErrVideoResultInvalidContent) require.Equal(t, 0, proxyHits) require.Equal(t, 0, sourceHits) require.Empty(t, store.created) }) + t.Run("techmobi archives through configured proxy", func(t *testing.T) { + start := time.Date(2026, 8, 6, 0, 0, 0, 0, time.UTC) + store := newFakeVideoResultStore() + restore := installVideoResultArchiveTestHooks(t, store, start) + defer restore() + t.Cleanup(ResetProxyClientCache) + t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") + payload := minimalMP4Fixture() + + sourceHits := 0 + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sourceHits++ + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write(payload) + })) + defer source.Close() + sourceURL, err := url.Parse(source.URL) + require.NoError(t, err) + + proxyHits := 0 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, sourceURL.Host, r.URL.Host) + + outbound := r.Clone(r.Context()) + outbound.RequestURI = "" + outbound.Header.Del("Proxy-Connection") + response, err := http.DefaultTransport.RoundTrip(outbound) + require.NoError(t, err) + defer response.Body.Close() + + for key, values := range response.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(response.StatusCode) + _, err = io.Copy(w, response.Body) + require.NoError(t, err) + })) + defer proxy.Close() + + result, err := ArchiveVideoResult(context.Background(), "task_proxy_archive", source.URL, proxy.URL) + require.NoError(t, err) + require.Equal(t, "video-results/tasks/task_proxy_archive.mp4", result.Object) + require.Equal(t, 1, proxyHits) + require.Equal(t, 1, sourceHits) + created := store.created["video-bucket/video-results/tasks/task_proxy_archive.mp4"] + require.Equal(t, payload, created.body) + }) + t.Run("uses the same object key across archive start dates", func(t *testing.T) { t.Setenv("VIDEO_RESULT_STORAGE_BUCKET", "video-bucket") payload := minimalMP4Fixture() From ffa8fac8d9d0ee15d4179abf00dc478f4e9efc94 Mon Sep 17 00:00:00 2001 From: think-back <71199016+think-back@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:24:04 +0800 Subject: [PATCH 29/32] Normalize channel UI formatting for stable frontend checks Constraint: scoped to the four current failing Prettier channel files Rejected: broader frontend formatting | would touch unrelated files outside ownership Confidence: high Scope-risk: narrow Directive: keep future channel UI edits formatted with package-local Prettier Tested: web/default bun x prettier --check src/features/channels/constants.ts; web/classic bun x prettier --check src/constants/channel.constants.js src/helpers/render.jsx src/components/table/channels/modals/EditChannelModal.jsx; web/default bun test src/features/channels/constants.test.ts; web/classic bun test src/components/table/channels/modals/modelapi-seedance-classic.test.js; git diff --check; format HEAD copies compare equal after package-local Prettier Not-tested: full frontend builds --- .../channels/modals/EditChannelModal.jsx | 2803 +++++++++-------- .../src/constants/channel.constants.js | 3 +- web/classic/src/helpers/render.jsx | 2002 ++++++------ .../src/features/channels/constants.ts | 3 +- 4 files changed, 2588 insertions(+), 2223 deletions(-) diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index ca0b76b6e7b..b02656a4ce3 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -27,7 +27,10 @@ import { verifyJSON, } from '../../../../helpers'; import { useIsMobile } from '../../../../hooks/common/useIsMobile'; -import { CHANNEL_OPTIONS, MODEL_FETCHABLE_CHANNEL_TYPES } from '../../../../constants'; +import { + CHANNEL_OPTIONS, + MODEL_FETCHABLE_CHANNEL_TYPES, +} from '../../../../constants'; import { SideSheet, Space, @@ -302,7 +305,8 @@ const EditChannelModal = (props) => { [inputs.upstream_model_update_last_detected_models], ); const upstreamDetectedModelsPreview = useMemo( - () => upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), + () => + upstreamDetectedModels.slice(0, UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT), [upstreamDetectedModels], ); const upstreamDetectedModelsOmittedCount = @@ -335,9 +339,7 @@ const EditChannelModal = (props) => { return { tagLabel: t('不更改'), tagColor: 'grey', - preview: t( - '此项可选,用于覆盖请求参数。不支持覆盖 stream 参数', - ), + preview: t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数'), }; } if (!verifyJSON(raw)) { @@ -1338,12 +1340,15 @@ const EditChannelModal = (props) => { } else { formApiRef.current?.setValues(getInitValues()); try { - navigator?.clipboard?.readText()?.then((text) => { - const parsed = parseChannelConnectionString(text); - if (parsed) { - setClipboardConfig(parsed); - } - }).catch(() => {}); + navigator?.clipboard + ?.readText() + ?.then((text) => { + const parsed = parseChannelConnectionString(text); + if (parsed) { + setClipboardConfig(parsed); + } + }) + .catch(() => {}); } catch {} } fetchModelGroups(); @@ -1351,7 +1356,8 @@ const EditChannelModal = (props) => { setUseManualInput(false); // 编辑模式下恢复用户偏好,创建模式一律折叠 setAdvancedSettingsOpen( - isEdit && localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' + isEdit && + localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true', ); } else { // 统一的模态框关闭重置逻辑 @@ -2221,92 +2227,97 @@ const EditChannelModal = (props) => {
{/* Upstream Model Management Section */} {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( -
- - {t('上游模型管理')} - +
+ + {t('上游模型管理')} + - - handleChannelOtherSettingsChange( - 'upstream_model_update_check_enabled', - value, - ) - } - extraText={t( - '开启后由后端定时任务检测该渠道上游模型变化', - )} - /> - - handleChannelOtherSettingsChange('upstream_model_update_auto_sync_enabled', value) - } - extraText={t('开启后检测到新增模型会自动加入当前渠道模型列表')} - /> - - handleInputChange( - 'upstream_model_update_ignored_models', - value, - ) - } - showClear - /> -
- {t('上次检测时间')}:  - {formatUnixTime( - inputs.upstream_model_update_last_check_time, - )} -
-
- {t('上次检测到可加入模型')}:  - {upstreamDetectedModels.length === 0 ? ( - t('暂无') - ) : ( - <> - - {upstreamDetectedModels.join(', ')} -
- } - > - - {upstreamDetectedModelsPreview.join(', ')} + + handleChannelOtherSettingsChange( + 'upstream_model_update_check_enabled', + value, + ) + } + extraText={t( + '开启后由后端定时任务检测该渠道上游模型变化', + )} + /> + + handleChannelOtherSettingsChange( + 'upstream_model_update_auto_sync_enabled', + value, + ) + } + extraText={t( + '开启后检测到新增模型会自动加入当前渠道模型列表', + )} + /> + + handleInputChange( + 'upstream_model_update_ignored_models', + value, + ) + } + showClear + /> +
+ {t('上次检测时间')}:  + {formatUnixTime( + inputs.upstream_model_update_last_check_time, + )} +
+
+ {t('上次检测到可加入模型')}:  + {upstreamDetectedModels.length === 0 ? ( + t('暂无') + ) : ( + <> + + {upstreamDetectedModels.join(', ')} +
+ } + > + + {upstreamDetectedModelsPreview.join(', ')} + + + + {upstreamDetectedModelsOmittedCount > 0 + ? t('(共 {{total}} 个,省略 {{omit}} 个)', { + total: upstreamDetectedModels.length, + omit: upstreamDetectedModelsOmittedCount, + }) + : t('(共 {{total}} 个)', { + total: upstreamDetectedModels.length, + })} - - - {upstreamDetectedModelsOmittedCount > 0 - ? t('(共 {{total}} 个,省略 {{omit}} 个)', { - total: upstreamDetectedModels.length, - omit: upstreamDetectedModelsOmittedCount, - }) - : t('(共 {{total}} 个)', { - total: upstreamDetectedModels.length, - })} - - - )} + + )} +
-
)} {/* Request Config Section */} @@ -2317,7 +2328,9 @@ const EditChannelModal = (props) => {
- {t('参数覆盖')} + + {t('参数覆盖')} +
@@ -2477,7 +2525,9 @@ const EditChannelModal = (props) => { label={t('渠道优先级')} placeholder={t('渠道优先级')} min={0} - onNumberChange={(value) => handleInputChange('priority', value)} + onNumberChange={(value) => + handleInputChange('priority', value) + } style={{ width: '100%' }} /> @@ -2487,7 +2537,9 @@ const EditChannelModal = (props) => { label={t('渠道权重')} placeholder={t('渠道权重')} min={0} - onNumberChange={(value) => handleInputChange('weight', value)} + onNumberChange={(value) => + handleInputChange('weight', value) + } style={{ width: '100%' }} /> @@ -2506,7 +2558,7 @@ const EditChannelModal = (props) => { } style={{ width: '100%' }} extraText={t( - '单个渠道允许的最大进行中请求数,0 表示不限制' + '单个渠道允许的最大进行中请求数,0 表示不限制', )} /> @@ -2517,10 +2569,68 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('disable_store', value)} extraText={t('store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用')} /> - handleChannelOtherSettingsChange('allow_safety_identifier', value)} extraText={t('safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私')} /> - handleChannelOtherSettingsChange('allow_include_obfuscation', value)} extraText={t('include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'disable_store', + value, + ) + } + extraText={t( + 'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_safety_identifier', + value, + ) + } + extraText={t( + 'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_include_obfuscation', + value, + ) + } + extraText={t( + 'include_obfuscation 用于控制 Responses 流混淆字段。默认关闭以避免客户端关闭该安全保护', + )} + /> )} @@ -2529,9 +2639,48 @@ const EditChannelModal = (props) => {
{t('字段透传控制')}
- handleChannelOtherSettingsChange('allow_service_tier', value)} extraText={t('service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用')} /> - handleChannelOtherSettingsChange('allow_inference_geo', value)} extraText={t('inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息')} /> - handleChannelOtherSettingsChange('allow_speed', value)} extraText={t('speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式')} /> + + handleChannelOtherSettingsChange( + 'allow_service_tier', + value, + ) + } + extraText={t( + 'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用', + )} + /> + + handleChannelOtherSettingsChange( + 'allow_inference_geo', + value, + ) + } + extraText={t( + 'inference_geo 字段用于控制 Claude 数据驻留推理区域。默认关闭以避免未经授权透传地域信息', + )} + /> + + handleChannelOtherSettingsChange('allow_speed', value) + } + extraText={t( + 'speed 字段用于控制 Claude 推理速度模式。默认关闭以避免意外切换到 fast 模式', + )} + /> )}
@@ -2543,460 +2692,396 @@ const EditChannelModal = (props) => { {inputs.type === 14 && ( - handleChannelOtherSettingsChange('claude_beta_query', value)} extraText={t('开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)')} /> + + handleChannelOtherSettingsChange( + 'claude_beta_query', + value, + ) + } + extraText={t( + '开启后,该渠道请求 Claude 时将强制追加 ?beta=true(无需客户端手动传参)', + )} + /> )} {inputs.type === 1 && ( - handleChannelSettingsChange('force_format', value)} extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')} /> + + handleChannelSettingsChange('force_format', value) + } + extraText={t( + '强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)', + )} + /> )} - handleChannelSettingsChange('thinking_to_content', value)} extraText={t('将 reasoning_content 转换为 标签拼接到内容中')} /> - handleChannelSettingsChange('pass_through_body_enabled', value)} extraText={t('启用请求体透传功能')} /> + + handleChannelSettingsChange('thinking_to_content', value) + } + extraText={t( + '将 reasoning_content 转换为 标签拼接到内容中', + )} + /> + + handleChannelSettingsChange( + 'pass_through_body_enabled', + value, + ) + } + extraText={t('启用请求体透传功能')} + /> {inputs.type !== 111 && ( - handleChannelSettingsChange('proxy', value)} showClear extraText={t('用于配置网络代理,支持 socks5 协议')} /> + + handleChannelSettingsChange('proxy', value) + } + showClear + extraText={t('用于配置网络代理,支持 socks5 协议')} + /> )} - handleChannelSettingsChange('system_prompt', value)} autosize showClear extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')} /> - handleChannelSettingsChange('system_prompt_override', value)} extraText={t('如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面')} /> + + handleChannelSettingsChange('system_prompt', value) + } + autosize + showClear + extraText={t( + '用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置', + )} + /> + + handleChannelSettingsChange( + 'system_prompt_override', + value, + ) + } + extraText={t( + '如果用户请求中包含系统提示词,则使用此设置拼接到用户的系统提示词前面', + )} + /> ); return ( - <> - -
- {!isEdit && clipboardConfig && ( - - {t('检测到剪贴板中的连接信息')} -
- - -
-
- } - /> - )} - {/* Core Configuration Card - Always Visible */} - - {/* Header */} -
- - - -
- - {t('核心配置')} - -
- {t('创建渠道所需的基本信息')} -
-
-
- - {isIonetChannel && ( + <> + +
+ {!isEdit && clipboardConfig && ( - - {ionetMetadata?.deployment_id && ( - - )} - - + className='ec-dbcd0a3c01b55203' + description={ +
+ {t('检测到剪贴板中的连接信息')} +
+ + +
+
+ } + /> )} + {/* Core Configuration Card - Always Visible */} + + {/* Header */} +
+ + + +
+ + {t('核心配置')} + +
+ {t('创建渠道所需的基本信息')} +
+
+
- setChannelSearchValue(value)} - renderOptionItem={renderChannelOption} - onChange={(value) => handleInputChange('type', value)} - disabled={isIonetLocked} - /> + {isIonetChannel && ( + + + {ionetMetadata?.deployment_id && ( + + )} + + + )} - {inputs.type === 57 && ( - setChannelSearchValue(value)} + renderOptionItem={renderChannelOption} + onChange={(value) => handleInputChange('type', value)} + disabled={isIonetLocked} /> - )} - {inputs.type === 20 && ( - { - setIsEnterpriseAccount(value); - handleInputChange('is_enterprise_account', value); - }} - extraText={t( - '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', - )} - initValue={inputs.is_enterprise_account} + {inputs.type === 57 && ( + + )} + + {inputs.type === 20 && ( + { + setIsEnterpriseAccount(value); + handleInputChange('is_enterprise_account', value); + }} + extraText={t( + '企业账户为特殊返回格式,需要特殊处理,如果非企业账户,请勿勾选', + )} + initValue={inputs.is_enterprise_account} + /> + )} + + handleInputChange('name', value)} + autoComplete='new-password' /> - )} - handleInputChange('name', value)} - autoComplete='new-password' - /> + {inputs.type === 33 && ( + <> + { + handleChannelOtherSettingsChange( + 'aws_key_type', + value, + ); + }} + extraText={t( + 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', + )} + /> + + )} - {inputs.type === 33 && ( - <> + {inputs.type === 41 && ( { + // 更新设置中的 vertex_key_type handleChannelOtherSettingsChange( - 'aws_key_type', + 'vertex_key_type', value, ); - }} - extraText={t( - 'AK/SK 模式:使用 AccessKey 和 SecretAccessKey;API Key 模式:使用 API Key', - )} - /> - - )} - - {inputs.type === 41 && ( - { - // 更新设置中的 vertex_key_type - handleChannelOtherSettingsChange( - 'vertex_key_type', - value, - ); - // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 - if (value === 'api_key') { - setBatch(false); - setUseManualInput(false); - setVertexKeys([]); - setVertexFileList([]); - if (formApiRef.current) { - formApiRef.current.setValue('vertex_files', []); + // 切换为 api_key 时,关闭批量与手动/文件切换,并清理已选文件 + if (value === 'api_key') { + setBatch(false); + setUseManualInput(false); + setVertexKeys([]); + setVertexFileList([]); + if (formApiRef.current) { + formApiRef.current.setValue('vertex_files', []); + } } - } - }} - extraText={ - inputs.vertex_key_type === 'api_key' - ? t('API Key 模式下不支持批量创建') - : t('JSON 模式支持手动输入或上传服务账号 JSON') - } - /> - )} - {batch ? ( - inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件,支持多文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] - } - extraText={batchExtra} - /> - ) : ( - handleInputChange('key', value)} - disabled={isIonetLocked} + }} extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
+ inputs.vertex_key_type === 'api_key' + ? t('API Key 模式下不支持批量创建') + : t('JSON 模式支持手动输入或上传服务账号 JSON') } - showClear /> - ) - ) : ( - <> - {inputs.type === 57 ? ( - <> - - handleInputChange('key', value) - } - disabled={isIonetLocked} - extraText={ -
- - {t( - '仅支持 JSON 对象,必须包含 access_token 与 account_id', - )} - - - - - {isEdit && ( - - )} - - {isEdit && ( - - )} - {batchExtra} - -
- } - autosize - showClear - /> - - setCodexOAuthModalVisible(false)} - onSuccess={handleCodexOAuthGenerated} - /> - - ) : inputs.type === 41 && - (inputs.vertex_key_type || 'json') === 'json' ? ( - <> - {!batch && ( -
- - {t('密钥输入方式')} - - - + )} + {batch ? ( + inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + } + dragMainText={t('点击上传文件或拖拽文件到这里')} + dragSubText={t('仅支持 JSON 文件,支持多文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> + ) : ( + + handleInputChange('key', value) + } + disabled={isIonetLocked} + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( - -
- )} - - {batch && ( - - )} - - {useManualInput && !batch ? ( + {batchExtra} +
+ } + showClear + /> + ) + ) : ( + <> + {inputs.type === 57 ? ( + <> { : t('密钥') } placeholder={t( - '请输入 JSON 格式的密钥内容,例如:\n{\n "type": "service_account",\n "project_id": "your-project-id",\n "private_key_id": "...",\n "private_key": "...",\n "client_email": "...",\n "client_id": "...",\n "auth_uri": "...",\n "token_uri": "...",\n "auth_provider_x509_cert_url": "...",\n "client_x509_cert_url": "..."\n}', + '请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}', )} rules={ isEdit @@ -3023,788 +3108,1048 @@ const EditChannelModal = (props) => { onChange={(value) => handleInputChange('key', value) } + disabled={isIonetLocked} extraText={ -
+
- {t('请输入完整的 JSON 格式密钥内容')} + {t( + '仅支持 JSON 对象,必须包含 access_token 与 account_id', + )} - {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - + + + + {isEdit && ( + )} - {isEdit && ( - )} - {batchExtra} + {isEdit && ( + + )} + {batchExtra} +
} autosize showClear /> - ) : ( - } - dragMainText={t('点击上传文件或拖拽文件到这里')} - dragSubText={t('仅支持 JSON 文件')} - style={{ marginTop: 10 }} - uploadTrigger='custom' - beforeUpload={() => false} - onChange={handleVertexUploadChange} - fileList={vertexFileList} - rules={ - isEdit - ? [] - : [ - { - required: true, - message: t('请上传密钥文件'), - }, - ] + + + setCodexOAuthModalVisible(false) } - extraText={batchExtra} + onSuccess={handleCodexOAuthGenerated} /> - )} - - ) : ( - - handleInputChange('key', value) - } - extraText={ -
- {isEdit && - isMultiKeyChannel && - keyMode === 'append' && ( - - {t( - '追加模式:新密钥将添加到现有密钥列表的末尾', - )} - - )} - {isEdit && ( - - )} - {batchExtra} -
- } - showClear - /> - )} - - )} - - {isEdit && isMultiKeyChannel && ( - setKeyMode(value)} - extraText={ - - {keyMode === 'replace' - ? t('覆盖模式:将完全替换现有的所有密钥') - : t('追加模式:将新密钥添加到现有密钥列表末尾')} - - } - /> - )} - {batch && multiToSingle && ( - <> - { - setMultiKeyMode(value); - handleInputChange('multi_key_mode', value); - }} - /> - {inputs.multi_key_mode === 'polling' && ( - - )} - - )} - - {inputs.type === 18 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 41 && ( - handleInputChange('other', value)} - rules={[ - { required: true, message: t('请填写部署地区') }, - ]} - template={REGION_EXAMPLE} - templateLabel={t('填入模板')} - editorType='region' - formApi={formApiRef.current} - extraText={t('设置默认地区和特定模型的专用地区')} - /> - )} - - {inputs.type === 21 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 39 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 49 && ( - handleInputChange('other', value)} - showClear - /> - )} - - {inputs.type === 1 && ( - - handleInputChange('openai_organization', value) - } - /> - )} - - {/* API Configuration Section */} - {showApiConfigCard && ( -
+ + ) : inputs.type === 41 && + (inputs.vertex_key_type || 'json') === 'json' ? ( + <> + {!batch && ( +
+ + {t('密钥输入方式')} + + + + + +
+ )} - {inputs.type === 40 && ( - - {t('邀请链接')}: - - window.open( - 'https://cloud.siliconflow.cn/i/hij0YNTZ', - ) - } - > - https://cloud.siliconflow.cn/i/hij0YNTZ - -
- } - className='!rounded-lg' - /> - )} + {batch && ( + + )} - {inputs.type === 3 && ( - <> - -
- + handleInputChange('key', value) + } + extraText={ +
+ + {t('请输入完整的 JSON 格式密钥内容')} + + {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
+ } + autosize + showClear + /> + ) : ( + } + dragMainText={t( + '点击上传文件或拖拽文件到这里', + )} + dragSubText={t('仅支持 JSON 文件')} + style={{ marginTop: 10 }} + uploadTrigger='custom' + beforeUpload={() => false} + onChange={handleVertexUploadChange} + fileList={vertexFileList} + rules={ + isEdit + ? [] + : [ + { + required: true, + message: t('请上传密钥文件'), + }, + ] + } + extraText={batchExtra} + /> )} - onChange={(value) => - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
-
+ + ) : ( - handleInputChange('other', value) + field='key' + label={ + isEdit + ? t('密钥(编辑模式下,保存的密钥不会显示)') + : t('密钥') } - showClear - /> -
-
- - handleChannelOtherSettingsChange( - 'azure_responses_version', - value, - ) + handleInputChange('key', value) + } + extraText={ +
+ {isEdit && + isMultiKeyChannel && + keyMode === 'append' && ( + + {t( + '追加模式:新密钥将添加到现有密钥列表的末尾', + )} + + )} + {isEdit && ( + + )} + {batchExtra} +
} showClear /> -
+ )} )} - {inputs.type === 8 && ( + {isEdit && isMultiKeyChannel && ( + setKeyMode(value)} + extraText={ + + {keyMode === 'replace' + ? t('覆盖模式:将完全替换现有的所有密钥') + : t('追加模式:将新密钥添加到现有密钥列表末尾')} + + } + /> + )} + {batch && multiToSingle && ( <> - { + setMultiKeyMode(value); + handleInputChange('multi_key_mode', value); + }} /> -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} + className='!rounded-lg mt-2' /> -
+ )} )} - {inputs.type === 37 && ( - + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 41 && ( + + handleInputChange('other', value) + } + rules={[ + { required: true, message: t('请填写部署地区') }, + ]} + template={REGION_EXAMPLE} + templateLabel={t('填入模板')} + editorType='region' + formApi={formApiRef.current} + extraText={t('设置默认地区和特定模型的专用地区')} /> )} - {inputs.type !== 3 && - inputs.type !== 8 && - inputs.type !== 22 && - inputs.type !== 36 && - (inputs.type !== 45 || doubaoApiEditUnlocked) && ( -
- - handleInputChange('base_url', value) + {inputs.type === 21 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 39 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 49 && ( + + handleInputChange('other', value) + } + showClear + /> + )} + + {inputs.type === 1 && ( + + handleInputChange('openai_organization', value) + } + /> + )} + + {/* API Configuration Section */} + {showApiConfigCard && ( +
+ {inputs.type === 40 && ( + + {t('邀请链接')}: + + window.open( + 'https://cloud.siliconflow.cn/i/hij0YNTZ', + ) + } + > + https://cloud.siliconflow.cn/i/hij0YNTZ + +
} - showClear - disabled={isIonetLocked} - extraText={t( - '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + className='!rounded-lg' + /> + )} + + {inputs.type === 3 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+
+ + handleInputChange('other', value) + } + showClear + /> +
+
+ + handleChannelOtherSettingsChange( + 'azure_responses_version', + value, + ) + } + showClear + /> +
+ + )} + + {inputs.type === 8 && ( + <> + +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ + )} + + {inputs.type === 37 && ( + -
- )} + )} - {inputs.type === 22 && ( -
- + + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + extraText={t( + '对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写', + )} + /> +
)} - onChange={(value) => - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} - {inputs.type === 36 && ( -
- - handleInputChange('base_url', value) - } - showClear - disabled={isIonetLocked} - /> -
- )} + {inputs.type === 22 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} - {inputs.type === 45 && !doubaoApiEditUnlocked && ( -
- - handleInputChange('base_url', value) - } - optionList={[ - { - value: 'https://ark.cn-beijing.volces.com', - label: 'https://ark.cn-beijing.volces.com', - }, - { - value: - 'https://ark.ap-southeast.bytepluses.com', - label: - 'https://ark.ap-southeast.bytepluses.com', - }, - { - value: DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, - label: doubaoCodingPlanOptionLabel, - disabled: !canKeepDeprecatedDoubaoCodingPlan, - }, - ]} - defaultValue='https://ark.cn-beijing.volces.com' - disabled={isIonetLocked} - /> + {inputs.type === 36 && ( +
+ + handleInputChange('base_url', value) + } + showClear + disabled={isIonetLocked} + /> +
+ )} + + {inputs.type === 45 && !doubaoApiEditUnlocked && ( +
+ + handleInputChange('base_url', value) + } + optionList={[ + { + value: 'https://ark.cn-beijing.volces.com', + label: 'https://ark.cn-beijing.volces.com', + }, + { + value: + 'https://ark.ap-southeast.bytepluses.com', + label: + 'https://ark.ap-southeast.bytepluses.com', + }, + { + value: + DEPRECATED_DOUBAO_CODING_PLAN_BASE_URL, + label: doubaoCodingPlanOptionLabel, + disabled: + !canKeepDeprecatedDoubaoCodingPlan, + }, + ]} + defaultValue='https://ark.cn-beijing.volces.com' + disabled={isIonetLocked} + /> +
+ )}
)} -
- )} - {/* Model Selection - Part of Core Config */} - setModelSearchValue(value)} - innerBottomSlot={ - modelSearchHintText ? ( - - {modelSearchHintText} - - ) : null - } - style={{ width: '100%' }} - onChange={(value) => handleInputChange('models', value)} - renderSelectedItem={(optionNode) => { - const modelName = String(optionNode?.value ?? ''); - return { - isRenderInTag: true, - content: ( - { - e.stopPropagation(); - const ok = await copy(modelName); - if (ok) { - showSuccess( - t('已复制:{{name}}', { name: modelName }), - ); - } else { - showError(t('复制失败')); - } - }} - > - {optionNode.label || modelName} - - ), - }; - }} - extraText={ - - - {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + {/* Model Selection - Part of Core Config */} + setModelSearchValue(value)} + innerBottomSlot={ + modelSearchHintText ? ( + + {modelSearchHintText} + + ) : null + } + style={{ width: '100%' }} + onChange={(value) => handleInputChange('models', value)} + renderSelectedItem={(optionNode) => { + const modelName = String(optionNode?.value ?? ''); + return { + isRenderInTag: true, + content: ( + { + e.stopPropagation(); + const ok = await copy(modelName); + if (ok) { + showSuccess( + t('已复制:{{name}}', { + name: modelName, + }), + ); + } else { + showError(t('复制失败')); + } + }} + > + {optionNode.label || modelName} + + ), + }; + }} + extraText={ + - )} - handleInputChange('models', fullModels) }, - ...(inputs.type === 4 && isEdit ? [{ node: 'item', name: t('Ollama 模型管理'), onClick: () => setOllamaModalVisible(true) }] : []), - { node: 'divider' }, - { node: 'item', name: t('复制所有模型'), onClick: () => { - if (inputs.models.length === 0) { showInfo(t('没有模型可以复制')); return; } - try { copy(inputs.models.join(',')); showSuccess(t('模型列表已复制到剪贴板')); } catch (error) { showError(t('复制失败')); } - }}, - { node: 'item', name: t('清除所有模型'), type: 'danger', onClick: () => handleInputChange('models', []) }, - ...((modelGroups && modelGroups.length > 0) ? [ + {MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type) && ( + + )} + + handleInputChange('models', fullModels), + }, + ...(inputs.type === 4 && isEdit + ? [ + { + node: 'item', + name: t('Ollama 模型管理'), + onClick: () => + setOllamaModalVisible(true), + }, + ] + : []), { node: 'divider' }, - ...modelGroups.map((group) => ({ + { node: 'item', - name: group.name, + name: t('复制所有模型'), onClick: () => { - let items = []; + if (inputs.models.length === 0) { + showInfo(t('没有模型可以复制')); + return; + } try { - if (Array.isArray(group.items)) { items = group.items; } - else if (typeof group.items === 'string') { - const parsed = JSON.parse(group.items || '[]'); - if (Array.isArray(parsed)) items = parsed; - } - } catch {} - const current = formApiRef.current?.getValue('models') || inputs.models || []; - const merged = Array.from(new Set([...current, ...items].map((m) => (m || '').trim()).filter(Boolean))); - handleInputChange('models', merged); + copy(inputs.models.join(',')); + showSuccess(t('模型列表已复制到剪贴板')); + } catch (error) { + showError(t('复制失败')); + } }, - })), - ] : []), - ]} + }, + { + node: 'item', + name: t('清除所有模型'), + type: 'danger', + onClick: () => + handleInputChange('models', []), + }, + ...(modelGroups && modelGroups.length > 0 + ? [ + { node: 'divider' }, + ...modelGroups.map((group) => ({ + node: 'item', + name: group.name, + onClick: () => { + let items = []; + try { + if (Array.isArray(group.items)) { + items = group.items; + } else if ( + typeof group.items === 'string' + ) { + const parsed = JSON.parse( + group.items || '[]', + ); + if (Array.isArray(parsed)) + items = parsed; + } + } catch {} + const current = + formApiRef.current?.getValue( + 'models', + ) || + inputs.models || + []; + const merged = Array.from( + new Set( + [...current, ...items] + .map((m) => (m || '').trim()) + .filter(Boolean), + ), + ); + handleInputChange('models', merged); + }, + })), + ] + : []), + ]} + > + + + + } + /> + + {/* Custom Model Name - Core Config */} + setCustomModel(value.trim())} + value={customModel} + suffix={ + - - - } - /> + {t('填入')} + + } + /> - {/* Custom Model Name - Core Config */} - setCustomModel(value.trim())} - value={customModel} - suffix={ - - } - /> + {/* Groups - Core Config */} + handleInputChange('groups', value)} + /> - {/* Groups - Core Config */} - handleInputChange('groups', value)} - /> + {/* Model Mapping - Core Config */} + + handleInputChange('model_mapping', value) + } + template={MODEL_MAPPING_EXAMPLE} + templateLabel={t('填入模板')} + editorType='keyValue' + formApi={formApiRef.current} + renderStringValueSuffix={({ pairKey, value }) => { + if (!MODEL_FETCHABLE_CHANNEL_TYPES.has(inputs.type)) { + return null; + } + const disabled = !String(pairKey ?? '').trim(); + return ( + +