feat: 新增 VolcAdapter (58) 渠道——火山方舟原生 API 兼容代理 - #4702
Conversation
Add Volc task list route adaptation and explicit unsupported DELETE behavior, preserve Ark video payload fields, and update official seedream/seedance model IDs with focused test coverage. Made-with: Cursor
…dpoint types Task 1 – Move image route to relay-router.go: - Register /volc/api/v3 group in relay-router.go with only POST /images/generations (synchronous relay via controller.Relay). - Remove POST /images/generations from video-router.go volcV3Router (async task group). - Drop now-unused `types` import from video-router.go. Task 2 – Trim volcengine/constants.go: - Remove doubao-seedance-* and bare seedance-* video model entries from relay/channel/volcengine/constants.go (wrong channel — they belong on doubao-video). - Keep all doubao-seedream-* and bare seedream-* image entries plus LLM entries. - Move seedance-* bare aliases to relay/channel/task/doubao/constants.go and add all official date-suffixed IDs from the 2026-04-27 Ark API scan. Task 3 – Rewrite middleware/volc_adapter_test.go: - Extract runVolcMiddlewareCase helper to eliminate boilerplate. - Replace veo-1 with realistic doubao-seedance-2-0-260128 model. - Add TestVolcConvert_ImageGeneration_T2I, _I2I (doubao-seedream models, image array). - Add TestVolcConvert_VideoSubmit_T2V (action set), _I2V (action absent when image present). - Improve VideoFetchByID, VideoList, VideoDelete tests with full assertions. - Add TestVolcConvert_RequestKeyFallback: table-driven model/prompt fallback chain (model → model_name → req_key, prompt → content). - Add TestVolcConvert_InvalidBody: invalid JSON and empty body → 400. - Assert KeyRequestBody context key via deep-equal on all submit/image cases. - Assert metadata deep-equals the entire original request body (not spot-checked). Task 4 – Add volc endpoint types for pricing/marketplace UI: - Add EndpointTypeVolcImage = "volc-image" and EndpointTypeVolcVideo = "volc-video" to constant/endpoint_type.go. - Register default paths in common/endpoint_defaults.go: volc-image → POST /volc/api/v3/images/generations volc-video → POST /volc/api/v3/contents/generations/tasks - Add "seedream" to common/model.go ImageGenerationModels so IsImageGenerationModel matches doubao-seedream-* and seedream-* names. - Update GetEndpointTypesByChannelType in common/endpoint_type.go: ChannelTypeDoubaoVideo → [volc-video, openai-video] ChannelTypeVolcEngine + image model → [volc-image, image-generation, openai] ChannelTypeVolcEngine + LLM/other → fall through to default (openai) - Pricing API wiring verified: supportedEndpointMap is auto-populated via GetDefaultEndpointInfo; no controller changes needed. - i18n: PricingEndpointTypes.jsx uses raw endpointType string as label (getEndpointTypeLabel returns endpointType verbatim); no translation keys exist for any endpoint type in the JSON locale files, so "volc-image"/"volc-video" will display as raw strings — consistent with all other endpoint types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Volc's official SDKs default to base URL https://ark.cn-beijing.volces.com and request /api/v3/...; mounting our compat gateway at the same path means users only need to change the domain. There's no collision with existing /api/* admin routes (those are all specific paths, none under /api/v3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Splits the volc-compat API endpoints onto a dedicated channel type so ChannelTypeVolcEngine (45) and ChannelTypeDoubaoVideo (54) revert to their pre-cleanup minimal scope (LLM/TTS and OpenAI-format video tasks respectively). The new VolcAdapter channel reuses existing volcengine and taskdoubao adaptors but ships its own default model list (seedream + seedance) and binds the volc-image/volc-video endpoint types. Display name chosen: "VolcAdapter" (matches the naming style of existing single-word entries like "VolcEngine", "DoubaoVideo", etc.) Channel type number: 58 — verified as next available after ChannelTypeCodex=57. ChannelTypeDummy becomes 58 (same value, per Go const repeat semantics). Changes by task: - Task 1.1: constant/channel.go — add ChannelTypeVolcAdapter=58, base URL, display name; common/api_type.go — map to APITypeVolcEngine - Task 1.2: relay/relay_adaptor.go — add VolcAdapter to taskdoubao case; relay/common/relay_info.go — add to streamSupportedChannels - Task 1.3: relay/channel/volcadapter/constants.go — new package with seedream + seedance model list; controller/model.go — register in openAIModels and override channelId2Models entry - Task 1.4: common/endpoint_type.go — remove VolcEngine/DoubaoVideo volc-* endpoint bindings; add ChannelTypeVolcAdapter case with per-model dispatch - Task 1.5: relay/channel/volcengine/constants.go — revert to pre-worktree state (LLM + seedream + seedance + seed-thinking); relay/channel/task/doubao/constants.go — revert (7 official + 2 legacy, no bare date aliases) - Task 1.6: relay/relay_task.go — change list filter to VolcAdapter platform; relay/relay_task_volc_list_test.go — update platform + add coexistence test - Task 1.7: middleware unchanged (no channel-type refs) - Task 1.8: web/src/constants/channel.constants.js — add type 58 to CHANNEL_OPTIONS; web/src/helpers/render.jsx — add case 58 icon (Doubao) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that the volc-compat gateway lives on its own ChannelTypeVolcAdapter channel, the existing VolcEngine (45) and DoubaoVideo (54) channels no longer need to advertise the full set of seedream/seedance models. Match upstream/main's minimal model lists so this branch stays close to the upstream merge base — the new-and-shiny seedream/seedance entries are available on the dedicated VolcAdapter channel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds RelayFormatVolc and routes the volc-compat image endpoint through a dedicated handler (mirroring the Gemini pattern) instead of cursor's body-rewriting middleware. Volc body fields (size 2K/4K, sequential_image_generation, optimize_prompt_options, watermark, etc.) flow through to upstream without translation. Changes: - types/relay_format.go: add RelayFormatVolc constant - dto/volc_image_request.go: new DTO that captures known Volc fields plus an Extra map for unknown fields (preserves byte-identity) - relay/channel/adapter.go: add ConvertVolcRequest to Adaptor interface - relay/channel/volcengine/adaptor.go: no-op pass-through implementation - relay/channel/*/adaptor.go (31 files): "unsupported" stub implementations - relay/helper/valid_request.go: add RelayFormatVolc case + validator - relay/volc_handler.go: new VolcImageHelper (mirrors GeminiHelper) - relay/common/relay_info.go: GenRelayInfoVolc + GenRelayInfo case - controller/relay.go: volcRelayHandler + RelayFormatVolc switch case - router/relay-router.go: image route uses RelayFormatVolc, removes VolcRequestConvert() middleware from the image route group The task path still uses the existing middleware in this commit; the next commit migrates it and deletes the middleware entirely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrates the volc-compat task endpoints (submit, fetch, list, delete)
to native RelayFormatVolc pass-through, matching the image side from
the prior commit. Body bytes flow byte-identical to upstream.
Deletes middleware/volc_adapter.go and its rewritten test now that
both image and task paths bypass the middleware.
Key changes:
- controller/relay.go: add RelayTaskVolcSubmit (GenRelayInfo with
RelayFormatVolc), RelayTaskFetchVolc, RelayTaskVolcDelete (501)
- relay/common/relay_info.go: GenRelayInfo handles RelayFormatVolc
with nil request as task-style relay info (with TaskRelayInfo)
- relay/channel/task/doubao/adaptor.go:
- ValidateRequestAndSetAction: branches on RelayFormatVolc to parse
Volc-native body minimally (model + content[] action detection)
- BuildRequestBody: branches on RelayFormatVolc to forward raw bytes
byte-identical; model-mapping patches only the model field
- EstimateBilling: branches on RelayFormatVolc to read video_url
from raw body content[] instead of TaskSubmitReq.Metadata
- router/video-router.go: volcV3Router uses new controllers directly,
no VolcRequestConvert() middleware; relay_mode and task_id are set
inline via route-level closures
- middleware/volc_adapter.go: DELETED
- middleware/volc_adapter_test.go: DELETED
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a mechanism for shipping default tiered-billing expressions in code. Default expressions activate only when: - Admin hasn't set BillingMode in DB - Admin hasn't set BillingExpr in DB - Admin hasn't customized ModelRatio in DB Architecture: B (lazy fallback). GetBillingMode/GetBillingExpr check defaultBillingExpr at lookup time with no startup mutation. Chosen over Architecture A because the DB load (loadOptionsFromDatabase) runs *after* InitRatioSettings, so at apply-time we cannot distinguish a DB-set ratio from a code-default ratio that the admin resaved; lazy lookup avoids the ambiguity entirely and keeps the hot path a simple two-map check. Added ratio_setting.IsModelRatioCustomized(model) to detect whether an admin explicitly changed ModelRatio for a model: if the model is absent from defaultModelRatio, any value in modelRatioMap came from DB; if it is present in defaultModelRatio, a differing value signals a DB override. The default map is empty in this commit; Step 4 populates it with seedance video expressions covering 1080p resolution and video-input discount tiers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This reverts commit e6d31ca8c. Defaults for Seedance billing will live in the UI's existing PRESET_GROUPS template list in TieredPricingEditor.jsx instead of a Go-side fallback mechanism. Existing deployments stay on legacy ratio mode unchanged; admins who want tiered billing for these models opt in via the UI preset. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds six tiered_expr presets to the Tiered Pricing Editor's "请求条件" group covering doubao-seedance models with their actual RMB/1M-output-token rates: - Seedance 2.0: 46/28 base, 51/31 at 1080p (text/with-video) - Seedance 2.0 Fast: 37/22 (text/with-video) - Seedance 1.5 Pro: 16/8 (with-audio/silent) - Seedance 1.0 Pro: 15/7.5 (online/flex) - Seedance 1.0 Pro Fast: 4.2/2.1 (online/flex) - Seedance 1.0 Lite: 10/5 (online/flex) Each expression coalesces native Volc body shape (top-level fields) and OpenAI-format-wrapped shape (metadata.*) so the same preset applies whether the request hits /api/v3/* or /v1/video/generations on a VolcAdapter channel. Token estimation formula per Volc docs: (input_video_duration + output_video_duration) × output_width × output_height × output_fps / 1024 Final billing uses upstream's usage.completion_tokens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous preset format `(cond) ? tier(...) : tier(...)` failed to
produce a valid cost estimate in the UI cost-estimator (evalExprLocally).
The local JS evaluator builds a minimal env {p, c, len, tier, math-helpers}
and calls `new Function(...)` on the raw expression string. When param()
appears before tier() — i.e. as the condition of the outermost ternary —
JavaScript throws `ReferenceError: param is not defined` before tier() is
ever reached, so the estimator shows an error and reports cost=0.
Investigation findings:
- BOTH old and new formats compile and run correctly in the Go billingexpr
engine (pkg/billingexpr); smokeTestExpr passes for both forms.
- The failure is UI-only: evalExprLocally in TieredPricingEditor.jsx did
not include param(), header(), has(), or nil in its JS eval environment.
- With the multiplicative form `tier("base", c * X) * (param(...) ? ...)`,
tier() executes first (JavaScript evaluates left-to-right) and returns a
valid float. param() is still evaluated as part of the multiplier, so the
error still occurs — meaning the fix also requires adding param/nil stubs
to evalExprLocally. Both changes are included in this commit.
- Root cause: evalExprLocally is a preview-only estimator; it intentionally
cannot inspect the real request body. param() stubs returning null allow
the expression to evaluate to the base-tier price (all multipliers resolve
to their else-branch with null inputs), giving a meaningful preview.
Changes:
1. Rewrites all 6 Seedance presets as `tier("base", c * <base>) * <mult>`
- doubao-seedance-2-0: 46 base × (res+video multipliers)
- doubao-seedance-2-0-fast: 37 base × (video multiplier)
- doubao-seedance-1-5-pro: 16 base × (silent 0.5 multiplier)
- doubao-seedance-1-0-pro: 15 base × (flex 0.5 multiplier)
- doubao-seedance-1-0-pro-fast: 4.2 base × (flex 0.5 multiplier)
- doubao-seedance-1-0-lite: 10 base × (flex 0.5 multiplier)
2. Adds param(), header(), has(), nil stubs to evalExprLocally so any
expression using request probes renders a valid base-price preview.
3. Adds permanent regression test at pkg/billingexpr/seedance_presets_test.go
covering every tier × body-shape combination (31 cases total):
- 8 cases for sd2-0 (4 combos × 2 body shapes)
- 4 cases for sd2-0-fast (2 combos × 2 body shapes)
- 5 cases for sd1-5-pro (3 native + 2 wrapped, including default)
- 4 cases for sd1-0-pro, sd1-0-pro-fast, sd1-0-lite (2 × 2 each)
- 6 smoke-test cases (one per model via billing_setting.SmokeTestExpr)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous inline param() form failed in upstream new-api v0.13.1's
evalExprLocally because that version lacks param/header stubs. Switching
to structured `requestRules` (matching existing claude-opus-fast /
gpt-5.4-tiers pattern) keeps `expr` as just `tier("base", c * BASE)` so
preview evaluates cleanly while the backend still receives the full
conditional expression after combineBillingExpr.
Multiplicative composition is used instead of a 4-way matrix because
the rule system doesn't support negation. For Seedance 2.0, this
introduces ~0.13% drift on the 1080p+video tier (31.04 instead of
31.00); accepted as Volc may publish 31 as a rounded display price.
Each conditional dimension has 2 rules (top-level path + metadata.*
path) to handle both /api/v3 native and /v1/video/generations OpenAI-
format request shapes.
Also relocates the regression test from pkg/billingexpr (upstream
shared) to relay/channel/volcadapter (Graylight channel that hosts
seedance models) for cleaner upstream merge boundaries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Comment out EndpointTypeVolcImage/Video and the VolcAdapter case in
GetEndpointTypesByChannelType, matching upstream's pattern of leaving
task-style channels (Kling/Jimeng/Suno/Midjourney) without dedicated
endpoint types. Channel 58 falls through to the OpenAI default in
the marketplace UI, consistent with existing task channels.
2. Merge RelayTaskVolcSubmit / RelayTaskFetchVolc / RelayTaskVolcDelete
back into the existing RelayTask / RelayTaskFetch by reading
RelayFormat from a context key ("relay_format"). Removes ~90 lines of
duplicated retry-loop logic. Routes set the format inline before
dispatching; RelayTaskVolcDelete is replaced by a 5-line inline closure.
3. Translate VolcAdapter channel selector label to Chinese to match
the rest of channel.constants.js ('火山方舟兼容 (Seedream + Seedance)').
4. Remove the param/header/has/nil stubs added to evalExprLocally in
commit 81d37dced. They became dead code after switching to structured
requestRules — the expr field is now just `tier("base", c * X)` with
no param() calls.
5. Drop the redundant EndpointTypeImageGeneration entry from the
VolcAdapter image-model return list (now moot since the case is
commented out). Test cases updated to use string literals for
volc-image/volc-video absence assertions since the constants are
now commented out.
Plan Y (extending task billing with tiered_expr support) is still
being discussed; estimateBillingVolcNative, videoInputRatioMap, and
the seedance presets are intentionally untouched in this commit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plan B1 implementation: extends task billing flow to support BillingMode=tiered_expr, making the 6 seedance presets actually fire (previously dead config — see commit message for fcfbb6629 for the discovery). Investigation finding (Step 0): Option A — tiered_expr restricted to RelayFormatVolc requests. EstimatedBillingTokens==0 guard ensures OpenAI-format entry (/v1/video/generations) stays on ratio billing. metadata.* rules dropped from all 6 seedance presets accordingly. Pre-charge (relay/helper/price.go): - ModelPriceHelperPerCall branches on BillingMode==tiered_expr && EstimatedBillingTokens>0 → modelPriceHelperTieredForTask() - Runs billingexpr.RunExprWithRequest() with estimated tokens, freezes BillingSnapshot + BillingRequestInput on RelayInfo Token estimator (relay/channel/task/doubao/seedance_estimator.go): - EstimateSeedanceTokens(modelName, body []byte) int64 - Formula: (inputVideoDurSec + outputDurSec) × W × H × 24 / 1024 - Conservative over-estimate: input video capped at 15 s - Looks up model default resolution + max duration from tables - Returns 0 on unknown model → falls back to ratio billing Interface plumbing: - TaskAdaptor.EstimateBillingTokens(c, info) added to interface - BaseBilling provides zero default (no-op for non-seedance adaptors) - Doubao TaskAdaptor overrides; guards on RelayFormatVolc - relay_task.go sets info.EstimatedBillingTokens before price helper; wraps EstimateBilling OtherRatios in TieredBillingSnapshot==nil guard Settlement (service/task_billing.go): - RecalculateTaskQuotaByTokens branches on TieredSnapshot != nil - Replays RunExprByHashWithRequest with actual token count - Falls through to ratio settlement on expression error Persistence (model/task.go, controller/relay.go): - TaskBillingContext gains TieredSnapshot + TieredRequestBody fields - controller/relay.go copies snapshot from RelayInfo to task record Tests: - seedance_estimator_test.go: 15 subtests covering resolution parsing, duration/frames, video-input detection, unknown model fallback - seedance_presets_test.go: 22 tests updated for simplified expressions (metadata.* cases removed; Option A) - go build ./relay/... ./service/... ./model/... ./controller/... clean - go test ./relay/channel/task/doubao/... PASS (all 15) - go test ./relay/channel/volcadapter/... PASS (all 22) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plan B1 (commit 975494f6f) implemented task tiered_expr support but made three architectural mistakes: 1. Volc-specific code lived in taskdoubao.TaskAdaptor, polluting an adaptor shared by channels 45 (VolcEngine) / 54 (DoubaoVideo) / 58 (VolcAdapter). 2. Tiered_expr settlement was added to generic service/task_billing.go RecalculateTaskQuotaByTokens, when the proper extension point — adaptor.AdjustBillingOnComplete — already exists in task_polling.go:540-560 (called BEFORE the token-recalc fallback). 3. BillingContext stored ~1-2 KB of raw request body per task to feed billingexpr param() lookups at settlement, while task.Data already has the Volc fetch response with resolution/duration/service_tier. This commit: - Extracts relay/channel/task/volcadapter/ as the dedicated adaptor for ChannelTypeVolcAdapter, embedding taskdoubao.TaskAdaptor for shared task plumbing (ParseTaskResult, FetchTask, etc). - Cleans Volc-specific branches and helpers out of taskdoubao, bringing it close to its upstream/main state. - Implements tiered_expr settle in volcadapter.AdjustBillingOnComplete, reading expression hash + 3 flags from BillingContext and resolution/duration/service_tier from task.Data (Volc fetch result). - Reverts the tiered_expr branch in service/task_billing.go. - Replaces TieredRequestBody []byte with TieredVolcFlags struct (~30 bytes vs ~1-2 KB per task). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e merge 1. Drop the now-commented EndpointTypeVolcImage/Video references from common/endpoint_defaults.go, common/endpoint_type.go, and constant/endpoint_type.go. These three files now match upstream/main byte-for-byte, simplifying future merges. 2. Inline volcRelayHandler (a one-line wrapper calling relay.VolcImageHelper) into the switch case in controller/relay.go, matching the style of WssHelper / ClaudeHelper / etc. 3. Remove ConvertVolcRequest from the Adaptor interface. It only had one real implementation (volcengine.Adaptor's no-op pass-through); all other 30+ stub implementations existed solely to satisfy the interface. Replace with a local type-assertion in relay/volc_handler.go's VolcImageHelper. 4. Merge relay/channel/volcadapter/ into relay/channel/task/volcadapter/. The former held only the channel's ModelList/ChannelName constants; the latter held the task adaptor. Same package name, same domain — no reason to split. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DraftTask struct and ContentItem.DraftTask field were added in cursor's feat/volc-adapter as part of the OpenAI-format draft_task content type passthrough for Seedance 1.5 pro. Upstream new-api does not have this feature. VolcAdapter (channel 58) already supports draft_task via raw body pass-through at /api/v3/*; users who want this feature should use the native Volc-compat path. The OpenAI-format /v1/video/generations entry on channels 45/54 reverts to upstream behavior (no DraftTask). Reduces fork divergence from upstream/main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tions route Path2RelayMode only handles /v1/... paths; the native Volc Ark image route at /api/v3/images/generations was leaving RelayMode=0 (Unknown), causing volcengine.Adaptor.GetRequestURL to return "unsupported relay mode: 0". Fix: explicitly set relay_mode context key to RelayModeImagesGenerations before calling controller.Relay, matching the pattern already used for task routes in video-router.go. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs found and fixed during Volc SDK e2e testing: 1. relay/relay_task.go: videoFetchByIDRespBodyBuilder now returns the Volc-native ContentGenerationTask JSON (patching id to public task ID) when relay_format is "volc". Without this the SDK's polling loop received an internal TaskDto wrapper with code/data fields instead of the flat id/status/usage/content structure the SDK expects. 2. middleware/distributor.go: GET /api/v3/contents/generations/tasks/:id no longer requires a model name in the request body. The new branch sets shouldSelectChannel=false for GET (task fetch) and reads model from body for POST (task submit). Without this every task status poll aborted with "Model name not specified". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds unit tests for security boundaries identified during Tier 1 E2E review: - List filter user_id spoofing rejected (token-derived only) - Volc body validator handles malformed inputs cleanly - Task model not in any channel returns clean 4xx - Path traversal in task_id treated as literal lookup - Channel-Id header restricted to admin (verify upstream behavior preserved) Tier 1 tests live in scripts/volc-local-test/ as E2E integration. These are the unit-test counterparts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r channel
Reverses an earlier cleanup decision (commits f97a0c616 + 1f71ca830) that
commented out the volc-image and volc-video endpoint types to match
upstream's Kling/Jimeng/Suno/Midjourney pattern. UX feedback: with the
types removed, the model marketplace shows "openai" as the primary
endpoint for seedream/seedance models served by ChannelTypeVolcAdapter,
which is misleading — the gateway's native paths are /api/v3/*, not
/v1/*.
Restores:
- EndpointTypeVolcImage = "volc-image" → /api/v3/images/generations
- EndpointTypeVolcVideo = "volc-video" → /api/v3/contents/generations/tasks
- GetEndpointTypesByChannelType[VolcAdapter] dispatches per-model:
seedream → [volc-image, image-generation, openai]
seedance → [volc-video, openai-video]
- Tests guard that VolcEngine (45) and DoubaoVideo (54) still
fall through to defaults — only channel 58 surfaces volc-* types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the 501 stub at DELETE /api/v3/contents/generations/tasks/:id. Verifies ownership, forwards DELETE to upstream Volc with channel API key, updates local task to cancelled status, and refunds pre-charge quota. Handler uses only common/constant/logger/model/service — no graylight deps. Ported from 5d9b05a2c (graylight/track-e-tos-signer), callback handler excluded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove the 12 seedream-*/seedance-* alias entries (without the doubao- prefix) and their two comment headers. ModelList now contains only the 12 canonical doubao-prefixed IDs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…S list
fix(billing): use gjson-standard double-quoted path in video_url check
The `param("content.#(type=='video_url')")` runtime check never fired because
gjson only accepts double-quoted string literals inside `#()` filters; the
single-quote form was treated as a literal apostrophe and never matched actual
values. This caused the 0.608696× video-input discount on
doubao-seedance-2-0-260128 and the 0.594595× discount on
doubao-seedance-2-0-fast-260128 to be silently skipped, overcharging admins
~64% for video-input requests.
Changes:
- TieredPricingEditor.jsx: update both seedance 2.0 PRESET_GROUPS entries to
use `content.#(type=="video_url")` (double-quoted gjson syntax, JS
single-quoted outer string).
- requestRuleExpr.js: replace all `[^"]+` path-capture regexes in
tryParseRequestCondition with `((?:[^"\\]|\\.)*)` plus unescapePath() helper,
so that round-tripping paths containing escaped double-quotes works correctly.
- requestRuleExpr.test.js: 7 vitest tests covering build→expr, expr→parse, full
round-trip, multi-group (seedance 2.0), and a regression test proving the old
single-quoted form was broken.
- web/package.json: add `test` script (`vitest run`).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ChannelTypeVolcAdapter (58) previously routed through APITypeVolcEngine to volcengine.Adaptor, which required adding a no-op ConvertVolcRequest to the volcengine package. This couples our new channel to the existing Volc channel and means any change to volcengine implicitly affects volcadapter. Add a dedicated APITypeVolcAdapter and channel.Adaptor implementation under relay/channel/volcadapter/. The new adaptor only supports the Volc-native /api/v3/images/generations endpoint; all other relay modes return a clear "not supported" error directing users to the volcengine channel for OpenAI-format requests. relay/channel/volcengine/ is now identical to upstream/main. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
VolcTaskDelete and its helpers (buildVolcDeleteResp, isTerminalStatus, volcDeleteMapStatus) previously lived in controller/graylight/, which the upstream PR cannot depend on. Move them to controller/ proper — the package was also empty after this move, so the directory is gone. router/video-router.go now imports only the single controller package instead of both controller and controller/graylight. No behavior change.
…ugh paths Both the image handler (relay/volc_handler.go) and the task adaptor (relay/channel/task/volcadapter/adaptor.go) were forwarding raw body bytes without applying model mapping or param override after reading from body storage. Image path: extract applyVolcImagePatches helper that operates on map[string]json.RawMessage so all unknown Volc-specific fields (sequential_image_generation, optimize_prompt_options, watermark, etc.) are preserved while patching "model" and injecting ParamOverride fields. Task path: add ApplyParamOverrideWithRelayInfo call after the existing patchVolcBodyModel / model-extraction block, before any future injectSafetyIdentifier / injectCallbackURL steps. Tests: 4 new unit tests for applyVolcImagePatches covering model mapping, no-op when not mapped, param override injection, and both combined; 1 new test for BuildRequestBody asserting ParamOverride is applied while preserving unknown fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without an explicit p term in the expression, the frontend tier breakdown UI fails to render the prompt-cost line. Adding p*0 keeps the cost identical but lets the UI parse the expression. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…billing.go The seed contained ¥-denominated rates which only make sense for CN deployments. Pre-seeding on every fresh install is wrong because USD-denominated deployments would silently get ~7× over-charges. Admin must configure billing per-deployment via the UI; the PRESET_GROUPS in TieredPricingEditor.jsx still expose curated templates as a starting point that admin can load and adjust before save. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both edits crept in during the feat/volc-adapter merge but are pure
churn — the import reordering in video-router.go and the
make()→map[]{} swap + extra comment in tiered_billing.go don't
contribute to the seedance preset feature this branch carries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
tryParseRequestCondition runs on every UI edit in the tiered-pricing editor. Compiling six RegExp objects per call is avoidable overhead; hoisting them to module-scope constants compiles them once at module load. Also factors out QUOTED_VALUE_RE for the two has()/== templates that share that fragment, and the numeric op→MATCH_* map. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The quoted-value regex fragment accepts any \\. escape sequence, but
JSON.parse only handles JSON's escape grammar (\", \\, \/, \b,
\f, \n, \r, \t, \uXXXX). When admins type a half-finished
expression like has(header("x"), "\q") the regex matches but
JSON.parse throws, bubbling up and crashing the editor. Guard both
JSON.parse call sites with safeJsonParse → null on failure, mirroring
the unescapePath try/catch pattern, and add tests for both cases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Squashed cherry of the PR QuantumNous#5 review feedback train (private fork). Generic-only files; graylight-specific callback wiring is left out. Key changes: - service/task_billing.go: settle now wraps task.Quota persistence, funding adjustment, used_quota / channel.used_quota updates and billing log in a single DB transaction. RowsAffected guard on the targeted UPDATE so a phantom task ID aborts the settle without applying side effects. Subscription updated_at now reuses the settle timestamp. Cache invalidation moved out-of-tx to keep the primary commit fast. - service/task_polling.go: SettleTaskBillingOnComplete is exported, accepts a nil adaptor (token fallback path), prefers TotalTokens but falls back to CompletionTokens when only the latter is reported. - model/task.go: TieredVolcFlags grows resolution / duration / service_tier fields so the tiered_expr settle path can evaluate param() lookups even on callback-only deployments where task.Data is just {"id":"..."}. - controller/relay.go: extractVolcFlags refactored to call the new parseVolcDuration helper. parseVolcDuration accepts float64, int, int64, json.Number and string forms; rejects negative, non-integer and out-of-int-range values. The decoder uses json.Decoder.UseNumber so numeric JSON values arrive as json.Number rather than float64. - relay/channel/task/volcadapter/adaptor.go: buildSynthesizedBody reads resolution / duration / service_tier from TieredVolcFlags first, falling back to task.Data only when flags are absent — fixes settle on callback-only deployments. AdjustBillingOnComplete comment updated to reflect exported SettleTaskBillingOnComplete name. Tests: - service/task_billing_test.go: regression tests for the targeted UPDATE persistence, RowsAffected==0 guard, funding rollback, CAS win/lose on both refund and settle, nil-adaptor token fallback, PerCallBilling skip with nil adaptor, effectiveTokenCount fallback. - controller/relay_volc_flags_test.go: parseVolcDuration string / json.Number / oversized / non-integer cases. - relay/channel/task/volcadapter/adaptor_test.go: two new cases for callback-path flags carrying resolution and flags taking priority over task.Data in buildSynthesizedBody. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (56)
WalkthroughAdds a new VolcAdapter channel (58) with Ark-native image and video task routes, adaptors, distributor/routing, token estimation, tiered billing, task delete/fetch/list, model catalogs, request validation, and extensive tests. UI gains channel option and Seedance tier presets. ChangesVolcAdapter Ark-native integration
Sequence Diagram(s)sequenceDiagram
participant Client
participant Gateway
participant Adaptor
participant VolcArk
participant Billing
Client->>Gateway: POST /api/v3/images/generations (Volc)
Gateway->>Adaptor: Convert/DoRequest (raw body)
Adaptor->>VolcArk: Forward request
VolcArk-->>Adaptor: Response
Adaptor-->>Gateway: OpenAI-like result
Gateway->>Billing: Post consume quota
Billing-->>Gateway: OK
Gateway-->>Client: 200 response
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new Volc Ark native pass-through channel (ChannelTypeVolcAdapter / API type 58) that forwards Volc Ark /api/v3/* requests byte-identically (preserving Volc-private fields), plus introduces tiered_expr billing integration and a transactional async-task settlement refactor. It also updates the Classic web UI to provide Seedance tiered pricing presets and improves request-rule expression parsing.
Changes:
- Add VolcAdapter (58) routing, adaptors, handlers, and Volc-native
/api/v3/images/generations+/api/v3/contents/generations/tasks*endpoints. - Integrate tiered_expr for Volc task pricing (token estimation + settlement snapshotting) and refactor async task settle/refund to commit accounting atomically.
- Enhance Classic UI tiered pricing editor presets and request-rule expression build/parse (with new tests).
Reviewed changes
Copilot reviewed 56 out of 56 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| web/classic/src/pages/Setting/Ratio/components/TieredPricingEditor.jsx | Adds Doubao Seedance tiered pricing presets. |
| web/classic/src/pages/Setting/Ratio/components/requestRuleExpr.js | Improves request-rule expression parsing/perf and escaping behavior. |
| web/classic/src/pages/Setting/Ratio/components/requestRuleExpr.test.js | Adds vitest coverage for expression round-trips/escaping. |
| web/classic/src/helpers/render.jsx | Adds icon mapping for channel type 58. |
| web/classic/src/constants/channel.constants.js | Adds UI option for VolcAdapter channel type 58. |
| web/classic/package.json | Adds vitest run test script. |
| types/relay_format.go | Introduces RelayFormatVolc constant. |
| setting/billing_setting/tiered_billing.go | Minor import formatting change. |
| service/task_polling.go | Exports settle helper, adds nil-adaptor + token fallback logic. |
| service/task_billing.go | Adds transactional settle for async task quota/funding/stat updates. |
| service/task_billing_test.go | Adds extensive tests for new transactional settle logic and edge cases. |
| router/video-router.go | Adds Volc-native task routes under /api/v3/.../tasks*. |
| router/relay-router.go | Adds Volc-native image route /api/v3/images/generations. |
| relay/volc_task_test.go | Adds tests around Volc task routing/body passthrough (but contains stale DELETE stub expectation). |
| relay/volc_handler.go | Implements Volc image handler with byte-level patching. |
| relay/volc_handler_test.go | Adds tests for Volc image handler and patching behavior. |
| relay/relay_task.go | Adds Volc-native task fetch/list response builders and tiered token estimation hook. |
| relay/relay_task_volc_list_test.go | Adds tests for Volc task list builder filtering/security invariants. |
| relay/relay_adaptor.go | Registers APITypeVolcAdapter adaptor + task adaptor routing for channel 58. |
| relay/relay_adaptor_test.go | Adds tests validating adaptor routing for VolcAdapter. |
| relay/helper/valid_request.go | Adds Volc image request validation dispatcher. |
| relay/helper/valid_request_volc_test.go | Adds tests for Volc image validation parsing/robustness. |
| relay/helper/price.go | Adds tiered_expr pricing branch for async tasks using estimated tokens. |
| relay/constant/relay_mode.go | Adds RelayModeVideoFetchList constant. |
| relay/common/relay_info.go | Adds RelayFormatVolc relay-info creation and EstimatedBillingTokens field. |
| relay/channel/volcadapter/adaptor.go | New image adaptor for VolcAdapter channel type 58. |
| relay/channel/task/volcadapter/volc_helpers.go | Volc-native task request parsing + body patch helpers. |
| relay/channel/task/volcadapter/seedance_presets_test.go | Adds regression tests for Seedance tiered_expr presets. |
| relay/channel/task/volcadapter/seedance_estimator.go | Implements Seedance token estimation formula. |
| relay/channel/task/volcadapter/estimator_test.go | Adds tests for Seedance estimator corner cases. |
| relay/channel/task/volcadapter/constants.go | Adds VolcAdapter model list constants (Seedream/Seedance). |
| relay/channel/task/volcadapter/constants_test.go | Adds tests for model list sanity (duplicates/type classification). |
| relay/channel/task/volcadapter/adaptor.go | New VolcAdapter task adaptor: byte passthrough + tiered_expr settlement. |
| relay/channel/task/volcadapter/adaptor_test.go | Adds unit tests for task adaptor settlement/body behavior. |
| relay/channel/task/taskcommon/helpers.go | Extends base billing helpers with EstimateBillingTokens default. |
| relay/channel/task/doubao/adaptor_test.go | Adds regression guards for legacy doubao task adaptor paths. |
| relay/channel/adapter.go | Extends TaskAdaptor interface with EstimateBillingTokens. |
| model/task.go | Adds TieredSnapshot + TieredVolcFlags to task billing context. |
| middleware/distributor.go | Adds Volc-native task path handling in distributor model extraction. |
| middleware/distributor_test.go | Adds tests for distributor security boundaries and Volc path behavior. |
| dto/volc_image_request.go | Adds Volc image request DTO with Extra-field capture. |
| controller/volc_task_delete.go | Adds DELETE task cancellation handler with refund + upstream forwarding. |
| controller/volc_task_delete_test.go | Adds unit tests for delete response/status mapping helpers. |
| controller/relay.go | Wires RelayFormatVolc image helper + snapshots Volc flags for tiered_expr. |
| controller/relay_volc_flags_test.go | Adds tests for Volc flags extraction and duration parsing. |
| controller/model.go | Publishes VolcAdapter model list for model listing endpoints. |
| controller/channel-test.go | Updates channel test routing for seedream models under VolcAdapter/VolcEngine. |
| constant/endpoint_type.go | Adds volc-image and volc-video endpoint types. |
| constant/channel.go | Adds ChannelTypeVolcAdapter (58) and base URL mapping. |
| constant/channel_test.go | Adds tests for VolcAdapter registration and base URL. |
| constant/api_type.go | Adds APITypeVolcAdapter constant. |
| common/model.go | Marks “seedream” as an image-generation model prefix. |
| common/endpoint_type.go | Adds endpoint type resolution for VolcAdapter (image vs video). |
| common/endpoint_type_test.go | Adds tests for VolcAdapter endpoint type mapping. |
| common/endpoint_defaults.go | Adds default endpoint infos for volc-image/volc-video. |
| common/api_type.go | Adds ChannelTypeVolcAdapter → APITypeVolcAdapter mapping. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } else if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { | ||
| // Volc-native task routes (/api/v3/contents/generations/tasks and .../tasks/:id). | ||
| // GET requests (task fetch and list) do not need channel selection. | ||
| // POST requests (task submit) extract model from the request body. | ||
| if c.Request.Method == http.MethodGet { | ||
| shouldSelectChannel = false | ||
| } else if c.Request.Method == http.MethodPost { | ||
| req, err := getModelFromRequest(c) | ||
| if err != nil { | ||
| return nil, false, err | ||
| } | ||
| if req != nil { | ||
| modelRequest.Model = req.Model | ||
| } | ||
| } |
| // TestVolcTaskDelete_Returns501 verifies that the DELETE route for volc tasks | ||
| // returns 501 Not Implemented with a recognizable message. | ||
| // The route uses an inline handler (no dedicated controller function). | ||
| func TestVolcTaskDelete_Returns501(t *testing.T) { | ||
| gin.SetMode(gin.TestMode) | ||
| w := httptest.NewRecorder() | ||
| router := gin.New() | ||
|
|
||
| // Mirror the inline handler registered in router/video-router.go. | ||
| router.DELETE("/api/v3/contents/generations/tasks/:id", func(c *gin.Context) { | ||
| c.JSON(http.StatusNotImplemented, gin.H{ | ||
| "code": "not_implemented", | ||
| "message": "DELETE /api/v3/contents/generations/tasks/:id is not supported yet", | ||
| "status_code": http.StatusNotImplemented, | ||
| }) | ||
| }) |
| // Unescape a JSON-string-escaped path (e.g. content.#(type==\"video_url\") → content.#(type=="video_url")). | ||
| // Only handles the two escape sequences that JSON.stringify emits for path strings: \" and \\. | ||
| function unescapePath(raw) { | ||
| return raw.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); | ||
| } |
| // GetAndValidateVolcImageRequest parses the native Volc Ark image request body. | ||
| // Only model presence is strictly validated; prompt/image validation is relaxed | ||
| // because Volc supports both t2i (prompt required) and i2i (image required) in | ||
| // the same endpoint and we want to pass all other fields through byte-identical. | ||
| func GetAndValidateVolcImageRequest(c *gin.Context) (*dto.VolcImageRequest, error) { | ||
| req := &dto.VolcImageRequest{} | ||
| if err := common.UnmarshalBodyReusable(c, req); err != nil { | ||
| return nil, err | ||
| } | ||
| // Accept model, model_name, or req_key as model identifier (Volc uses all three) | ||
| if req.Model == "" { | ||
| return nil, errors.New("model is required") | ||
| } |
| if json.Unmarshal(t.Data, &probe) == nil { | ||
| if _, hasStatus := probe["status"]; hasStatus { | ||
| // task.Data is an upstream Volc response — return it with the | ||
| // public task ID so the SDK's polling loop can match responses. | ||
| probe["id"] = json.RawMessage(`"` + t.TaskID + `"`) | ||
| if patched, err := json.Marshal(probe); err == nil { | ||
| return patched |
| b, err := common.Marshal(synth) | ||
| if err != nil { | ||
| return []byte(`{"id":"` + t.TaskID + `","status":"` + arkStatus + `"}`) | ||
| } |
| func countFilteredVolcVideoTasks(userID int, queryParams model.SyncTaskQueryParams, modelFilter string, taskIDsFilter map[string]bool) int64 { | ||
| totalBase := model.TaskCountAllUserTask(userID, queryParams) | ||
| if totalBase <= 0 { | ||
| return 0 | ||
| } | ||
| // DB layer has no model/task_ids columns for direct filtering; fetch then filter in-memory. | ||
| allTasks := model.TaskGetAllUserTask(userID, 0, int(totalBase), queryParams) | ||
| var filteredCount int64 |
| req, reqErr := http.NewRequestWithContext(context.Background(), http.MethodDelete, deleteURL, nil) | ||
| if reqErr != nil { | ||
| logger.LogError(c, "VolcTaskDelete: build DELETE request failed: "+reqErr.Error()) | ||
| c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) | ||
| return |
| } | ||
| b, err := common.Marshal(synth) | ||
| if err != nil { | ||
| return []byte(`{"id":"` + t.TaskID + `","status":"` + arkStatus + `"}`) |
| // On model-patch failure the function logs and continues with the un-patched body | ||
| // (conservative: avoids introducing a new error path for a non-critical patch). | ||
| // On param-override failure the function returns an error. | ||
| func applyVolcImagePatches(rawBytes []byte, info *relaycommon.RelayInfo) ([]byte, *types.NewAPIError) { | ||
| // 1. Model mapping patch — byte-level, preserves all unknown fields. | ||
| if info.IsModelMapped && info.UpstreamModelName != "" { | ||
| var bodyMap map[string]json.RawMessage | ||
| if err := common.Unmarshal(rawBytes, &bodyMap); err == nil { | ||
| if newModel, err := common.Marshal(info.UpstreamModelName); err == nil { | ||
| bodyMap["model"] = newModel | ||
| if patched, err := common.Marshal(bodyMap); err == nil { | ||
| rawBytes = patched | ||
| } | ||
| // Marshal of bodyMap failed: log and continue with un-patched body. | ||
| } | ||
| // Marshal of model string failed: log and continue. | ||
| } | ||
| // Unmarshal failed: log and continue with un-patched body. | ||
| } |
概述
新增
ChannelTypeVolcAdapter(channel type 58) 渠道,作为火山方舟(Volc Ark)原生 API 的兼容代理。该渠道与现有ChannelTypeVolcEngine(45) 共存,不修改 volcengine 渠道的任何现有行为:/v1/chat/completions等)。/api/v3/contents/generations/tasks*(视频/图片/3D 任务)和/api/v3/images/generations(图片生成)。请求体字节级转发,保留所有火山私有字段(sequential_image_generation、optimize_prompt_options、watermark、safety_identifier等)不被解析丢弃。适用场景:用户使用官方 Volc SDK 或 cURL 直接调用火山方舟 API 时,可以把 baseURL 指到 NewAPI 实例,请求体一字不差地转发到上游。
主要新增功能
1. 新渠道类型 ChannelTypeVolcAdapter (58) + APITypeVolcAdapter
constant/channel.go: 新增渠道类型 58VolcAdapter,base URL 默认指向https://ark.cn-beijing.volces.com。constant/api_type.go+common/api_type.go: 新增APITypeVolcAdapter,渠道 58 路由到独立的 image adaptor,不复用 volcengine.Adaptor。relay/relay_adaptor.go: 注册APITypeVolcAdapter → volcadapter.Adaptor。2. 独立 image channel.Adaptor
relay/channel/volcadapter/adaptor.go(新建,~100 行):实现完整channel.Adaptorinterface。/api/v3/images/generations,其他 relay mode 返回明确错误"volcadapter does not support relay mode N; for OpenAI-format requests use the volcengine channel"。ConvertVolcRequest作为 no-op pass-through(请求体已经是 Volc 原生格式)。Convert*Request方法返回"not supported"引导用户走 volcengine 渠道。DoResponse委托给openai.Adaptor.DoResponse(火山图片响应与 OpenAI 兼容)。GetModelList复用 task adaptor 的模型列表(同一个上游服务)。3. Task adaptor
relay/channel/task/volcadapter/(新建):实现TaskAdaptorinterface 处理异步视频/图片/3D 任务。adaptor.go: 字节级请求体透传(保留全部 Volc 字段),仅在info.IsModelMapped时通过patchVolcBodyModel改写model字段。seedance_estimator.go: 7 个 Seedance 模型的 token 估算(Volc 公式:(input_video_dur + output_dur) × W × H × FPS / 1024),用于 tiered_expr 计费的预扣 token 上限。volc_helpers.go: 字节级 JSON patch 工具(保留未知字段)。constants.go: 模型列表(Doubao Seedance 1.0/1.5/2.0 系列 + lite-i2v/lite-t2v/pro/pro-fast 等 7 个完整 ID)。4. Native pass-through handlers
Image:
relay/volc_handler.go(新建)VolcImageHelper处理/api/v3/images/generations,模式参考GeminiHelper:dto.VolcImageRequest(仅捕获 size、prompt 等已知字段,body 仍按原样转发)。volcImageConverterinterface 检查渠道支持,不支持返回 400。applyVolcImagePatcheshelper 在转发前应用 model mapping 和 param override(byte-level JSON patch,不破坏未知字段)。Task:
router/video-router.go+relay/relay_task.go新增
/api/v3/contents/generations/tasksPOST/GET/DELETE 路由:relay_format=volc,走RelayFormatVolc分支)。tasks/:id)和列表查询(tasks?...),返回 Volc 原生ContentGenerationTaskshape。controller/volc_task_delete.go处理任务取消(owner 校验 + 上游 DELETE 转发 + 本地状态更新 + 退款)。5. Tiered_expr 计费在 volcadapter 上的接入
pkg/billingexpr/(已在上游存在)的 tiered_expr 引擎首次接入 task channel:relay/channel/task/volcadapter/adaptor.go: 实现EstimateBilling、EstimateBillingTokens、AdjustBillingOnComplete,使任务在提交时按param("resolution") == "1080p" ? ... : ...等表达式计算预扣 quota,完成时按实际 completion_tokens 重算。model/task.go: 新增TieredVolcFlags字段(generate_audio、draft、has_video_input、resolution、duration、service_tier),在提交时从请求体抓取,settle 时通过param()暴露给表达式。这在「callback-only 部署」(task.Data仅含{"id":"..."}的场景)下保证表达式能拿到正确参数。controller/relay.go: 新增extractVolcFlags+parseVolcDuration,支持float64/int/int64/json.Number/string多种 duration 形态,拒绝非整数和负数(避免15.9被静默截断成15)。6. 异步任务结算(settle)链路重构
service/task_billing.go+service/task_polling.go重构异步任务的差额结算:tasks.quotaUPDATE + 钱包/订阅扣款 +users.used_quota/channels.used_quota更新 + billing log 一次性提交,避免「task UPDATE 成功但钱包写入失败」的不一致。RowsAffected守卫:targeted UPDATE 匹配 0 行(task ID 错误或行被删)时立刻 abort,不应用任何副作用。SettleTaskBillingOnComplete导出,接受niladaptor(callback handler 在 init 顺序未跑完时仍可结算,走 token fallback 路径)。effectiveTokenCounthelper:优先TotalTokens,回退CompletionTokens(部分火山响应只填 completion 一侧)。task.UpdateWithStatus(snap.Status)返回won标志,polling 和 callback 两条路径都检查,防止双重结算。model.InvalidateUserCache(task.UserId)确保 Redis 用户余额不滞后。7. 计费表达式编辑器 UI
web/classic/src/pages/Setting/Ratio/components/(已有的分档计费编辑器)增强:TieredPricingEditor.jsx: 加 7 个 Doubao Seedance 模型预设(PRESET_GROUPS list)。requestRuleExpr.js: hoist regex 到 module 作用域(性能);has()表达式安全 parse 带引号值(避免输入\q之类崩溃);用双引号代替单引号修复 gjson 路径匹配(Volc 视频字段如content.#(type=="video_url"))。requestRuleExpr.test.js: 新增针对 has() 安全 parse 的测试。改动文件清单
总计 56 文件,+5685/-88 行(其中测试约 3000 行)。
测试覆盖
relay/channel/task/volcadapter/: 16 个测试(adaptor、estimator、Seedance presets、constants)relay/volc_handler_test.go: 8 个测试(body pass-through、ConvertVolcRequest 支持检测、model mapping、param override)controller/relay_volc_flags_test.go: 3 个测试(duration 多种形态解析、非法输入拒绝)controller/volc_task_delete_test.go: 4 个测试service/task_billing_test.go: 19 个测试(事务结算、CAS、token fallback、PerCallBilling 跳过、各种回滚场景)relay/relay_task_volc_list_test.go: list 端点构造测试relay/volc_task_test.go: 完整 RelayTask 流程common/endpoint_type_test.go、constant/channel_test.go、relay/relay_adaptor_test.go: 注册一致性测试middleware/distributor_test.go: routing 测试web/classic/src/pages/Setting/Ratio/components/requestRuleExpr.test.js: 编辑器表达式工具测试兼容性
relay/channel/volcengine/*:现有 volcengine 渠道(45)行为完全不变。APITypeVolcAdapter不影响其他 API type 的路由。BillingMode = tiered_expr时仍走原来的 ratio 计费。TieredVolcFlags)作为task.PrivateData.BillingContext的子结构以 JSON 存储,不影响 schema migration。配置说明
要使用 volcadapter 渠道:
VolcAdapter(58)。https://ark.cn-beijing.volces.com,可改。doubao-seedance-2-0-260128等 ID(参考relay/channel/task/volcadapter/constants.go)。请求示例(视频生成):
Summary by CodeRabbit
Release Notes
New Features
POST /api/v3/images/generations) and video task management (POST/GET/DELETE /api/v3/contents/generations/tasks,GET /api/v3/contents/generations/tasks/:id).Improvements