Skip to content

Codex/group media fixed pricing - #4333

Closed
LJR199887 wants to merge 307 commits into
QuantumNous:mainfrom
LJR199887:codex/group-media-fixed-pricing
Closed

Codex/group media fixed pricing#4333
LJR199887 wants to merge 307 commits into
QuantumNous:mainfrom
LJR199887:codex/group-media-fixed-pricing

Conversation

@LJR199887

@LJR199887 LJR199887 commented Apr 18, 2026

Copy link
Copy Markdown

⚠️ 提交说明 / PR Notice

Important

  • 请提供人工撰写的简洁摘要,避免直接粘贴未经整理的 AI 输出。

📝 变更描述 / Description

(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)

🚀 变更类型 / Type of change

  • 🐛 Bug 修复 (Bug fix) - 请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug
  • ✨ 新功能 (New feature) - 重大特性建议先通过 Issue 沟通
  • ⚡ 性能优化 / 重构 (Refactor)
  • 📝 文档更新 (Documentation)

🔗 关联任务 / Related Issue

  • Closes # (如有)

✅ 提交前检查项 / Checklist

  • 人工确认: 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
  • 非重复提交: 我已搜索现有的 IssuesPRs,确认不是重复提交。
  • Bug fix 说明: 若此 PR 标记为 Bug fix,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
  • 变更理解: 我已理解这些更改的工作原理及可能影响。
  • 范围聚焦: 本 PR 未包含任何与当前任务无关的代码改动。
  • 本地验证: 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
  • 安全合规: 代码中无敏感凭据,且符合项目代码规范。

📸 运行证明 / Proof of Work

(请在此粘贴截图、关键日志或测试报告,以证明变更生效)

Summary by CodeRabbit

  • New Features

    • Creative Center: asset library, upload/proxy/download, history, batch ZIP export/download.
    • Async media APIs & Playground: submit async image/video jobs and poll results; async image endpoints.
  • Enhancements

    • Playground UI: richer image/video generation & edit controls (sync/async), upload/config support.
    • Task logs: dashboard with media-type stats, image preview modal, improved polling/grace handling.
    • Pricing UI: duration/resolution billing views and additional endpoint options.

LJR199887 and others added 30 commits March 30, 2026 16:23
Enhance empty/loading states and unify spacing/typography for a more product-grade look.

Made-with: Cursor
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Creative Center assets and archive, async image/video task submission and polling, multi-dimensional pricing (group/seconds/resolution) and billing/quota refinements, task upsert/persistence on submit, Sora/Grok/XAI adaptor enhancements, and broad frontend integrations (asset UI, playground, pricing, task-logs).

Changes

Cohort / File(s) Summary
Billing model & filtering
common/billing_model.go, common/billing_model_test.go
Introduce billing-model normalization, duration-/resolution-only predicates, and FilterOtherRatiosForBillingModel plus unit tests.
Endpoint types & routing
constant/endpoint_type.go, common/endpoint_defaults.go, common/endpoint_type.go, common/endpoint_type_test.go
Add image-edit endpoint type and OpenAI video endpoint default; endpoint selection now prioritizes image-edit/video models; tests updated.
Model classification
common/model.go
Add Grok/OpenAI video/edit model lists and new IsImageEditModel/IsOpenAIVideoModel predicates with exact/prefix matching.
Pricing maps & helpers
setting/ratio_setting/model_ratio.go, model/pricing.go, types/price_data.go, relay/helper/price.go, setting/ratio_setting/*_test.go, relay/helper/price_test.go
Add group/model pricing by seconds/resolution, update/get APIs, min-aggregation helpers, and extend PriceData (GroupPriceOverride, BaseQuota); price resolution now supports group overrides.
Relay quota & task upsert
relay/relay_task.go, controller/relay.go, relay/relay_task_test.go
New quota calc (normalize ratios, seconds/resolution pricing) and upsert-pending task persistence on submit; move from success-only task inserts to upsert/update flows; tests added.
Async image/video controllers & DTOs
controller/async_image.go, controller/async_video.go, controller/async_video_test.go, dto/async_image.go, dto/async_video.go
Add async image/video submission endpoints, background relay workers, fetch endpoints, DTOs and tests.
Playground & env defaults
controller/playground.go, common/init.go
Expand playground to handle sync/async image/video flows and task recording; change TASK_TIMEOUT_MINUTES default and add TASK_NOT_FOUND_GRACE_MINUTES env.
Creative Center: model/controller/service/frontend
model/creative_center_history.go, model/creative_center_asset.go, dto/asset.go, controller/creative_center_history.go, controller/creative_center_upload.go, controller/asset.go, service/creative_center_asset_archive.go, service/creative_center_asset_archive_test.go, web/src/pages/Asset/index.jsx, router/api-router.go
Implement Creative Center history model (MEDIUMTEXT), asset flattening from tasks/histories, upload/proxy endpoints (external image bed or local), ZIP archive creation with partial-failure reporting, controllers, routes and frontend asset library.
Adaptor/image handling (OpenAI/XAI/Grok)
relay/channel/openai/adaptor.go, relay/channel/xai/adaptor.go, relay/channel/xai/constants.go, relay/channel/xai/dto.go, relay/channel/*/adaptor_test.go
Improve image-edit JSON/multipart handling, support data:/b64/url/image arrays, add Grok model variants and XAI multipart/JSON normalization; tests added.
Sora/Grok video adaptor
relay/channel/task/sora/adaptor.go, relay/channel/task/sora/adaptor_test.go
Refactor Sora/Grok request normalization, fetch URL heuristics, float progress handling, video URL extraction; simplify billing to seconds-only; comprehensive tests added.
Task queries, identifiers & stats
model/task.go, model/task_stats.go, model/task_stats_test.go, controller/task.go
Add request-id fields to TaskPrivateData, GetRequestID(), TaskGetUserTasksByIdentifiers, media_type filter, and aggregated task stats endpoints.
Task polling & transient video handling
service/task_polling.go, service/task_polling_transient_test.go
Add RefreshVideoTask, transient 404 grace-window detection, improved updateVideoSingleTask handling and consume-log duration update on completion; tests added.
Billing session & quota behavior
service/billing_session.go, service/quota.go, service/task_billing.go, service/task_billing_test.go, service/log_info_generate.go
Skip token adjustments for TokenUnlimited, record UseTimeSeconds in consume logs, include group-price-override metadata.
Relay submit & multipart utils
relay/common/relay_info.go, relay/common/relay_utils.go, relay/common/relay_info_test.go
Extend TaskSubmitReq/TaskInfo (request_id/messages/image_url/image_reference/timestamps), improve HasImage detection (scan messages), and multipart parsing; tests updated.
Frontend & UX integrations
many web/src/... files (App.jsx, playground SettingsPanel, model-pricing UI, task-logs UI/hooks, theme/context, API helpers, Asset page, etc.)
Wire Creative Center routes/UI, playground controls, stale-GET session cache & 429 handling, force light theme (remove toggle), pricing UI support for seconds/resolution quota types, task logs image preview and related hooks/components, and many UI adjustments.
Archive service & misc
service/creative_center_asset_archive.go, replace.js, web/src/helpers/render.jsx, docs/linksky-api-usage.md
Add ZIP archive creation service/tests, color-replace script, FolderOpen icon support, and LinkSky API usage docs.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client / Web
    participant AsyncCtrl as Async Controller
    participant TaskModel as Task Model (DB)
    participant Relay as Relay / Upstream
    participant Storage as Service (downloads/archive)

    Client->>AsyncCtrl: POST /v1/images/async-generations
    AsyncCtrl->>TaskModel: Insert pending Task (metadata, body ref)
    TaskModel-->>AsyncCtrl: TaskID
    AsyncCtrl-->>Client: 202 AsyncImageTaskResponse (task id)

    AsyncCtrl->>Relay: (goroutine) forward saved request to upstream
    Relay->>Storage: fetch media or store response bytes
    Storage-->>Relay: response bytes / error
    alt upstream 2xx
        Relay->>TaskModel: Update Task (status=success, result URL, store response bytes)
    else non-2xx
        Relay->>TaskModel: Update Task (status=failure, fail reason, optionally store body)
    end

    Client->>AsyncCtrl: GET /v1/images/async-generations/:task_id
    AsyncCtrl->>TaskModel: Load Task
    TaskModel-->>AsyncCtrl: Task (status/progress/result)
    AsyncCtrl-->>Client: AsyncImageTaskResponse
Loading
sequenceDiagram
    participant Caller as Price resolver
    participant PriceHelper as relay/helper/price.go
    participant RatioStore as setting/ratio_setting store
    participant PriceData as PriceData result

    Caller->>PriceHelper: Request price (model, group, ratios)
    alt Group seconds price exists
        PriceHelper->>RatioStore: GetGroupModelPriceBySeconds(group, model, seconds)
        RatioStore-->>PriceHelper: price
        PriceHelper->>PriceData: Set GroupPriceOverride, BaseQuota
    else Group resolution price exists
        PriceHelper->>RatioStore: GetGroupModelPriceByResolution(group, model, resolution)
        RatioStore-->>PriceHelper: price
        PriceHelper->>PriceData: Set GroupPriceOverride, BaseQuota
    else Fallback
        PriceHelper->>RatioStore: GetModelPrice or apply ratio_setting
        RatioStore-->>PriceHelper: price/ratios
        PriceHelper->>PriceData: Compute quota using ratios
    end
    PriceHelper-->>Caller: PriceData (Quota, OtherRatios, override flags)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

enhancement

Suggested reviewers

  • Calcium-Ion

"🐰 I hopped through code both wide and deep,
Assets and videos now awake from sleep.
Ratios counted, tasks persist with care,
Zip and preview, media everywhere.
A floppy-eared cheer — let's ship with flair!"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
service/quota.go (1)

347-399: ⚠️ Potential issue | 🟠 Major

Mirror TokenUnlimited in post-consume token debits.

PreConsumeTokenQuota now skips token pre-consumption for unlimited tokens, but PostConsumeQuota still adjusts token quota for every non-playground request. That can still decrement token balance during final settlement for TokenUnlimited traffic.

💳 Proposed fix
- if !relayInfo.TokenUnlimited && token.RemainQuota < quota {
+ if token.RemainQuota < quota {
    return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota))
  }
- if !relayInfo.IsPlayground {
+ if !relayInfo.IsPlayground && !relayInfo.TokenUnlimited {
    if quota > 0 {
      err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota)
    } else {
      err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota)
    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/quota.go` around lines 347 - 399, PostConsumeQuota currently always
adjusts token quota for non-playground requests which will wrongly debit
unlimited tokens; update PostConsumeQuota to skip token adjustments when
relayInfo.TokenUnlimited is true (same guard used in PreConsumeTokenQuota).
Concretely, in the block that calls
model.DecreaseTokenQuota/model.IncreaseTokenQuota, add a condition checking
relayInfo.TokenUnlimited (e.g., if !relayInfo.IsPlayground &&
!relayInfo.TokenUnlimited) so that model.DecreaseTokenQuota and
model.IncreaseTokenQuota are not invoked for unlimited tokens.
web/src/hooks/dashboard/useDashboardData.js (1)

178-270: ⚠️ Potential issue | 🟡 Minor

Missing buildDashboardStaleKey in the useCallback dependency arrays.

loadQuotaData (L226), loadUptimeData (L251), and getUserData (L270) all call buildDashboardStaleKey(...) but none list it in their deps. Although buildDashboardStaleKey itself is memoized, its identity depends on isAdminUser; getUserData's deps currently omit both, so if isAdmin() ever transitioned during a session the callback would keep issuing requests under the stale "user"/"admin" cache namespace. This will also be flagged by react-hooks/exhaustive-deps.

🔧 Proposed fix
-  }, [inputs, dataExportDefaultTime, isAdminUser, now]);
+  }, [inputs, dataExportDefaultTime, isAdminUser, now, buildDashboardStaleKey]);
...
-  }, [activeUptimeTab]);
+  }, [activeUptimeTab, buildDashboardStaleKey]);
...
-  }, [userDispatch]);
+  }, [userDispatch, buildDashboardStaleKey]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/dashboard/useDashboardData.js` around lines 178 - 270, The
callbacks loadQuotaData, loadUptimeData, and getUserData call
buildDashboardStaleKey but do not include it (and in getUserData also
isAdminUser) in their useCallback dependency arrays; update the dependency
arrays to include buildDashboardStaleKey for loadQuotaData, loadUptimeData, and
getUserData, and add isAdminUser to getUserData's deps so the memoized callbacks
update when buildDashboardStaleKey or admin state changes (refer to functions
loadQuotaData, loadUptimeData, getUserData).
relay/channel/xai/dto.go (1)

16-31: ⚠️ Potential issue | 🔴 Critical

Remove unsupported Seed and Seeds fields from the ImageRequest DTO.

The xAI Grok image generation API (endpoint /v1/images/generations) does not support seed or seeds parameters in its official specification. The official request parameters are: aspect_ratio, model, n, prompt, quality, resolution, response_format, and user only. Including fields that the upstream API doesn't accept will cause requests to fail or be silently ignored by the relay. Delete both Seed *float64 and Seeds []int from the struct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/xai/dto.go` around lines 16 - 31, The ImageRequest DTO
currently contains unsupported fields Seed and Seeds which the xAI Grok image
generation API does not accept; remove the Seed *float64 and Seeds []int fields
from the ImageRequest struct (type ImageRequest) so the struct only includes
supported parameters (e.g., Model, Prompt, N, Image, Size, AspectRatio,
OutputResolution, ResponseFormat) and ensure any code that marshals or
references ImageRequest.Seed/Seeds is updated or removed to avoid compile
errors.
web/src/helpers/utils.jsx (1)

155-189: ⚠️ Potential issue | 🟠 Major

backendMessage is computed but never used — backend error strings are still dropped.

extractBackendErrorMessage(error) is assigned to backendMessage at line 155, but nothing inside showError subsequently reads it. The generic default branch and the final fallback still only use error.message/error, so structured backend payloads like { error: { message } } or { message } are silently discarded — defeating the purpose of the new helper. Consider preferring backendMessage where it's available (at least in the default branch and the non-Axios path).

🔧 Proposed wiring
-        default:
-          Toast.error('错误:' + error.message);
+        default:
+          Toast.error('错误:' + (backendMessage || error.message));
       }
       return;
     }
-    Toast.error('错误:' + error.message);
+    Toast.error('错误:' + (backendMessage || error.message));
   } else {
-    Toast.error('错误:' + error);
+    Toast.error('错误:' + (backendMessage || error));
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/helpers/utils.jsx` around lines 155 - 189, The showError function
computes backendMessage via extractBackendErrorMessage(error) but never uses it;
update showError to prefer backendMessage when present (use backendMessage
instead of error.message) in the Axios default case, in the non-Axios path (the
final Toast.error fallback), and any other branches that currently call
Toast.error('错误:' + error.message) or Toast.error('错误:' + error) so structured
backend payloads are surfaced; keep existing fallback to error.message if
backendMessage is falsy.
model/pricing.go (1)

346-373: ⚠️ Potential issue | 🟠 Major

QuotaType classification ignores group-only fixed pricing.

pricing.GroupModelPrice / GroupModelPriceBySeconds / GroupModelPriceByResolution are populated unconditionally at Lines 347-349, but the QuotaType decision at Lines 353-373 only inspects the default (non-group) maps modelPriceBySecondsMap[formattedModelName], modelPriceByResolutionMap[formattedModelName], and ratio_setting.GetModelPrice(model, false). A media model that has been configured exclusively with group-scoped fixed pricing (the very feature this PR is introducing — "Add group fixed pricing for media models") but no default fixed/seconds/resolution price will fall through to the else branch and be reported to the frontend as QuotaType=0 (ratio-based), with ModelRatio/CompletionRatio populated instead of the expected fixed-pricing badge.

Please also consult the group maps when deciding QuotaType, e.g.:

♻️ Proposed classification fix
-		if hasSecondsPrice && len(secondsPriceMap) > 0 {
+		if hasSecondsPrice && len(secondsPriceMap) > 0 {
 			pricing.ModelPriceBySeconds = make(map[string]float64, len(secondsPriceMap))
 			for seconds, price := range secondsPriceMap {
 				pricing.ModelPriceBySeconds[seconds] = price
 			}
 			pricing.QuotaType = 2
-		} else if hasResolutionPrice && len(resolutionPriceMap) > 0 {
+		} else if len(pricing.GroupModelPriceBySeconds) > 0 {
+			pricing.QuotaType = 2
+		} else if hasResolutionPrice && len(resolutionPriceMap) > 0 {
 			pricing.ModelPriceByResolution = make(map[string]float64, len(resolutionPriceMap))
 			for resolution, price := range resolutionPriceMap {
 				pricing.ModelPriceByResolution[resolution] = price
 			}
 			pricing.QuotaType = 3
-		} else if findPrice {
+		} else if len(pricing.GroupModelPriceByResolution) > 0 {
+			pricing.QuotaType = 3
+		} else if findPrice || len(pricing.GroupModelPrice) > 0 {
 			pricing.ModelPrice = modelPrice
 			pricing.QuotaType = 1
 		} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/pricing.go` around lines 346 - 373, The QuotaType logic ignores
group-level fixed pricing: when GroupModelPrice / GroupModelPriceBySeconds /
GroupModelPriceByResolution contain entries for formattedModelName, the code
should treat the model as fixed-priced instead of falling back to ratio-based.
Update the decision block (the if/else that inspects modelPriceBySecondsMap,
modelPriceByResolutionMap, and ratio_setting.GetModelPrice) to also check the
group maps (groupModelPriceMap[formattedModelName],
groupModelPriceBySecondsMap[formattedModelName],
groupModelPriceByResolutionMap[formattedModelName]) and, when those group maps
exist and are non-empty, populate the corresponding pricing.ModelPrice /
ModelPriceBySeconds / ModelPriceByResolution values from the group maps and set
pricing.QuotaType to the appropriate value (2 for seconds, 3 for resolution, 1
for fixed price) before falling back to
ratio_setting.GetModelRatio/GetCompletionRatio and QuotaType=0.
controller/playground.go (1)

621-698: ⚠️ Potential issue | 🟡 Minor

playgroundBodyCaptureWriter buffers the full response in memory for every non-stream playground request.

Wrapping c.Writer with a bytes.Buffer duplicate (Lines 51-68) is fine for small JSON responses, but it fires for every Playground, PlaygroundImageGenerations, and PlaygroundImageEdits call. Image/edit responses with inline b64_json payloads commonly run into several MiB per item, and the buffer is held until the handler returns. Consider: (a) capping the capture buffer (e.g., stop appending past N MiB, still stream-through via the inner writer) and treating over-cap responses as "no media URL extracted", or (b) only attaching the capture writer when the action is image/media and the request was known-non-stream, skipping for plain chat completions where media extraction will return nothing anyway. As-is, a burst of concurrent playground requests with base64 image responses could pressure the process RSS materially.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/playground.go` around lines 621 - 698, The
playgroundBodyCaptureWriter currently buffers entire responses (used in
Playground, PlaygroundImageGenerations, PlaygroundImageEdits) which can OOM on
large b64_json image payloads; either (A) add a capped buffer behavior in
playgroundBodyCaptureWriter (introduce a constant like
maxPlaygroundCaptureBytes) so Write() continues to write-through to the embedded
ResponseWriter but stops appending to the internal buffer after the cap and sets
a flag indicating "truncated", and ensure Status()/body accessors handle
truncated payloads, or (B) only wrap c.Writer with playgroundBodyCaptureWriter
for requests that are non-stream and have an image/media action (use
inferPlaygroundChatRequestAction or the action passed into
createPendingPlaygroundMediaTask to detect image/edit flows) and skip wrapping
for plain chat completions; update call sites (Playground,
PlaygroundImageGenerations, PlaygroundImageEdits) to apply one of these fixes
and ensure recordPlayground* / updatePlaygroundMediaTask treat
truncated/no-buffer as "no media URL extracted."
relay/relay_task.go (1)

621-624: ⚠️ Potential issue | 🟠 Major

Use the task-scoped key for realtime follow-up fetches.

Realtime fetch currently uses channelModel.Key, which can be different from the key that created the async task. Prefer the persisted per-task key when present.

Proposed fix
+	fetchKey := channelModel.Key
+	if task.PrivateData.Key != "" {
+		fetchKey = task.PrivateData.Key
+	}
 	resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
 		"task_id": task.GetUpstreamTaskID(),
 		"action":  task.Action,
 	}, proxy)
-	resp, err := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{
+	resp, err := adaptor.FetchTask(baseURL, fetchKey, map[string]any{

Based on learnings, async video follow-up requests should prefer task.PrivateData.Key over channel.Key.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/relay_task.go` around lines 621 - 624, The realtime fetch is using
channelModel.Key but should prefer the per-task persisted key; update the
adaptor.FetchTask call in relay_task.go (the caller that currently passes
channelModel.Key) to pass task.PrivateData.Key when present (falling back to
channelModel.Key) so follow-up fetches use the task-scoped key (keep the same
task.GetUpstreamTaskID() payload and proxy argument).
🧹 Nitpick comments (25)
docs/linksky-api-usage.md (2)

509-523: Use environment variable for API key in Python example.

The Python SDK example uses a hardcoded string placeholder "YOUR_LINKSKY_API_KEY" (line 513), which is inconsistent with the Node.js example (line 495) and curl examples that use environment variables. For security best practices and consistency, the Python example should also demonstrate environment variable usage.

🔐 Suggested security improvement
+import os
 from openai import OpenAI

 client = OpenAI(
-    api_key="YOUR_LINKSKY_API_KEY",
+    api_key=os.environ.get("LINKSKY_API_KEY"),
     base_url="https://linksky.top/v1",
 )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/linksky-api-usage.md` around lines 509 - 523, The Python example
currently hardcodes the API key when constructing OpenAI(...) which is insecure;
change the OpenAI client instantiation (the OpenAI(...) call that assigns to
client) to read the API key from an environment variable (e.g.,
os.environ["LINKSKY_API_KEY"]) instead of the literal "YOUR_LINKSKY_API_KEY" so
it matches the Node.js and curl examples and follows best practices for secrets
management.

158-186: Consider clarifying the duplicate parameter structure.

The example shows both top-level parameters and nested extra_body.google.image_config with overlapping fields:

  • aspect_ratio appears at both line 174 and line 180
  • output_resolution (line 175) vs. image_size (line 181)

While this may be intentional for API compatibility, users might be confused about which parameters take precedence or whether both are required. Consider adding a brief explanation of why both structures are included.

📝 Suggested clarification

Add a note before or after the example explaining:

说明: 当前项目同时支持顶层参数和 `extra_body.google.image_config` 嵌套结构。
为确保兼容性,建议同时传递这两个结构,其中 `output_resolution` 对应 `image_size`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/linksky-api-usage.md` around lines 158 - 186, The example duplicates
image params at the top level ("aspect_ratio", "output_resolution") and inside
extra_body.google.image_config ("aspect_ratio", "image_size"), which may confuse
users; update the docs by adding a short clarifying note near the curl example
stating that the API supports both top-level parameters and the nested
extra_body.google.image_config for compatibility, that users should send both
when targeting Google-specific features, and that output_resolution maps to
image_size (i.e., output_resolution ⇄ image_size), referencing the keys
"aspect_ratio", "output_resolution", "extra_body.google.image_config", and
"image_size" so readers can locate the fields in the example.
replace.js (1)

1-16: Remove the one-off source-mutation script after applying the color migration.

This hard-coded script rewrites web/src/pages/CreativeCenter/index.jsx in place and is not documented or wired into tooling. Keeping it at the repo root makes accidental future rewrites more likely.

Suggested cleanup
-const fs = require('fs');
-let content = fs.readFileSync('web/src/pages/CreativeCenter/index.jsx', 'utf8');
-
-content = content.replace(/indigo-600/g, 'blue-600');
-content = content.replace(/indigo-500/g, 'blue-500');
-content = content.replace(/indigo-400/g, 'blue-400');
-content = content.replace(/indigo-300/g, 'blue-300');
-content = content.replace(/indigo-200/g, 'blue-200');
-content = content.replace(/indigo-100/g, 'blue-100');
-content = content.replace(/indigo-50/g, 'blue-50');
-content = content.replace(/purple-600/g, 'sky-500');
-content = content.replace(/purple-500/g, 'sky-400');
-content = content.replace(/99,102,241/g, '59,130,246'); // indigo-500 to blue-500
-
-fs.writeFileSync('web/src/pages/CreativeCenter/index.jsx', content, 'utf8');
-console.log('Done replacing colors.');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@replace.js` around lines 1 - 16, Delete the one-off root script that mutates
source in place (the file using fs.readFileSync/fs.writeFileSync and the
content/console.log flow) to prevent accidental future rewrites; if you want to
keep it for reproducibility instead, move it into a documented repo
scripts/migrations area, add a clear README entry and a safe flag or dry-run
option rather than overwriting files in-place, and ensure any retained script is
not executable by default.
web/src/components/layout/SiderBar.jsx (1)

43-113: Minor: inconsistent path between routerMap.asset and nav item to.

routerMap.asset is /console/assets but the nav item's to is /assets (Line 112). The Link actually uses routerMapState[itemKey] (Line 420), so navigation works, and selection matching also uses routerMapState, so this is consistent with the existing convention used by sibling items (e.g., token, log). No functional issue, but the to: '/assets' field is effectively unused — consider aligning it with /console/assets for clarity, or remove it if it's truly redundant across all items.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/layout/SiderBar.jsx` around lines 43 - 113, The
workspaceItems entry for "资产库" in the SiderBar component has a mismatched to
value ('to: \'/assets\'') versus routerMap.asset ('/console/assets'); update the
workspaceItems item (in the SiderBar function where workspaceItems is defined)
to use the same canonical path (change to '/console/assets') or remove the
unused to property entirely so it matches routerMap.asset/routerMapState lookups
(refer to routerMap.asset, routerMapState, and the workspaceItems itemKey
'asset').
relay/constant/relay_mode_test.go (1)

5-22: LGTM.

Good table-driven coverage for the new playground and async image route mappings. Consider also adding cases for /pg/video/generations and /pg/video/async-generations if Path2RelayMode is expected to resolve those as RelayModeVideoSubmit (or whichever constant applies), to match the router additions in router/relay-router.go.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/constant/relay_mode_test.go` around lines 5 - 22, Add test cases in
TestPath2RelayModeSupportsPlaygroundImageRoutes to cover the new video route
mappings: include entries for "/pg/video/generations" and
"/pg/video/async-generations" and assert they map to RelayModeVideoSubmit by
calling Path2RelayMode; update the tests slice (in
relay/constant/relay_mode_test.go) to include these two paths with want set to
RelayModeVideoSubmit so the unit test reflects the router changes in
router/relay-router.go.
common/endpoint_type.go (1)

40-47: Possible duplicate EndpointTypeOpenAIVideo for Sora channel.

For ChannelTypeSora the switch sets endpointTypes = [OpenAIVideo], and if the same request also satisfies IsOpenAIVideoModel(modelName) the prepend at Line 41 produces [OpenAIVideo, OpenAIVideo]. Depending on downstream consumers iterating endpoint types, this can lead to redundant routing/fallback attempts. A cheap guard is to only prepend when the type isn't already first:

♻️ Suggested tweak
-	if IsOpenAIVideoModel(modelName) {
-		endpointTypes = append([]constant.EndpointType{constant.EndpointTypeOpenAIVideo}, endpointTypes...)
-	} else if IsImageEditModel(modelName) {
-		endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageEdit}, endpointTypes...)
-	} else if IsImageGenerationModel(modelName) {
-		// add to first
-		endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...)
-	}
+	var prepend constant.EndpointType
+	switch {
+	case IsOpenAIVideoModel(modelName):
+		prepend = constant.EndpointTypeOpenAIVideo
+	case IsImageEditModel(modelName):
+		prepend = constant.EndpointTypeImageEdit
+	case IsImageGenerationModel(modelName):
+		prepend = constant.EndpointTypeImageGeneration
+	}
+	if prepend != "" && (len(endpointTypes) == 0 || endpointTypes[0] != prepend) {
+		endpointTypes = append([]constant.EndpointType{prepend}, endpointTypes...)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common/endpoint_type.go` around lines 40 - 47, The code may prepend
constant.EndpointTypeOpenAIVideo into endpointTypes even when ChannelTypeSora
already set it, causing duplicate entries; update the prepending logic in the
block that checks IsOpenAIVideoModel(modelName) to only prepend
constant.EndpointTypeOpenAIVideo if endpointTypes is empty or endpointTypes[0]
!= constant.EndpointTypeOpenAIVideo (i.e., guard the append with a check against
the first element) so endpointTypes does not become [OpenAIVideo, OpenAIVideo];
reference the IsOpenAIVideoModel function and the endpointTypes slice when
making this change.
service/task_billing_test.go (1)

728-745: Strengthen assertions to actually prove “no adjustment occurred.”

Both tests only assert NoError, but their names (SkipsUnlimitedToken…) imply a behavioral guarantee: that no user/token quota mutation and no billing log is written when TokenUnlimited is true. Without side-effect assertions, the tests would still pass if someone accidentally removed the skip branch (as long as the code path happened not to error out for a zero-valued WalletFunding). Consider asserting on user quota and log count, mirroring the pattern used in other tests in this file (e.g. TestRecalculate_ZeroDelta).

♻️ Suggested hardening
 func TestPreConsumeTokenQuota_SkipsUnlimitedToken(t *testing.T) {
     truncate(t)
+    const userID = 39
+    seedUser(t, userID, 10000)

     relayInfo := &relaycommon.RelayInfo{
+        UserId:         userID,
         TokenId:        0,
         TokenKey:       "playground_1_playground-video",
         TokenUnlimited: true,
     }

     require.NoError(t, PreConsumeTokenQuota(relayInfo, 1000))
+    assert.Equal(t, 10000, getUserQuota(t, userID))
+    assert.Equal(t, int64(0), countLogs(t))
 }

 func TestBillingSessionSettle_SkipsUnlimitedTokenAdjustment(t *testing.T) {
     truncate(t)

     const userID = 40
     seedUser(t, userID, 10000)

     session := &BillingSession{
         relayInfo: &relaycommon.RelayInfo{
             UserId:         userID,
             TokenId:        0,
             TokenKey:       "playground_1_playground-video",
             TokenUnlimited: true,
         },
         funding: &WalletFunding{userId: userID},
     }

     require.NoError(t, session.Settle(1000))
+    assert.Equal(t, 10000, getUserQuota(t, userID))
+    assert.Equal(t, int64(0), countLogs(t))
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_billing_test.go` around lines 728 - 745, The test
TestBillingSessionSettle_SkipsUnlimitedTokenAdjustment currently only asserts no
error; update it to verify the skip behavior by checking that calling
BillingSession.Settle when BillingSession.relayInfo.TokenUnlimited == true does
not change the user's quota and does not write any billing logs: seed the user
(seedUser), capture the current quota (e.g., via whatever read function or
repository used elsewhere in tests), call session.Settle(1000), then assert the
quota equals the pre-call value and that billing log count (the logs store used
elsewhere in tests) did not increase; mirror the pattern from
TestRecalculate_ZeroDelta for assertions and use the same WalletFunding/funding
and log-check utilities used in other tests.
service/billing_session.go (1)

95-113: Capture TokenUnlimited into a local before the goroutine for consistency.

The Refund closure snapshots tokenId, tokenKey, isPlayground, etc. into locals (clearly intentional to avoid reading s.relayInfo off-thread), but the newly added s.relayInfo.TokenUnlimited check on line 108 is read from the shared struct inside the goroutine. For consistency — and to guard against any future mutation of relayInfo after Refund returns — copy it alongside isPlayground.

Proposed tweak
   isPlayground := s.relayInfo.IsPlayground
+  tokenUnlimited := s.relayInfo.TokenUnlimited
   tokenConsumed := s.tokenConsumed
   funding := s.funding
@@
-    if tokenConsumed > 0 && !isPlayground && !s.relayInfo.TokenUnlimited {
+    if tokenConsumed > 0 && !isPlayground && !tokenUnlimited {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/billing_session.go` around lines 95 - 113, The goroutine captures
some relayInfo fields but reads s.relayInfo.TokenUnlimited directly inside the
closure; create a local variable (e.g., tokenUnlimited :=
s.relayInfo.TokenUnlimited) alongside tokenId, tokenKey, isPlayground,
tokenConsumed, funding before the gopool.Go call and use that local
(tokenUnlimited) inside the closure when checking tokenUnlimited in the
model.IncreaseTokenQuota branch to avoid accessing shared state from the
goroutine.
service/text_quota.go (1)

251-282: Optional: hoist duplicated OtherRatios filtering out of both branches.

The filter + write-back + multiply block is identical in both the !UsePrice and UsePrice paths. Consider computing and persisting the filtered ratios once before the if/else and sharing the multiplication logic to reduce drift risk.

♻️ Sketch
+	otherRatios := common.FilterOtherRatiosForBillingModel(relayInfo.OriginModelName, relayInfo.PriceData.OtherRatios)
+	relayInfo.PriceData.OtherRatios = otherRatios
+
 	if !relayInfo.PriceData.UsePrice {
 		...
-		otherRatios := common.FilterOtherRatiosForBillingModel(...)
-		relayInfo.PriceData.OtherRatios = otherRatios
 		for _, otherRatio := range otherRatios {
 			quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
 		}
 		...
 	} else {
 		...
-		otherRatios := common.FilterOtherRatiosForBillingModel(...)
-		relayInfo.PriceData.OtherRatios = otherRatios
 		for _, otherRatio := range otherRatios {
 			quotaCalculateDecimal = quotaCalculateDecimal.Mul(decimal.NewFromFloat(otherRatio))
 		}
 		...
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/text_quota.go` around lines 251 - 282, Hoist the duplicated
filtering/multiplication: call
common.FilterOtherRatiosForBillingModel(relayInfo.OriginModelName,
relayInfo.PriceData.OtherRatios) once before the if/else, assign back to
relayInfo.PriceData.OtherRatios, and then apply the loop that multiplies
quotaCalculateDecimal by each decimal.NewFromFloat(otherRatio) in a shared place
after both branches compute the base quotaCalculateDecimal; ensure both branches
stop performing the same filtering/multiplication so summary.Quota is set from
the final quotaCalculateDecimal.Round(0).IntPart() in the same shared location.
setting/ratio_setting/model_price_by_resolution_test.go (1)

9-34: LGTM — nice case-insensitivity coverage; consider adding one missing-resolution negative case.

The three case permutations ("1k"/"1K", "2K"/"2k", "4k"/"4K") are a good regression guard. For parity with TestGetModelPriceBySeconds, consider appending a quick _, ok := GetModelPriceByResolution("nano-banana", "8k"); require.False(t, ok) to lock in negative behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@setting/ratio_setting/model_price_by_resolution_test.go` around lines 9 - 34,
Add a negative-resolution assertion to TestGetModelPriceByResolution: after the
existing positive checks call GetModelPriceByResolution("nano-banana", "8k") (or
another non-existent resolution) and assert ok is false (e.g., _, ok :=
GetModelPriceByResolution(...); require.False(t, ok)). Keep the existing
UpdateModelPriceByResolutionByJSONString setup and defer restore; only append
this failing-resolution check to the end of TestGetModelPriceByResolution to
mirror TestGetModelPriceBySeconds.
service/task_polling_transient_test.go (1)

10-96: Guard against future parallel tests mutating TaskNotFoundGraceMinutes.

Both tests toggle the package-global constant.TaskNotFoundGraceMinutes. This is fine today because the tests run serially, but if any test in service/ later calls t.Parallel() the global will race. Two cheap protections:

  1. Call t.Setenv-style helpers or wrap the mutation in a small helper that panics if called concurrently, or
  2. Factor isTransientVideoNotFoundResponse to take the grace minutes as a parameter so tests don't touch globals at all.

Not a blocker for this PR, just a hardening step.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling_transient_test.go` around lines 10 - 96, The tests
mutate the package-global constant.TaskNotFoundGraceMinutes which can race if
tests run in parallel; refactor isTransientVideoNotFoundResponse to accept a
graceMinutes parameter (e.g., isTransientVideoNotFoundResponse(statusCode int,
body []byte, submitTime, now, graceMinutes int64) or similar), update all call
sites to pass constant.TaskNotFoundGraceMinutes in production code, and update
the two tests to stop modifying the global by passing the desired grace (10 or
0) directly to the function; ensure function and tests compile and remove any
defer-based restoration of the global.
router/api-router.go (1)

346-352: Protect the /asset/*/download endpoints against abuse.

POST /asset/self/download and POST /asset/download (presumably ZIP-archive users' media assets) are authenticated but have no rate-limit middleware. If the handlers stream large archives assembled from many assets, concurrent requests can exhaust disk/IO and memory. Consider attaching middleware.CriticalRateLimit() (or a dedicated download limiter) and an archive-size cap.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@router/api-router.go` around lines 346 - 352, The two download endpoints
under the assetRoute (handlers controller.DownloadUserCreativeCenterAssets and
controller.DownloadAllCreativeCenterAssets) lack rate-limiting and can be abused
to exhaust IO/memory; add middleware.CriticalRateLimit() (or a dedicated
download limiter) to the POST routes for "/self/download" and "/download" and
also enforce an archive-size cap inside the handlers (e.g., limit number of
asset entries, total bytes, or deny oversized archive requests in
DownloadUserCreativeCenterAssets and DownloadAllCreativeCenterAssets) so
concurrent archive generation cannot overwhelm disk/IO.
web/src/hooks/common/useHeaderBar.js (1)

92-114: Nit: actualTheme is now a constant, so the memoization/effect wiring is dead weight.

Since actualTheme is hardcoded to 'light', the useEffect dependency [actualTheme] will never re-fire after mount — which is fine, but the variable and dep can be removed for clarity. If there's any plan to bring theming back, leaving a // TODO comment pointing to the intentional removal would help future readers (and match the companion change in context/Theme/index.jsx).

♻️ Optional cleanup
-  const actualTheme = 'light';
-
   // Logo loading effect
@@
   // Send theme to iframe
   useEffect(() => {
     try {
       const iframe = document.querySelector('iframe');
       const cw = iframe && iframe.contentWindow;
       if (cw) {
-        cw.postMessage({ themeMode: actualTheme }, '*');
+        cw.postMessage({ themeMode: 'light' }, '*');
       }
     } catch (e) {
       // Silently ignore cross-origin or access errors
     }
-  }, [actualTheme]);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/common/useHeaderBar.js` around lines 92 - 114, actualTheme is a
hardcoded constant so the second useEffect's dependency array is unnecessary and
the constant is dead weight; remove the actualTheme variable, inline the static
'light' value in the iframe postMessage call inside the useEffect, and change
the effect dependency array to [] (or add a brief // TODO noting theming is
intentionally removed if you want to keep a reminder). Update the reference to
actualTheme in the useEffect to use the string 'light' so there are no unused
variables.
relay/channel/xai/adaptor_test.go (1)

119-151: Minor: misleading model name in XAI adaptor test.

TestConvertImageRequestPreservesAspectRatioAndOutputResolution passes Model: "nano-banana-pro" through the XAI adaptor to verify AspectRatio/OutputResolution/Seed/Seeds are preserved. Since nano-banana-pro is not an XAI model, a future reader may reasonably think the XAI adaptor is meant to handle it. Swapping in a real Grok image model (or a clearly-fake name like xai-test-model) keeps the assertion intent (field pass-through) while avoiding the cross-provider confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/xai/adaptor_test.go` around lines 119 - 151, In
TestConvertImageRequestPreservesAspectRatioAndOutputResolution change the DTO's
Model value from the non-XAI name "nano-banana-pro" to an XAI-appropriate
identifier (e.g., a real Grok image model string or a clearly-fake XAI name like
"xai-test-model") so the test's intent (verifying field pass-through in
adaptor.ConvertImageRequest for dto.ImageRequest) isn't confusing about provider
ownership; update the dto.ImageRequest.Model field in the test accordingly.
relay/channel/openai/adaptor.go (1)

439-482: When multipart parsing fails, we now silently fall back to JSON — worth logging.

The new condition only parses the multipart form when Content-Type contains multipart/form-data. If a client does send a multipart body but c.MultipartForm() fails for some reason (e.g. truncated upload), the old code returned "failed to parse multipart form"; with the new structure that error path still exists, but for non-multipart edit requests we silently drop mask and any non-whitelisted form fields that a richer future request might carry. The whitelisted fallback currently covers prompt/n/size/quality/response_format only. Fields like background, model_version, user, image_urls on the DTO won't propagate.

Suggest either:

  • Explicitly iterating a broader set (including anything new on dto.ImageRequest you want to support), or
  • Marshaling request to a struct tagged for form fields and writing whatever is non-empty.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/openai/adaptor.go` around lines 439 - 482, ConvertImageRequest
currently swallows multipart form fields when c.MultipartForm() fails or when
Content-Type detection skips parsing, losing fields like
mask/background/model_version/user/image_urls; update ConvertImageRequest to log
the multipart parse error (when c.MultipartForm() returns an error) and ensure
all DTO fields propagate by either iterating a broader set of fields from
dto.ImageRequest or marshaling dto.ImageRequest into form tags and writing every
non-empty field to the multipart writer; specifically modify the code around
c.MultipartForm()/mf handling in ConvertImageRequest to (1) call processLogger
or a logger with the parsing error when c.MultipartForm() fails, and (2) add
logic that writes mask, background, model_version, user, image_urls, and any
other dto.ImageRequest fields to writer when mf is nil so non-multipart fallback
preserves those values.
web/src/components/table/task-logs/modals/ContentModal.jsx (1)

84-95: Download via <a download> can fail for cross-origin HTTP(S) URLs.

When imagePreviewUrl points to a non–same-origin HTTP(S) resource (e.g., when the proxy is disabled or returns a redirect to a third-party CDN), the download attribute is ignored by browsers and the link will just navigate/open the image instead of saving it. Also, the filename task-preview-image is extension-less, so saved files lose their mime hint.

Consider fetching the URL as a blob and using URL.createObjectURL, and deriving an extension from the URL or content-type. Not blocking—just worth hardening if downloads are a common flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/table/task-logs/modals/ContentModal.jsx` around lines 84 -
95, The current handleDownloadImage uses an <a download> with imagePreviewUrl
which fails for cross-origin HTTP(S) URLs and produces extension-less filenames;
change handleDownloadImage to fetch the image URL as a blob (use fetch with
response.blob()), derive a file extension from the
response.headers.get('content-type') or fallback to parsing the imagePreviewUrl
path, create an object URL via URL.createObjectURL(blob), set link.href to that
object URL and link.download to a name including the extension (e.g.,
task-preview-image.<ext>), call link.click(), then revoke the object URL with
URL.revokeObjectURL and handle fetch errors with a fallback to the existing
link-based behavior.
dto/openai_image.go (1)

161-169: imagePriceRatio forced to 1 for resolution-only models — confirm the end-to-end billing path.

Setting ImagePriceRatio = 1 here means the DALL·E-style sizeRatio * qualityRatio * n contribution is neutralized for resolution-only models (e.g. nano-banana-pro). That's only correct if the resolution-based price is folded in later by FilterOtherRatiosForBillingModel / PriceData.ModelPrice (which this PR does by emptying OtherRatios and driving cost from the fixed resolution price). Worth an inline comment cross-referencing common.IsResolutionOnlyBillingModel and the resolution price lookup in relay/helper/price.go so this isn't read as "free" by future readers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dto/openai_image.go` around lines 161 - 169, The imagePriceRatio is being
forced to 1 when common.IsResolutionOnlyBillingModel(i.Model) returns true which
can look like the image cost is being zeroed out; add an inline comment next to
the imagePriceRatio assignment explaining that for resolution-only models we
neutralize size/quality/n here because the final resolution-based price is
applied later (see FilterOtherRatiosForBillingModel and PriceData.ModelPrice)
and reference the resolution price lookup in relay/helper/price.go so future
readers understand the end-to-end billing path.
service/task_polling.go (2)

73-96: Transient-404 classifier: explicit trim set + fallback semantics look correct.

One note: when submitTime <= 0 || now <= 0 the function returns true (treat as transient and keep polling). Without a valid timestamp this could loop indefinitely for a given upstream response, but sweepTimedOutTasks / TaskTimeoutMinutes will still terminate the task eventually, so this is an acceptable tradeoff. Worth a short comment on the fallback so future readers don't re-question it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling.go` around lines 73 - 96, Add a short clarifying comment
inside isTransientVideoNotFoundResponse (near the submitTime/now check)
explaining that when submitTime <= 0 || now <= 0 the function conservatively
returns true to treat the 404 as transient and continue polling, and that this
fallback may allow longer-lived polling but is bounded by the global
sweepTimedOutTasks / TaskTimeoutMinutes mechanism which will eventually
terminate the task; reference isTransientVideoNotFoundResponse,
constant.TaskNotFoundGraceMinutes, and the sweepTimedOutTasks/TaskTimeoutMinutes
timeout behavior in the comment so future readers understand the tradeoff.

582-592: Consume-log duration update: guard against clock-skew FinishTime.

Logic is sound (prefers SubmitTime, falls back to StartTime, only updates when FinishTime > startAt). One edge case: if upstream reports a FinishTime that's wildly in the future relative to startAt (or if startAt is a legacy sentinel seconds-vs-ms mismatch), the computed use_time could be stored as a bogus large integer. Not a bug today, but a small sanity cap (e.g., ignore if delta exceeds a reasonable upper bound) would be a cheap defense.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling.go` around lines 582 - 592, The consume-log update may
record a bogus large use_time if FinishTime is far in the future or units are
mismatched; compute delta := task.FinishTime - startAt and add a sanity cap
(e.g., maxUseTime := 24*3600 seconds) before calling
model.UpdateConsumeLogUseTimeByRequestId: only call the update when delta > 0 &&
delta <= maxUseTime, otherwise skip and logger.LogWarn with context
(task.TaskID, RequestId, delta) so bad upstream values are ignored; keep symbols
to change: task.SubmitTime, task.StartTime, task.FinishTime,
task.PrivateData.RequestId and the call to
model.UpdateConsumeLogUseTimeByRequestId.
model/task_stats_test.go (1)

110-125: Brittle expected counts (7, 2, 5) for getTaskActionsForMediaType.

These are tightly coupled to the current action set and will break on every legitimate addition (e.g., a new Grok image variant added elsewhere in this PR). Consider asserting on set membership of expected actions instead of raw counts, so the test documents intent and survives additive changes:

Proposed tightening
-	allActions := getTaskActionsForMediaType(TaskMediaTypeAll)
-	if len(allActions) != 7 {
-		t.Fatalf("expected 7 actions for all media type, got %d", len(allActions))
-	}
+	allActions := getTaskActionsForMediaType(TaskMediaTypeAll)
+	for _, want := range []string{"imageGenerate", "imageEdit", "generate", "textGenerate", "remixGenerate" /* ... */} {
+		if !slices.Contains(allActions, want) {
+			t.Fatalf("expected action %q in all-media actions, got %v", want, allActions)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/task_stats_test.go` around lines 110 - 125, The test
TestGetTaskActionsForMediaType is brittle because it asserts exact counts;
change it to assert that the expected action IDs/names are present for each
media type rather than exact lengths: call
getTaskActionsForMediaType(TaskMediaTypeAll|Image|Video) and for each result
ensure the known required actions (e.g., the canonical image and video action
identifiers your code expects) are contained in the returned slice (use a helper
contains/map lookup to check membership), and only optionally assert that the
returned set is non-empty instead of hardcoded numeric expectations; update
references in the test to use contains checks against
getTaskActionsForMediaType.
relay/helper/price_test.go (1)

17-178: LGTM — good coverage of the new override semantics.

Tests cleanly assert the intended behavior: when a user-group override is present, GroupPriceOverride=true, GroupPriceOverrideGroup="vip", the override price is used, and GroupRatioInfo.GroupRatio stays at the UsingGroup ratio (1.0) rather than being multiplied in — matching the "Prefer user group for media fixed pricing" commit.

Minor: the tests mutate package-level globals (common.QuotaPerUnit, ratio_setting JSON). Since none of them call t.Parallel() this is safe today, but a single mis-addition of t.Parallel() in this package will silently break state isolation. Consider wrapping global mutations in a small withPricingConfig(t, fn) helper (or using t.Cleanup) so future parallelization doesn't silently race.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/helper/price_test.go` around lines 17 - 178, Tests mutate package-level
globals (common.QuotaPerUnit and ratio_setting JSON settings) which can race if
tests are parallelized; wrap each test's global mutations in a helper such as
withPricingConfig (or use t.Cleanup) that captures originals (e.g., original :=
ratio_setting.ModelPriceBySeconds2JSONString(), originalQuotaPerUnit :=
common.QuotaPerUnit), applies the temporary updates via ratio_setting.Update...
and setting common.QuotaPerUnit, and guarantees restoration by registering
cleanup to call the Update... back to originals and reset common.QuotaPerUnit;
update the tests (e.g.,
TestModelPriceHelperUsesSecondsPriceForChatCompatibleVideo,
TestModelPriceHelperUsesGroupResolutionPriceWithoutGroupRatio,
TestModelPriceHelperUsesGroupPerCallPriceWithoutGroupRatio,
TestModelPriceHelperFallsBackToSecondsMinPrice) to use that helper so state
isolation is enforced even if t.Parallel() is added later.
common/billing_model.go (1)

28-50: Minor: allocation in resolution-only branch is wasted.

filtered := make(map[string]float64, len(ratios)) at line 33 is allocated unconditionally, but the IsResolutionOnlyBillingModel branch returns its own (empty) filtered at line 40 via early return of a freshly referenced map — fine, but you could simplify by short-circuiting before the allocation, or by returning map[string]float64{} explicitly to make intent obvious.

Proposed tidy-up
 func FilterOtherRatiosForBillingModel(modelName string, ratios map[string]float64) map[string]float64 {
 	if len(ratios) == 0 {
 		return map[string]float64{}
 	}
+	if IsResolutionOnlyBillingModel(modelName) {
+		return map[string]float64{}
+	}
 
 	filtered := make(map[string]float64, len(ratios))
 	switch {
 	case IsDurationOnlyBillingModel(modelName):
 		if ratio, ok := ratios["seconds"]; ok && ratio > 0 {
 			filtered["seconds"] = ratio
 		}
-	case IsResolutionOnlyBillingModel(modelName):
-		return filtered
 	default:
 		for key, ratio := range ratios {
 			if ratio > 0 {
 				filtered[key] = ratio
 			}
 		}
 	}
 
 	return filtered
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@common/billing_model.go` around lines 28 - 50, The allocation of filtered in
FilterOtherRatiosForBillingModel is done before checking
IsResolutionOnlyBillingModel, wasting an allocation for the resolution-only
branch; move the IsResolutionOnlyBillingModel(modelName) check before creating
filtered (or return map[string]float64{} immediately in that branch) and only
call filtered := make(map[string]float64, len(ratios)) when needed (e.g., after
handling IsResolutionOnlyBillingModel and IsDurationOnlyBillingModel branches)
so the empty map allocation is avoided.
controller/asset.go (1)

146-172: sliceCreativeCenterAssets copies every element for the "hide username" case — small but avoidable.

You allocate a fresh CreativeCenterAsset per item just to clear Username. If includeUsername is true, you could return the subslice directly (same pointers), and if it's false, clear the field in a single pass without copyAsset := *asset. Micro-optimization, but since this runs on every list request it's worth the pass.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/asset.go` around lines 146 - 172, The current
sliceCreativeCenterAssets always makes a deep copy of each element to blank
Username, which is unnecessary; change it to return the subslice
assets[startIdx:endIdx] directly when includeUsername is true, and when
includeUsername is false allocate items once (capacity endIdx-startIdx) and in a
single pass append the original pointers but set asset.Username = "" on each
non-nil element (instead of doing copyAsset := *asset), preserving nil checks
and bounds logic using startIdx/pageSize/endIdx as already computed.
web/src/hooks/task-logs/useTaskLogsData.js (1)

38-73: Hardcoded Chinese label strings in a reusable hook.

TASK_STATS_RANGE_PRESETS and TASK_MEDIA_TYPE_OPTIONS embed Chinese labels ('今天', '全部', …) and are re-exported via taskStatsRangePresets / taskMediaTypeOptions. Consumers will either render those verbatim (bypassing i18n) or need to call t(preset.label) at the call site, which is fragile and easy to miss. Prefer returning only the i18n key (e.g., labelKey: '今天') and document that the consumer must wrap it in t(...), or compute the label inside the hook via useTranslation. As per coding guidelines: "Use useTranslation() hook and call t('中文key') in components."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/task-logs/useTaskLogsData.js` around lines 38 - 73,
TASK_STATS_RANGE_PRESETS and TASK_MEDIA_TYPE_OPTIONS currently contain hardcoded
Chinese label strings; update the hook to compute localized labels inside it by
importing and using useTranslation() and replacing label fields with t(...)
results (or alternatively expose labelKey instead of label) so consumers no
longer receive raw Chinese text. Specifically, modify TASK_STATS_RANGE_PRESETS
and TASK_MEDIA_TYPE_OPTIONS and the exported taskStatsRangePresets /
taskMediaTypeOptions to call const { t } = useTranslation() and set each
preset/option label to t('your.i18n.key') (or expose labelKey if you prefer the
consumer to translate) and ensure all references to label in consumers use the
new shape.
controller/async_video.go (1)

65-75: Dead-defensive nil check after already dereferencing c.Request.

c.Request.Method and c.Request.Header.Clone() are read unconditionally at Lines 68-69, yet Line 73 checks c.Request != nil && c.Request.URL != nil. If c.Request were actually nil the goroutine would have panicked three lines earlier. Move the guard before the first dereference or drop it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/async_video.go` around lines 65 - 75, The code defensively checks
c.Request only when setting job.RawQuery but already dereferences c.Request
earlier (c.Request.Method and c.Request.Header.Clone()), which is inconsistent
and can panic; either move the guard (c.Request != nil && c.Request.URL != nil)
to run before any use of c.Request or remove the guard entirely if c.Request is
guaranteed non-nil. Update the asyncVideoJob construction so that access to
c.Request.Method, c.Request.Header.Clone(), and c.Request.URL.RawQuery are
performed only after verifying c.Request (and c.Request.URL where needed),
referring to asyncVideoJob, c.Request.Method, c.Request.Header.Clone(), and
job.RawQuery to locate the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bb2d0fcd-c926-44e0-988e-3c58499aa73c

📥 Commits

Reviewing files that changed from the base of the PR and between f995a86 and 0cce2a6.

⛔ Files ignored due to path filters (1)
  • web/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (144)
  • common/billing_model.go
  • common/billing_model_test.go
  • common/endpoint_defaults.go
  • common/endpoint_type.go
  • common/endpoint_type_test.go
  • common/init.go
  • common/model.go
  • constant/endpoint_type.go
  • constant/env.go
  • constant/task.go
  • controller/asset.go
  • controller/async_image.go
  • controller/async_video.go
  • controller/async_video_test.go
  • controller/creative_center_history.go
  • controller/creative_center_upload.go
  • controller/playground.go
  • controller/relay.go
  • controller/task.go
  • docs/linksky-api-usage.md
  • dto/asset.go
  • dto/async_image.go
  • dto/async_video.go
  • dto/openai_image.go
  • dto/openai_image_test.go
  • dto/openai_request.go
  • dto/openai_request_zero_value_test.go
  • dto/task.go
  • dto/task_stats.go
  • middleware/distributor.go
  • model/creative_center_asset.go
  • model/creative_center_asset_test.go
  • model/creative_center_history.go
  • model/log.go
  • model/main.go
  • model/option.go
  • model/pricing.go
  • model/task.go
  • model/task_cas_test.go
  • model/task_stats.go
  • model/task_stats_test.go
  • model/user.go
  • old_index.jsx
  • relay/channel/openai/adaptor.go
  • relay/channel/openai/adaptor_test.go
  • relay/channel/task/gemini/adaptor.go
  • relay/channel/task/sora/adaptor.go
  • relay/channel/task/sora/adaptor_test.go
  • relay/channel/task/vertex/adaptor.go
  • relay/channel/xai/adaptor.go
  • relay/channel/xai/adaptor_test.go
  • relay/channel/xai/constants.go
  • relay/channel/xai/dto.go
  • relay/common/relay_info.go
  • relay/common/relay_info_test.go
  • relay/common/relay_utils.go
  • relay/constant/relay_mode.go
  • relay/constant/relay_mode_test.go
  • relay/helper/price.go
  • relay/helper/price_test.go
  • relay/relay_adaptor.go
  • relay/relay_task.go
  • relay/relay_task_test.go
  • replace.js
  • router/api-router.go
  • router/relay-router.go
  • router/video-router.go
  • service/billing_session.go
  • service/channel_affinity_usage_cache_test.go
  • service/creative_center_asset_archive.go
  • service/creative_center_asset_archive_test.go
  • service/log_info_generate.go
  • service/quota.go
  • service/task_billing.go
  • service/task_billing_test.go
  • service/task_polling.go
  • service/task_polling_transient_test.go
  • service/text_quota.go
  • service/text_quota_test.go
  • setting/ratio_setting/group_model_price_test.go
  • setting/ratio_setting/model_price_by_resolution_test.go
  • setting/ratio_setting/model_price_by_seconds_test.go
  • setting/ratio_setting/model_ratio.go
  • setting/system_setting/image_bed.go
  • types/price_data.go
  • web/src/App.jsx
  • web/src/components/layout/NoticeModal.jsx
  • web/src/components/layout/PageLayout.jsx
  • web/src/components/layout/SiderBar.jsx
  • web/src/components/layout/headerbar/ActionButtons.jsx
  • web/src/components/layout/headerbar/index.jsx
  • web/src/components/playground/SettingsPanel.jsx
  • web/src/components/settings/OtherSetting.jsx
  • web/src/components/settings/RatioSetting.jsx
  • web/src/components/settings/SystemSetting.jsx
  • web/src/components/settings/personal/cards/NotificationSettings.jsx
  • web/src/components/table/channels/modals/ModelTestModal.jsx
  • web/src/components/table/model-pricing/filter/PricingDisplaySettings.jsx
  • web/src/components/table/model-pricing/filter/PricingQuotaTypes.jsx
  • web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  • web/src/components/table/model-pricing/layout/content/PricingContent.jsx
  • web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
  • web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
  • web/src/components/table/model-pricing/layout/header/SearchActions.jsx
  • web/src/components/table/model-pricing/modal/PricingFilterModal.jsx
  • web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx
  • web/src/components/table/model-pricing/modal/components/ModelPricingTable.jsx
  • web/src/components/table/model-pricing/view/card/PricingCardView.jsx
  • web/src/components/table/model-pricing/view/table/PricingTableColumns.jsx
  • web/src/components/table/models/ModelsColumnDefs.jsx
  • web/src/components/table/models/modals/EditModelModal.jsx
  • web/src/components/table/models/modals/EditPrefillGroupModal.jsx
  • web/src/components/table/task-logs/TaskLogsColumnDefs.jsx
  • web/src/components/table/task-logs/TaskLogsDashboard.jsx
  • web/src/components/table/task-logs/TaskLogsFilters.jsx
  • web/src/components/table/task-logs/TaskLogsTable.jsx
  • web/src/components/table/task-logs/index.jsx
  • web/src/components/table/task-logs/modals/ContentModal.jsx
  • web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  • web/src/constants/common.constant.js
  • web/src/constants/playground.constants.js
  • web/src/context/Theme/index.jsx
  • web/src/helpers/api.js
  • web/src/helpers/render.jsx
  • web/src/helpers/utils.jsx
  • web/src/hooks/common/useHeaderBar.js
  • web/src/hooks/common/useNavigation.js
  • web/src/hooks/common/useSidebar.js
  • web/src/hooks/dashboard/useDashboardData.js
  • web/src/hooks/model-pricing/useModelPricingData.jsx
  • web/src/hooks/playground/useApiRequest.jsx
  • web/src/hooks/task-logs/useTaskLogsData.js
  • web/src/hooks/usage-logs/useUsageLogsData.jsx
  • web/src/pages/About/index.jsx
  • web/src/pages/Asset/index.jsx
  • web/src/pages/CreativeCenter/index.jsx
  • web/src/pages/Home/index.jsx
  • web/src/pages/Setting/Operation/SettingsHeaderNavModules.jsx
  • web/src/pages/Setting/Operation/SettingsSidebarModulesAdmin.jsx
  • web/src/pages/Setting/Ratio/GroupRatioSettings.jsx
  • web/src/pages/Setting/Ratio/ModelRatioSettings.jsx
  • web/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx
  • web/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js
  • web/tailwind.config.js
💤 Files with no reviewable changes (8)
  • web/src/components/table/model-pricing/modal/PricingFilterModal.jsx
  • web/src/components/table/model-pricing/layout/header/PricingVendorIntro.jsx
  • web/src/components/table/model-pricing/layout/content/PricingContent.jsx
  • web/src/components/layout/headerbar/ActionButtons.jsx
  • web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  • web/src/components/layout/headerbar/index.jsx
  • web/src/components/table/model-pricing/layout/header/PricingTopSection.jsx
  • web/src/components/table/model-pricing/modal/components/FilterModalContent.jsx

Comment thread controller/async_video.go
Comment on lines +240 to +250
RelayTask(ctx)

responseBody := recorder.Body.Bytes()
statusCode := recorder.Code
if statusCode == 0 {
statusCode = http.StatusOK
}
if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices {
updateAsyncVideoTaskFailure(task, responseBody, extractPlaygroundTaskErrorMessage(responseBody, "async video request failed"))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that the sync/async video submit path persists UpstreamTaskID reliably before RelayTask returns 2xx.
rg -nP --type=go -C5 'UpstreamTaskID\s*='
rg -nP --type=go -C5 'RefreshVideoTask'

Repository: QuantumNous/new-api

Length of output: 2137


🏁 Script executed:

# Get full context of async_video.go around lines 240-250
sed -n '220,260p' controller/async_video.go

Repository: QuantumNous/new-api

Length of output: 1344


🏁 Script executed:

# Check what updateAsyncVideoTaskRunning does
rg -nP --type=go 'func updateAsyncVideoTaskRunning' -A 10

Repository: QuantumNous/new-api

Length of output: 763


🏁 Script executed:

# Find updateAsyncImageTaskSuccess for comparison
rg -nP --type=go 'updateAsyncImageTaskSuccess' -B 5 -A 10

Repository: QuantumNous/new-api

Length of output: 2055


🏁 Script executed:

# Check the RelayTask function for video context
rg -nP --type=go 'func.*RelayTask\(ctx' -A 30 | head -80

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check if there's a finalization function for async video success similar to image
rg -nP --type=go 'updateAsyncVideoTaskSuccess|updateAsyncVideoTaskCompleted|updateAsyncVideoTaskDone' -B 2 -A 15

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check the background scheduler logic to see if it gates on UpstreamTaskID
rg -nP --type=go 'RefreshVideoTask|UpstreamTaskID' controller/async_video.go service/task_polling.go -B 3 -A 3

Repository: QuantumNous/new-api

Length of output: 2317


Add success handler to match async image flow.

When RelayTask completes with 2xx, the task remains in TaskStatusInProgress without finalization—unlike controller/async_image.go line 224, which calls updateAsyncImageTaskSuccess on success. The video task then depends entirely on client-side polling (RefreshVideoTask at line 100) or background scheduler refresh (which gates on PrivateData.UpstreamTaskID being non-empty at controller/async_video.go:126).

This creates an asymmetry: if the upstream provider does not return an UpstreamTaskID in the initial response, the task cannot progress via polling and will never finalize, leaving it permanently in IN_PROGRESS. Consider adding a success handler (e.g., updateAsyncVideoTaskSuccess) to capture UpstreamTaskID and finalize the task state, mirroring the image flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/async_video.go` around lines 240 - 250, The handler currently only
treats non-2xx as failures after calling RelayTask, leaving tasks in
TaskStatusInProgress for successful 2xx responses; add a success path similar to
controller/async_image.go by checking when statusCode is in the 2xx range
(http.StatusOK <= statusCode < http.StatusMultipleChoices) and calling
updateAsyncVideoTaskSuccess(task, responseBody) (or the appropriate signature)
to record the UpstreamTaskID and finalize the task state; keep the existing
updateAsyncVideoTaskFailure(task, responseBody,
extractPlaygroundTaskErrorMessage(...)) for non-2xx responses and ensure you
still default statusCode from recorder.Code the same way.

Comment thread model/task.go
Comment on lines 115 to 126
// TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。
type TaskBillingContext struct {
ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
GroupPriceOverride bool `json:"group_price_override,omitempty"`
GroupPriceOverrideGroup string `json:"group_price_override_group,omitempty"`
UsingGroup string `json:"using_group,omitempty"`
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all read-sites of the new fields to confirm graceful fallback for pre-PR tasks.
rg -nP --type=go -C3 'GroupPriceOverride\b|GroupPriceOverrideGroup\b|UsingGroup\s*:'

Repository: QuantumNous/new-api

Length of output: 20146


🏁 Script executed:

rg -nP --type=go 'updateVideoSingleTask|settle|doRefund|refundTask' -A 5 | head -100

Repository: QuantumNous/new-api

Length of output: 6616


🏁 Script executed:

# Find where TaskBillingContext is used in task polling/refund
rg -nP --type=go 'task\.PrivateData|TaskBillingContext' service/task_polling.go -B 2 -A 5

Repository: QuantumNous/new-api

Length of output: 2237


🏁 Script executed:

# Check task_billing.go for how it uses these fields during refund
rg -nP --type=go -A 10 'func.*[Rr]efund|func.*[Ss]ettle' service/task_billing.go

Repository: QuantumNous/new-api

Length of output: 409


🏁 Script executed:

rg -nP --type=go 'func RecalculateTaskQuota' -A 20

Repository: QuantumNous/new-api

Length of output: 2293


🏁 Script executed:

# Look for how UsingGroup and GroupPriceOverrideGroup are actually used in billing/refund
rg -nP --type=go 'UsingGroup|GroupPriceOverrideGroup' service/task_billing.go -B 3 -A 3

Repository: QuantumNous/new-api

Length of output: 699


🏁 Script executed:

# Check if there are any fallback patterns when these fields are empty
rg -nP --type=go 'task\.Group|billing.*group' service/task_billing.go -i

Repository: QuantumNous/new-api

Length of output: 224


🏁 Script executed:

# Search for any code that reads BillingContext fields during billing operations
rg -nP --type=go 'BillingContext|PrivateData\..*Group' service/task_billing.go -B 2 -A 5

Repository: QuantumNous/new-api

Length of output: 796


🏁 Script executed:

# Check if UsingGroup or GroupPriceOverrideGroup from BillingContext are used anywhere in actual quota calculation
rg -nP --type=go 'bc\.(UsingGroup|GroupPriceOverrideGroup|GroupPriceOverride)' service/ -B 2 -A 3

Repository: QuantumNous/new-api

Length of output: 735


🏁 Script executed:

# Verify the complete flow of RecalculateTaskQuotaByTokens to see if it ever considers the stored billing context
cat -n service/task_billing.go | sed -n '264,330p'

Repository: QuantumNous/new-api

Length of output: 1453


🏁 Script executed:

# Check if GroupPriceOverride from BillingContext actually affects quota calculation in refund flow
rg -nP --type=go 'BillingContext.*GroupPrice|if.*GroupPrice' service/task_billing.go -B 3 -A 8

Repository: QuantumNous/new-api

Length of output: 498


🏁 Script executed:

# Verify that RecalculateTaskQuota itself doesn't use BillingContext
cat -n service/task_billing.go | sed -n '200,260p'

Repository: QuantumNous/new-api

Length of output: 2103


🏁 Script executed:

# Look for any billing context usage in the actual refund path
rg -nP --type=go 'RefundTaskQuota|taskAdjustFunding' service/task_billing.go -A 15 | head -60

Repository: QuantumNous/new-api

Length of output: 2055


🏁 Script executed:

# Check if RecalculateTaskQuotaByTokens respects GroupPriceOverride from BillingContext
rg -nP --type=go 'GroupPriceOverride' service/task_billing.go

Repository: QuantumNous/new-api

Length of output: 300


🏁 Script executed:

# Comprehensive check: does any refund/settle code path use GroupPriceOverride from BillingContext to skip group ratio?
rg -nP --type=go 'PerCallBilling|GroupPriceOverride' service/task_billing.go -B 2 -A 2

Repository: QuantumNous/new-api

Length of output: 576


🏁 Script executed:

# Check the actual flow in task_polling.go settle path
cat -n service/task_polling.go | sed -n '636,670p'

Repository: QuantumNous/new-api

Length of output: 841


🏁 Script executed:

# Check if there's any TODO or FIXME related to using BillingContext in settle
rg -nP --type=go 'TODO|FIXME' service/task_billing.go

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check the learnings context - verify what the intended behavior should be
# by looking at how it's supposed to work based on test expectations
cat -n service/task_billing_test.go | sed -n '425,485p'

Repository: QuantumNous/new-api

Length of output: 2334


🏁 Script executed:

# Verify: does RecalculateTaskQuotaByTokens ever consult BillingContext to check GroupPriceOverride?
rg -nP --type=go 'RecalculateTaskQuotaByTokens' -A 50 service/task_billing.go | grep -i 'billingcontext\|groupprice'

Repository: QuantumNous/new-api

Length of output: 45


Stored billing-override fields in TaskBillingContext are not consulted during polling settle/refund — tokens path will ignore GroupPriceOverride.

GroupPriceOverride, GroupPriceOverrideGroup, and UsingGroup are persisted into task.PrivateData.BillingContext to carry "fixed media pricing per group" decisions into async refund/settlement. However, the actual quota recalculation paths do not read these fields:

  • settleTaskBillingOnComplete() only checks PerCallBilling, not GroupPriceOverride.
  • RecalculateTaskQuotaByTokens() always applies groupRatio (line 290–301) regardless of whether bc.GroupPriceOverride was true at submit time.

If a task was originally submitted with GroupPriceOverride=true (meaning fixed pricing at the group's media rate, without per-user ratio scaling), and polling later triggers the token-recalculation path, the quota will be incorrectly recalculated with the group ratio applied. This violates the original pricing intent.

The BillingContext fields must be consulted during quota recalculation to skip group ratio multiplication when GroupPriceOverride=true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@model/task.go` around lines 115 - 126, The quota recalculation paths must
honor TaskBillingContext.GroupPriceOverride: update settleTaskBillingOnComplete
and RecalculateTaskQuotaByTokens to read the saved billing context
(task.PrivateData.BillingContext -> TaskBillingContext) and, when
bc.GroupPriceOverride == true, skip applying bc.GroupRatio (i.e., do not
multiply by GroupRatio) and instead use the stored group pricing intent (use
GroupPriceOverrideGroup / UsingGroup as needed) so the original fixed-per-group
pricing is preserved during polling/settlement. Ensure both functions check
bc.PerCallBilling as before and short-circuit consistently when
bc.GroupPriceOverride is set.

Comment on lines +46 to +65
const normalizeStaleTimestamp = (value) => {
const text = typeof value === 'string' ? value.trim() : '';
return text ? text.slice(0, 16) : '';
};

const buildLogsStaleKey = (scope, filters = {}) =>
[
'usage-logs',
scope,
isAdminUser ? 'admin' : 'user',
filters.type ?? '',
filters.username || '',
filters.token_name || '',
filters.model_name || '',
filters.channel || '',
filters.group || '',
filters.request_id || '',
normalizeStaleTimestamp(filters.start_timestamp),
normalizeStaleTimestamp(filters.end_timestamp),
].join(':');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C5 'staleCacheKey' web/src/helpers/api.js

Repository: QuantumNous/new-api

Length of output: 437


🏁 Script executed:

#!/bin/bash
# Get more context around buildGetRequestKey and how staleCacheKey is used
rg -nP -B2 -A15 'function buildGetRequestKey' web/src/helpers/api.js

Repository: QuantumNous/new-api

Length of output: 681


🏁 Script executed:

#!/bin/bash
# Find where buildLogsStaleKey is called and what filters are passed
rg -nP 'buildLogsStaleKey' web/src/hooks/usage-logs/useUsageLogsData.jsx

Repository: QuantumNous/new-api

Length of output: 288


🏁 Script executed:

#!/bin/bash
# Check how stat-self and list API calls use filters/pagination
rg -nP -B3 -A3 "(stat-self|list)" web/src/hooks/usage-logs/useUsageLogsData.jsx | head -80

Repository: QuantumNous/new-api

Length of output: 538


🏁 Script executed:

#!/bin/bash
# Get full list API call context
rg -nP -A10 "staleCacheKey: buildLogsStaleKey\('list'" web/src/hooks/usage-logs/useUsageLogsData.jsx

Repository: QuantumNous/new-api

Length of output: 375


🏁 Script executed:

#!/bin/bash
# Get full stat-self API call context
rg -nP -A10 "staleCacheKey: buildLogsStaleKey\('stat-self'" web/src/hooks/usage-logs/useUsageLogsData.jsx

Repository: QuantumNous/new-api

Length of output: 401


🏁 Script executed:

#!/bin/bash
# Find 429 handling/stale cache logic in api.js
rg -nP -B5 -A5 '429|stale|GET_STALE_CACHE' web/src/helpers/api.js | head -100

Repository: QuantumNous/new-api

Length of output: 2128


🏁 Script executed:

#!/bin/bash
# Get the full list API call including all config parameters
rg -nP -B5 -A20 "staleCacheKey: buildLogsStaleKey\('list'" web/src/hooks/usage-logs/useUsageLogsData.jsx

Repository: QuantumNous/new-api

Length of output: 826


🏁 Script executed:

#!/bin/bash
# Check if page/pageSize/startIdx appear anywhere in the list request or hook
rg -nP 'page|startIdx|pageSize' web/src/hooks/usage-logs/useUsageLogsData.jsx | head -30

Repository: QuantumNous/new-api

Length of output: 1134


Include pagination parameters in cache key for list requests.

buildLogsStaleKey('list', ...) omits page (via p query param) and page_size parameters from the cache key. The URL embeds these as /api/log/?p=${startIdx}&page_size=${pageSize}&..., but since staleCacheKey is used as a custom key, buildGetRequestKey ignores the query params entirely. This creates a cache collision: requests to different pages generate identical cache keys. When a 429 error occurs on page 2, tryResolveStaleGetResponse returns the cached page 1 response because the keys match.

Include pagination in buildLogsStaleKey('list', ...):

buildLogsStaleKey = (scope, filters = {}) => [
  'usage-logs',
  scope,
  isAdminUser ? 'admin' : 'user',
  filters.type ?? '',
  filters.username || '',
  filters.token_name || '',
  filters.model_name || '',
  filters.channel || '',
  filters.group || '',
  filters.request_id || '',
  filters.start_timestamp || '',
  filters.end_timestamp || '',
  filters.page || '',           // Add
  filters.page_size || '',      // Add
].join(':');

Then pass them when calling the list endpoint:

staleCacheKey: buildLogsStaleKey('list', {
  type: currentLogType,
  username,
  token_name,
  model_name,
  channel,
  group,
  request_id,
  start_timestamp,
  end_timestamp,
  page: startIdx,       // Add
  page_size: pageSize,  // Add
}),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/hooks/usage-logs/useUsageLogsData.jsx` around lines 46 - 65, The
cache key built by buildLogsStaleKey (used as staleCacheKey for list requests)
omits pagination, causing different pages to share the same key and return wrong
cached responses; update buildLogsStaleKey (and keep normalizeStaleTimestamp
as-is) to include filters.page and filters.page_size in the joined key, and
update callers that invoke buildLogsStaleKey('list', ...) to pass page and
page_size (the p/startIdx and pageSize values) so each paginated list request
gets a unique staleCacheKey; no other behavior changes required.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
service/task_polling.go (1)

487-518: ⚠️ Potential issue | 🟠 Major

New transient/not-found handling is skipped for non-JSON response bodies.

The new logic at lines 502-515 only runs when the outer common.Unmarshal(responseBody, &errorResult) succeeds (line 490). If an upstream returns a non-JSON body — e.g. a bare Not Found from an edge proxy/nginx, an HTML error page, or any text — Unmarshal fails, the whole block is skipped, taskResult.Status stays "", and the switch falls through to default: on line 565, returning "unknown task status for task ...". Net effect: the exact "generic 404 while result is publishing" case this PR is meant to handle is silently bypassed whenever the body isn't valid JSON, which is very common for gateway-level 404s.

Consider running the transient check before/outside the JSON branch so it is independent of GeneralErrorResponse parsing.

🛠️ Suggested restructure
 	if taskResult.Status == "" {
-		//taskResult = relaycommon.FailTaskInfo("upstream returned empty status")
-		errorResult := &dto.GeneralErrorResponse{}
-		if err = common.Unmarshal(responseBody, &errorResult); err == nil {
-			openaiError := errorResult.TryToOpenAIError()
-			if openaiError != nil {
-				if openaiError.Code == "429" {
-					return nil
-				}
-				taskResult = relaycommon.FailTaskInfo("upstream returned error")
-			} else {
-				bodyLower := strings.ToLower(string(responseBody))
-				if isTransientVideoNotFoundResponse(resp.StatusCode, responseBody, task.SubmitTime, now, task.Properties.OriginModelName, task.Properties.UpstreamModelName) {
-					logger.LogInfo(ctx, fmt.Sprintf("Task %s upstream result not ready yet, keep polling, response: %s", taskId, string(responseBody)))
-					return nil
-				}
-				if strings.Contains(bodyLower, "not found") {
-					taskResult = relaycommon.FailTaskInfo("upstream task not found")
-					taskResult.Reason = strings.TrimSpace(string(responseBody))
-				} else {
-					logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, keep polling, response: %s", taskId, string(responseBody)))
-					return nil
-				}
-			}
-		}
+		// Handle transient 404s even when the body isn't valid JSON (bare "Not Found"
+		// from gateways, HTML error pages, etc.).
+		if isTransientVideoNotFoundResponse(resp.StatusCode, responseBody, task.SubmitTime, now, task.Properties.OriginModelName, task.Properties.UpstreamModelName) {
+			logger.LogInfo(ctx, fmt.Sprintf("Task %s upstream result not ready yet, keep polling, response: %s", taskId, string(responseBody)))
+			return nil
+		}
+		bodyLower := strings.ToLower(string(responseBody))
+		errorResult := &dto.GeneralErrorResponse{}
+		if err = common.Unmarshal(responseBody, &errorResult); err == nil {
+			if openaiError := errorResult.TryToOpenAIError(); openaiError != nil {
+				if openaiError.Code == "429" {
+					return nil
+				}
+				taskResult = relaycommon.FailTaskInfo("upstream returned error")
+			} else if strings.Contains(bodyLower, "not found") {
+				taskResult = relaycommon.FailTaskInfo("upstream task not found")
+				taskResult.Reason = strings.TrimSpace(string(responseBody))
+			} else {
+				logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with unrecognized error format, keep polling, response: %s", taskId, string(responseBody)))
+				return nil
+			}
+		} else if strings.Contains(bodyLower, "not found") {
+			taskResult = relaycommon.FailTaskInfo("upstream task not found")
+			taskResult.Reason = strings.TrimSpace(string(responseBody))
+		} else {
+			logger.LogError(ctx, fmt.Sprintf("Task %s returned empty status with non-JSON body, keep polling, response: %s", taskId, string(responseBody)))
+			return nil
+		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling.go` around lines 487 - 518, When taskResult.Status is
empty, the transient "not found" detection (isTransientVideoNotFoundResponse)
should run regardless of whether common.Unmarshal(responseBody, &errorResult)
succeeds; move or duplicate the call so the transient check executes before
attempting to parse JSON (i.e., in the block where taskResult.Status == "" and
before/independent of invoking common.Unmarshal and
dto.GeneralErrorResponse.TryToOpenAIError), and only fall back to JSON-specific
handling (TryToOpenAIError and extracting error.Code/strings.Contains) if
parsing succeeds—this ensures non-JSON upstream bodies (plain "Not Found" or
HTML) are properly recognized as transient and keep polling instead of falling
through to the default unknown status.
🧹 Nitpick comments (1)
service/task_polling.go (1)

52-55: Align with CacheGetChannel for consistency across polling functions.

updateSunoTasks and updateVideoTasks both use model.CacheGetChannel() to retrieve channel info during polling. RefreshVideoTask should do the same—there's no documented reason why this manual user-triggered refresh requires a direct DB hit instead of the cached variant, and changing it would improve consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling.go` around lines 52 - 55, RefreshVideoTask currently
calls model.GetChannelById(task.ChannelId, true) while other polling functions
(updateSunoTasks, updateVideoTasks) use model.CacheGetChannel for consistency;
change RefreshVideoTask to call model.CacheGetChannel with the same semantics
used elsewhere (pass the task.ChannelId and the same cache flags) instead of
GetChannelById so the polling path uses cached channel lookups consistent with
updateSunoTasks/updateVideoTasks and avoids an unnecessary direct DB hit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@service/task_polling.go`:
- Around line 92-99: The current check returns true when submitTime <= 0 (or now
<= 0), which incorrectly keeps legacy/missing-submit-time tasks polling; change
the permissive fallback to fail-safe by returning false instead: update the
condition around submitTime and now (the lines checking "if submitTime <= 0 ||
now <= 0") to return false instead of true so the logic that uses
constant.TaskNotFoundGraceMinutes enforces the stricter path and lets
sweepTimedOutTasks handle eventual cleanup.

---

Outside diff comments:
In `@service/task_polling.go`:
- Around line 487-518: When taskResult.Status is empty, the transient "not
found" detection (isTransientVideoNotFoundResponse) should run regardless of
whether common.Unmarshal(responseBody, &errorResult) succeeds; move or duplicate
the call so the transient check executes before attempting to parse JSON (i.e.,
in the block where taskResult.Status == "" and before/independent of invoking
common.Unmarshal and dto.GeneralErrorResponse.TryToOpenAIError), and only fall
back to JSON-specific handling (TryToOpenAIError and extracting
error.Code/strings.Contains) if parsing succeeds—this ensures non-JSON upstream
bodies (plain "Not Found" or HTML) are properly recognized as transient and keep
polling instead of falling through to the default unknown status.

---

Nitpick comments:
In `@service/task_polling.go`:
- Around line 52-55: RefreshVideoTask currently calls
model.GetChannelById(task.ChannelId, true) while other polling functions
(updateSunoTasks, updateVideoTasks) use model.CacheGetChannel for consistency;
change RefreshVideoTask to call model.CacheGetChannel with the same semantics
used elsewhere (pass the task.ChannelId and the same cache flags) instead of
GetChannelById so the polling path uses cached channel lookups consistent with
updateSunoTasks/updateVideoTasks and avoids an unnecessary direct DB hit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: df3ff3cc-c63e-4e8e-a892-9b936fd479fb

📥 Commits

Reviewing files that changed from the base of the PR and between c25e873 and 1938952.

📒 Files selected for processing (2)
  • service/task_polling.go
  • service/task_polling_transient_test.go
✅ Files skipped from review due to trivial changes (1)
  • service/task_polling_transient_test.go

Comment thread service/task_polling.go
Comment on lines +92 to +99
if constant.TaskNotFoundGraceMinutes <= 0 {
return false
}
if submitTime <= 0 || now <= 0 {
return true
}
return now-submitTime <= int64(constant.TaskNotFoundGraceMinutes)*60
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Permissive fallback when submitTime <= 0.

When TaskNotFoundGraceMinutes > 0 but submitTime is missing or zero (e.g. persisted before SubmitTime was reliably set, or imported/legacy rows), this returns true unconditionally, so the task stays in "keep polling" forever until sweepTimedOutTasks eventually kills it. Returning false here would fail-safe to the stricter path and rely on the submit-time invariant the rest of the codebase assumes.

🛡️ Suggested fix
-	if submitTime <= 0 || now <= 0 {
-		return true
-	}
+	if submitTime <= 0 || now <= 0 {
+		return false
+	}
 	return now-submitTime <= int64(constant.TaskNotFoundGraceMinutes)*60
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if constant.TaskNotFoundGraceMinutes <= 0 {
return false
}
if submitTime <= 0 || now <= 0 {
return true
}
return now-submitTime <= int64(constant.TaskNotFoundGraceMinutes)*60
}
if constant.TaskNotFoundGraceMinutes <= 0 {
return false
}
if submitTime <= 0 || now <= 0 {
return false
}
return now-submitTime <= int64(constant.TaskNotFoundGraceMinutes)*60
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/task_polling.go` around lines 92 - 99, The current check returns true
when submitTime <= 0 (or now <= 0), which incorrectly keeps
legacy/missing-submit-time tasks polling; change the permissive fallback to
fail-safe by returning false instead: update the condition around submitTime and
now (the lines checking "if submitTime <= 0 || now <= 0") to return false
instead of true so the logic that uses constant.TaskNotFoundGraceMinutes
enforces the stricter path and lets sweepTimedOutTasks handle eventual cleanup.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@relay/channel/task/gemini/dto.go`:
- Around line 73-82: Change the OperationError struct's Code field from string
to any (interface{}) so numeric codes (e.g., int32) can unmarshal correctly;
update the type declaration of OperationError (the Code field) and keep the
custom UnmarshalJSON implementation (operationErrorAlias / Unmarshal JSON)
unchanged so the alias-based unmarshal picks up the new any type and preserves
compatibility with string codes.

In `@relay/channel/task/sora/adaptor.go`:
- Around line 143-148: The helper relayInfoUpstreamModelName incorrectly returns
"" when info.ChannelMeta is nil even if info.UpstreamModelName is set, causing
BuildRequestURL to pick the wrong endpoint; update relayInfoUpstreamModelName to
only guard against a nil info (i.e., check info == nil) and return
info.UpstreamModelName directly without gating on ChannelMeta so BuildRequestURL
can correctly detect an upstream model name.
- Around line 510-568: The multipart path currently skips writing the "seconds"
field for Sora models (the continue in the formData.Value loop that checks
isSoraVideoModel and key == "seconds"), so requests like seconds=8 get dropped
when duration is synthesized; change the loop logic to stop skipping "seconds"
(i.e., remove "key == \"seconds\"" from the continue condition) so original
seconds values are written via writer.WriteField, and keep the existing duration
fallback logic (durationValue defaulting to "4" and writing "duration") so both
seconds and duration are preserved for Sora models; refer to the formData.Value
loop, the hasSeconds/durationValue variables,
isSoraVideoModel(info.UpstreamModelName), and writer.WriteField usages to locate
the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 241df049-f1b1-4376-9a72-a54e4e172663

📥 Commits

Reviewing files that changed from the base of the PR and between 1938952 and 0fc1489.

📒 Files selected for processing (6)
  • relay/channel/task/gemini/adaptor_test.go
  • relay/channel/task/gemini/dto.go
  • relay/channel/task/sora/adaptor.go
  • relay/channel/task/sora/adaptor_test.go
  • relay/channel/task/vertex/adaptor.go
  • relay/channel/task/vertex/adaptor_test.go
✅ Files skipped from review due to trivial changes (1)
  • relay/channel/task/sora/adaptor_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • relay/channel/task/vertex/adaptor.go

Comment on lines +73 to +82
type OperationError struct {
Message string `json:"message"`
Code string `json:"code"`
}

func (e *OperationError) UnmarshalJSON(data []byte) error {
switch common.GetJsonType(data) {
case "object":
type operationErrorAlias OperationError
return common.Unmarshal(data, (*operationErrorAlias)(e))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Google Cloud long-running Operation error field schema code type

💡 Result:

In Google Cloud long-running operations (LROs), defined in google.longrunning.Operation protobuf, the error field is of type google.rpc.Status. This Status message has: - code: int32 (an enum value from google.rpc.Code, e.g., 1 for CANCELLED, 5 for NOT_FOUND) - message: string (developer-facing English error message) - details: repeated google.protobuf.Any (list of error detail messages) When done=true and error is set (oneof result), it indicates failure or cancellation. This schema is standard across Google Cloud APIs. Protobuf definitions: - Operation.error = google.rpc.Status error = 4; - Status: message Status { int32 code = 1; string message = 2; repeated google.protobuf.Any details = 3; }

Citations:


🏁 Script executed:

fd -t f "dto.go" relay/channel/task/gemini/

Repository: QuantumNous/new-api

Length of output: 95


🏁 Script executed:

if [ -f "relay/channel/task/gemini/dto.go" ]; then
  echo "=== File exists, checking OperationError context ==="
  sed -n '70,90p' relay/channel/task/gemini/dto.go
  echo ""
  echo "=== Checking usage of OperationError ==="
  rg -n "OperationError" relay/channel/task/gemini/
fi

Repository: QuantumNous/new-api

Length of output: 904


🏁 Script executed:

rg -n "ParseTaskResult\|OperationError" relay/channel/task/gemini/ -A 5 -B 2

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check the broader context of how this DTO is used
rg -n "json:" relay/channel/task/gemini/dto.go | head -20

Repository: QuantumNous/new-api

Length of output: 1176


🏁 Script executed:

# Look for the complete file structure around line 70 and how errors are handled
sed -n '65,100p' relay/channel/task/gemini/dto.go

Repository: QuantumNous/new-api

Length of output: 633


🏁 Script executed:

# Search for where these DTOs are unmarshaled
rg -n "Unmarshal\|json.Unmarshal\|ParseTaskResult" relay/channel/task/gemini/*.go -B 2 -A 3

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Check files in relay/channel/task/gemini to find usage
ls -la relay/channel/task/gemini/

Repository: QuantumNous/new-api

Length of output: 480


🏁 Script executed:

# Search across the gemini task implementation
rg -n "ParseTaskResult\|parseTaskResult\|TaskResult" relay/channel/task/gemini/*.go -B 1 -A 3

Repository: QuantumNous/new-api

Length of output: 45


🏁 Script executed:

# Search for where TaskResult (the struct containing OperationError) is used
rg -n "TaskResult" relay/channel/task/gemini/*.go -B 2 -A 5

Repository: QuantumNous/new-api

Length of output: 1976


🏁 Script executed:

# Look at the entire dto.go file to understand the structure
cat -n relay/channel/task/gemini/dto.go

Repository: QuantumNous/new-api

Length of output: 3712


🏁 Script executed:

# Check the ParseTaskResult function to see how it handles errors
sed -n '208,230p' relay/channel/task/gemini/adaptor.go

Repository: QuantumNous/new-api

Length of output: 642


🏁 Script executed:

# Search for any actual usage/examples of OperationError with numeric code
rg -n "code" relay/channel/task/gemini/adaptor_test.go

Repository: QuantumNous/new-api

Length of output: 314


Change OperationError.Code from string to any to accept numeric error codes.

Google Cloud long-running operations return error responses with numeric code fields (int32). The current Code string declaration causes unmarshal to fail on standard error objects, preventing the error message from being recorded. Changing to Code any preserves compatibility with both string and numeric codes.

Proposed fix
 type OperationError struct {
 	Message string `json:"message"`
-	Code    string `json:"code"`
+	Code    any    `json:"code"`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type OperationError struct {
Message string `json:"message"`
Code string `json:"code"`
}
func (e *OperationError) UnmarshalJSON(data []byte) error {
switch common.GetJsonType(data) {
case "object":
type operationErrorAlias OperationError
return common.Unmarshal(data, (*operationErrorAlias)(e))
type OperationError struct {
Message string `json:"message"`
Code any `json:"code"`
}
func (e *OperationError) UnmarshalJSON(data []byte) error {
switch common.GetJsonType(data) {
case "object":
type operationErrorAlias OperationError
return common.Unmarshal(data, (*operationErrorAlias)(e))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/task/gemini/dto.go` around lines 73 - 82, Change the
OperationError struct's Code field from string to any (interface{}) so numeric
codes (e.g., int32) can unmarshal correctly; update the type declaration of
OperationError (the Code field) and keep the custom UnmarshalJSON implementation
(operationErrorAlias / Unmarshal JSON) unchanged so the alias-based unmarshal
picks up the new any type and preserves compatibility with string codes.

Comment on lines +143 to +148
func relayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
if info == nil || info.ChannelMeta == nil {
return ""
}
return info.UpstreamModelName
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t gate UpstreamModelName on ChannelMeta.

BuildRequestURL uses this helper to choose /v1/video/generations; if RelayInfo.UpstreamModelName is set but ChannelMeta is nil, this returns "" and falls back to /v1/videos.

Proposed fix
 func relayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
-	if info == nil || info.ChannelMeta == nil {
+	if info == nil {
 		return ""
 	}
 	return info.UpstreamModelName
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func relayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
if info == nil || info.ChannelMeta == nil {
return ""
}
return info.UpstreamModelName
}
func relayInfoUpstreamModelName(info *relaycommon.RelayInfo) string {
if info == nil {
return ""
}
return info.UpstreamModelName
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/task/sora/adaptor.go` around lines 143 - 148, The helper
relayInfoUpstreamModelName incorrectly returns "" when info.ChannelMeta is nil
even if info.UpstreamModelName is set, causing BuildRequestURL to pick the wrong
endpoint; update relayInfoUpstreamModelName to only guard against a nil info
(i.e., check info == nil) and return info.UpstreamModelName directly without
gating on ChannelMeta so BuildRequestURL can correctly detect an upstream model
name.

Comment on lines +510 to +568
hasSeconds := false
hasDuration := false
durationValue := ""
hasSize := false
sizeValue := ""
hasAspectRatio := false
aspectRatioValue := ""
for key, values := range formData.Value {
if key == "model" {
continue
}
if key == "seconds" && len(values) > 0 && strings.TrimSpace(values[0]) != "" {
hasSeconds = true
}
if key == "duration" && len(values) > 0 && strings.TrimSpace(values[0]) != "" {
hasDuration = true
}
if key == "duration" && len(values) > 0 && durationValue == "" {
durationValue = strings.TrimSpace(values[0])
}
if key == "size" && len(values) > 0 && strings.TrimSpace(values[0]) != "" {
hasSize = true
if sizeValue == "" {
sizeValue = strings.TrimSpace(values[0])
}
}
if key == "aspect_ratio" && len(values) > 0 && strings.TrimSpace(values[0]) != "" {
hasAspectRatio = true
}
if key == "aspect_ratio" && len(values) > 0 && aspectRatioValue == "" {
aspectRatioValue = strings.TrimSpace(values[0])
}
if isSoraVideoModel(info.UpstreamModelName) && (key == "seconds" || key == "size") {
continue
}
for _, v := range values {
writer.WriteField(key, v)
}
}
if info.UpstreamModelName == "grok-imagine-1.0-video" && !hasSeconds && durationValue != "" {
writer.WriteField("seconds", durationValue)
}
if isSoraVideoModel(info.UpstreamModelName) {
if !hasDuration {
if durationValue == "" {
durationValue = "4"
}
writer.WriteField("duration", durationValue)
}
if !hasAspectRatio {
if aspectRatioValue == "" && hasSize {
aspectRatioValue = soraAspectRatioFromSize(sizeValue)
}
if aspectRatioValue == "" {
aspectRatioValue = "9:16"
}
writer.WriteField("aspect_ratio", aspectRatioValue)
}
writer.WriteField("async", "true")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve multipart seconds when mapping Sora duration.

The JSON path maps seconds to duration, but the multipart path only records hasSeconds; for Sora it then drops the seconds field and defaults missing duration to 4, ignoring a request like seconds=8.

Proposed fix
 		hasSeconds := false
+		secondsValue := ""
 		hasDuration := false
 		durationValue := ""
@@
 			if key == "seconds" && len(values) > 0 && strings.TrimSpace(values[0]) != "" {
 				hasSeconds = true
+				if secondsValue == "" {
+					secondsValue = strings.TrimSpace(values[0])
+				}
 			}
@@
 		if isSoraVideoModel(info.UpstreamModelName) {
 			if !hasDuration {
 				if durationValue == "" {
-					durationValue = "4"
+					durationValue = secondsValue
+				}
+				if durationValue == "" {
+					durationValue = "4"
 				}
 				writer.WriteField("duration", durationValue)
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/channel/task/sora/adaptor.go` around lines 510 - 568, The multipart
path currently skips writing the "seconds" field for Sora models (the continue
in the formData.Value loop that checks isSoraVideoModel and key == "seconds"),
so requests like seconds=8 get dropped when duration is synthesized; change the
loop logic to stop skipping "seconds" (i.e., remove "key == \"seconds\"" from
the continue condition) so original seconds values are written via
writer.WriteField, and keep the existing duration fallback logic (durationValue
defaulting to "4" and writing "duration") so both seconds and duration are
preserved for Sora models; refer to the formData.Value loop, the
hasSeconds/durationValue variables, isSoraVideoModel(info.UpstreamModelName),
and writer.WriteField usages to locate the change.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
relay/relay_task.go (2)

315-335: ⚠️ Potential issue | 🟡 Minor

Avoid returning stale ratio metadata.

X-New-Api-Other-Ratios is written before AdjustBillingOnSubmit, but the final ratios can change afterward. If this header is user-visible billing metadata, move header emission until after adjustment or make the header explicitly represent the estimate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/relay_task.go` around lines 315 - 335, The header
X-New-Api-Other-Ratios is set from otherRatios before calling
adaptor.AdjustBillingOnSubmit, which can change info.PriceData.OtherRatios and
lead to stale user-visible billing metadata; move the
c.Header("X-New-Api-Other-Ratios", ...) emission to after the
AdjustBillingOnSubmit / calcTaskQuotaWithRatios block (or explicitly label it as
an estimate) so the header reflects final ratios—update the sequence around
otherRatios, adaptor.AdjustBillingOnSubmit, calcTaskQuotaWithRatios, and
info.PriceData.OtherRatios accordingly.

303-326: ⚠️ Potential issue | 🟠 Major

Clean up the pre-created task on submit failures.

Because the pending task is inserted before the upstream call, these early returns can leave a task permanently submitted even though the submit failed. Also close the non-2xx response body after reading it.

Suggested direction
 	resp, err := adaptor.DoRequest(c, info, requestBody)
 	if err != nil {
+		// Mark the pre-created local task as failed, or defer creation until submit succeeds.
 		return nil, service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError)
 	}
 	if resp != nil && !isSuccessfulTaskSubmitStatus(resp.StatusCode) {
+		defer resp.Body.Close()
 		responseBody, _ := io.ReadAll(resp.Body)
+		// Mark the pre-created local task as failed, or defer creation until submit succeeds.
 		return nil, service.TaskErrorWrapper(fmt.Errorf("%s", string(responseBody)), "fail_to_fetch_task", resp.StatusCode)
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/relay_task.go` around lines 303 - 326, The pending task inserted by
upsertPendingRelayTaskRecord must be removed when upstream submission fails: add
a cleanup call (e.g. deletePendingRelayTaskRecord(c, info, platform) or
updatePendingRelayTaskToFailed(...)) whenever adaptor.DoRequest returns an
error, when resp is non-nil but !isSuccessfulTaskSubmitStatus(resp.StatusCode),
and when adaptor.DoResponse returns taskErr; also ensure you close resp.Body
after reading it (call resp.Body.Close() after io.ReadAll and in all error paths
where resp is non-nil) to avoid leaking connections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@controller/async_video.go`:
- Around line 137-145: Multipart form handling in async_video.go never copies
the request_id into the request, so task.PrivateData.ClientRequestId is lost;
update the multipart parsing block (where req.Prompt, req.Model, req.Image,
req.ImageURL, req.Size, req.Seconds, req.Duration are set using
firstAsyncVideoFormValue(form.Value, ...)) to also read and set req.RequestId =
strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "request_id")) (or
similar) so that req.RequestId is populated for multipart submissions and
preserved into task.PrivateData.ClientRequestId downstream.
- Around line 52-70: The code double-copies the upload by calling
storage.Bytes() then copying it again with append when building asyncVideoJob;
to fix, avoid the second copy by either (A) assigning the already-read slice
directly (set asyncVideoJob.Body = bodyBytes instead of append([]byte(nil),
bodyBytes...)) or (preferred) change asyncVideoJob to carry a handle (e.g.,
BodyStorage or an io.ReadSeeker/io.ReadCloser backed by temp file) and pass the
storage handle from where readAsyncVideoTaskRequest and initAsyncVideoTask are
called so large multipart uploads are kept on disk rather than duplicated in
memory.
- Around line 257-268: The updateAsyncVideoTaskFailure function currently writes
raw responseBody and failReason into task.Data and task.FailReason; instead
sanitize and JSON-wrap them: call redactVideoResponseBody(responseBody) to get a
redacted []byte (removing bytesBase64Encoded and truncating data: URLs),
sanitize failReason to strip or replace any data: URLs/HTML (reuse
redactVideoResponseBody or a small helper), then construct a JSON object like
{"error": "<sanitized-failReason>", "response":
<redacted-response-as-string-or-object>} and marshal it using the project's JSON
wrapper (e.g., common.JSONMarshal or equivalent) and assign that JSON to
task.Data; keep task.FailReason set to a short sanitized string (no data: URLs),
and preserve other fields as before so DB JSON field updates succeed across
SQLite/MySQL/Postgres.

In `@relay/relay_task.go`:
- Around line 85-109: The submit flow fails to persist resolved header overrides
so async follow-ups lose client_header passthrough values; update the submit
logic where task is populated (around task.Action, task.PrivateData, and
task.PrivateData.BillingContext) to call the same resolution used elsewhere
(e.g., processHeaderOverride or the function that returns the resolved header
map) and assign its result to task.PrivateData.ResolvedHeaderOverride so the
resolved headers are stored on the task at creation and available for async
follow-ups.

---

Outside diff comments:
In `@relay/relay_task.go`:
- Around line 315-335: The header X-New-Api-Other-Ratios is set from otherRatios
before calling adaptor.AdjustBillingOnSubmit, which can change
info.PriceData.OtherRatios and lead to stale user-visible billing metadata; move
the c.Header("X-New-Api-Other-Ratios", ...) emission to after the
AdjustBillingOnSubmit / calcTaskQuotaWithRatios block (or explicitly label it as
an estimate) so the header reflects final ratios—update the sequence around
otherRatios, adaptor.AdjustBillingOnSubmit, calcTaskQuotaWithRatios, and
info.PriceData.OtherRatios accordingly.
- Around line 303-326: The pending task inserted by upsertPendingRelayTaskRecord
must be removed when upstream submission fails: add a cleanup call (e.g.
deletePendingRelayTaskRecord(c, info, platform) or
updatePendingRelayTaskToFailed(...)) whenever adaptor.DoRequest returns an
error, when resp is non-nil but !isSuccessfulTaskSubmitStatus(resp.StatusCode),
and when adaptor.DoResponse returns taskErr; also ensure you close resp.Body
after reading it (call resp.Body.Close() after io.ReadAll and in all error paths
where resp is non-nil) to avoid leaking connections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4b76154b-379f-48ec-a9a2-052ca911bc39

📥 Commits

Reviewing files that changed from the base of the PR and between 0fc1489 and 20e46b5.

📒 Files selected for processing (4)
  • controller/async_video.go
  • controller/async_video_test.go
  • relay/relay_task.go
  • web/src/pages/CreativeCenter/index.jsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • controller/async_video_test.go

Comment thread controller/async_video.go
Comment on lines +52 to +70
bodyBytes, err := storage.Bytes()
if err != nil {
respondAsyncVideoOpenAIError(c, http.StatusBadRequest, err.Error(), types.ErrorCodeReadRequestBodyFailed)
return
}

req := readAsyncVideoTaskRequest(c, bodyBytes)
task := initAsyncVideoTask(c, req)
if err := task.Insert(); err != nil {
respondAsyncVideoOpenAIError(c, http.StatusInternalServerError, err.Error(), types.ErrorCodeQueryDataError)
return
}

job := asyncVideoJob{
TaskID: task.TaskID,
Path: asyncVideoGeneration,
Method: c.Request.Method,
Header: c.Request.Header.Clone(),
Body: append([]byte(nil), bodyBytes...),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid duplicating large media bodies in memory.

storage.Bytes() materializes the full upload, then Line 70 copies it again into the async job. For multipart media, concurrent requests can retain 2x body size until relay completion. Prefer passing a BodyStorage/temp-backed handle into the worker; at minimum avoid the second copy.

Minimal improvement to remove the extra copy
 	job := asyncVideoJob{
 		TaskID: task.TaskID,
 		Path:   asyncVideoGeneration,
 		Method: c.Request.Method,
 		Header: c.Request.Header.Clone(),
-		Body:   append([]byte(nil), bodyBytes...),
+		Body:   bodyBytes,
 		Keys:   cloneAsyncImageContextKeys(c),
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/async_video.go` around lines 52 - 70, The code double-copies the
upload by calling storage.Bytes() then copying it again with append when
building asyncVideoJob; to fix, avoid the second copy by either (A) assigning
the already-read slice directly (set asyncVideoJob.Body = bodyBytes instead of
append([]byte(nil), bodyBytes...)) or (preferred) change asyncVideoJob to carry
a handle (e.g., BodyStorage or an io.ReadSeeker/io.ReadCloser backed by temp
file) and pass the storage handle from where readAsyncVideoTaskRequest and
initAsyncVideoTask are called so large multipart uploads are kept on disk rather
than duplicated in memory.

Comment thread controller/async_video.go
Comment on lines +137 to +145
req.Prompt = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "prompt"))
req.Model = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "model"))
req.Image = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "image"))
req.ImageURL = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "image_url"))
req.Size = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "size"))
req.Seconds = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "seconds"))
if duration, err := strconv.Atoi(strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "duration"))); err == nil {
req.Duration = duration
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve request_id for multipart submissions.

JSON requests can populate req.RequestId, but multipart requests never copy the request_id form field, so task.PrivateData.ClientRequestId is lost for those clients.

Proposed fix
 			req.ImageURL = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "image_url"))
 			req.Size = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "size"))
 			req.Seconds = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "seconds"))
+			req.RequestId = strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "request_id"))
 			if duration, err := strconv.Atoi(strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "duration"))); err == nil {
 				req.Duration = duration
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/async_video.go` around lines 137 - 145, Multipart form handling in
async_video.go never copies the request_id into the request, so
task.PrivateData.ClientRequestId is lost; update the multipart parsing block
(where req.Prompt, req.Model, req.Image, req.ImageURL, req.Size, req.Seconds,
req.Duration are set using firstAsyncVideoFormValue(form.Value, ...)) to also
read and set req.RequestId =
strings.TrimSpace(firstAsyncVideoFormValue(form.Value, "request_id")) (or
similar) so that req.RequestId is populated for multipart submissions and
preserved into task.PrivateData.ClientRequestId downstream.

Comment thread controller/async_video.go
Comment on lines +257 to +268
func updateAsyncVideoTaskFailure(task *model.Task, responseBody []byte, failReason string) {
if task == nil {
return
}
task.Status = model.TaskStatusFailure
task.Progress = taskcommon.ProgressComplete
task.FinishTime = time.Now().Unix()
task.FailReason = strings.TrimSpace(failReason)
task.PrivateData.ResultURL = ""
if len(responseBody) > 0 {
task.Data = append([]byte(nil), responseBody...)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize and JSON-wrap failure payloads before persisting.

task.Data is a JSON field, but this stores arbitrary upstream response bytes; HTML/plain-text failures can make task.Update() fail on JSON-enforcing databases. It also bypasses the existing video response redaction path, so base64/data URL payloads can be persisted in Data or FailReason.

Proposed direction
 	task.Status = model.TaskStatusFailure
 	task.Progress = taskcommon.ProgressComplete
 	task.FinishTime = time.Now().Unix()
 	task.FailReason = strings.TrimSpace(failReason)
+	if strings.HasPrefix(strings.ToLower(task.FailReason), "data:") {
+		task.FailReason = "async video request failed"
+	}
 	task.PrivateData.ResultURL = ""
 	if len(responseBody) > 0 {
-		task.Data = append([]byte(nil), responseBody...)
+		sanitizedBody := redactVideoResponseBody(responseBody)
+		var decoded any
+		if err := common.Unmarshal(sanitizedBody, &decoded); err != nil {
+			if wrappedBody, marshalErr := common.Marshal(gin.H{
+				"error": strings.TrimSpace(string(sanitizedBody)),
+			}); marshalErr == nil {
+				sanitizedBody = wrappedBody
+			} else {
+				sanitizedBody = nil
+			}
+		}
+		if len(sanitizedBody) > 0 {
+			task.Data = append([]byte(nil), sanitizedBody...)
+		}
 	}

Based on learnings, redactVideoResponseBody removes bytesBase64Encoded fields/truncates base64, and data: URLs should not be stored in task.FailReason. As per coding guidelines, database code must be compatible with SQLite, MySQL, and PostgreSQL, and Go JSON operations must use common wrappers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@controller/async_video.go` around lines 257 - 268, The
updateAsyncVideoTaskFailure function currently writes raw responseBody and
failReason into task.Data and task.FailReason; instead sanitize and JSON-wrap
them: call redactVideoResponseBody(responseBody) to get a redacted []byte
(removing bytesBase64Encoded and truncating data: URLs), sanitize failReason to
strip or replace any data: URLs/HTML (reuse redactVideoResponseBody or a small
helper), then construct a JSON object like {"error": "<sanitized-failReason>",
"response": <redacted-response-as-string-or-object>} and marshal it using the
project's JSON wrapper (e.g., common.JSONMarshal or equivalent) and assign that
JSON to task.Data; keep task.FailReason set to a short sanitized string (no
data: URLs), and preserve other fields as before so DB JSON field updates
succeed across SQLite/MySQL/Postgres.

Comment thread relay/relay_task.go
Comment on lines +85 to +109
task.Action = info.Action
task.Status = model.TaskStatusSubmitted
task.Progress = taskcommon.ProgressSubmitted
task.PrivateData.RequestId = info.RequestId
task.PrivateData.BillingSource = info.BillingSource
task.PrivateData.SubscriptionId = info.SubscriptionId
task.PrivateData.TokenId = info.TokenId
task.PrivateData.UpstreamRequestPath = strings.TrimSpace(info.RequestURLPath)
if prompt := extractTaskPromptFromContext(c); prompt != "" {
task.Properties.Input = prompt
}
if clientRequestID := extractTaskClientRequestIDFromContext(c); clientRequestID != "" {
task.PrivateData.ClientRequestId = clientRequestID
}
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: info.PriceData.ModelPrice,
GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: info.PriceData.ModelRatio,
OtherRatios: info.PriceData.OtherRatios,
OriginModelName: info.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName),
GroupPriceOverride: info.PriceData.GroupPriceOverride,
GroupPriceOverrideGroup: info.PriceData.GroupPriceOverrideGroup,
UsingGroup: info.UsingGroup,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist resolved header overrides with the task.

ResolvedHeaderOverride is not copied into task.PrivateData, so async follow-up requests can lose {client_header:*} passthrough values after the submit request context is gone.

Suggested fix
 	task.PrivateData.RequestId = info.RequestId
+	task.PrivateData.ResolvedHeaderOverride = info.ResolvedHeaderOverride
 	task.PrivateData.BillingSource = info.BillingSource

Based on learnings: In the Sora2/OpenAI async video task flow, the resolved header map from processHeaderOverride must be persisted into task.PrivateData.ResolvedHeaderOverride at submit time.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
task.Action = info.Action
task.Status = model.TaskStatusSubmitted
task.Progress = taskcommon.ProgressSubmitted
task.PrivateData.RequestId = info.RequestId
task.PrivateData.BillingSource = info.BillingSource
task.PrivateData.SubscriptionId = info.SubscriptionId
task.PrivateData.TokenId = info.TokenId
task.PrivateData.UpstreamRequestPath = strings.TrimSpace(info.RequestURLPath)
if prompt := extractTaskPromptFromContext(c); prompt != "" {
task.Properties.Input = prompt
}
if clientRequestID := extractTaskClientRequestIDFromContext(c); clientRequestID != "" {
task.PrivateData.ClientRequestId = clientRequestID
}
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: info.PriceData.ModelPrice,
GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: info.PriceData.ModelRatio,
OtherRatios: info.PriceData.OtherRatios,
OriginModelName: info.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName),
GroupPriceOverride: info.PriceData.GroupPriceOverride,
GroupPriceOverrideGroup: info.PriceData.GroupPriceOverrideGroup,
UsingGroup: info.UsingGroup,
}
task.Action = info.Action
task.Status = model.TaskStatusSubmitted
task.Progress = taskcommon.ProgressSubmitted
task.PrivateData.RequestId = info.RequestId
task.PrivateData.ResolvedHeaderOverride = info.ResolvedHeaderOverride
task.PrivateData.BillingSource = info.BillingSource
task.PrivateData.SubscriptionId = info.SubscriptionId
task.PrivateData.TokenId = info.TokenId
task.PrivateData.UpstreamRequestPath = strings.TrimSpace(info.RequestURLPath)
if prompt := extractTaskPromptFromContext(c); prompt != "" {
task.Properties.Input = prompt
}
if clientRequestID := extractTaskClientRequestIDFromContext(c); clientRequestID != "" {
task.PrivateData.ClientRequestId = clientRequestID
}
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: info.PriceData.ModelPrice,
GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: info.PriceData.ModelRatio,
OtherRatios: info.PriceData.OtherRatios,
OriginModelName: info.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName),
GroupPriceOverride: info.PriceData.GroupPriceOverride,
GroupPriceOverrideGroup: info.PriceData.GroupPriceOverrideGroup,
UsingGroup: info.UsingGroup,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@relay/relay_task.go` around lines 85 - 109, The submit flow fails to persist
resolved header overrides so async follow-ups lose client_header passthrough
values; update the submit logic where task is populated (around task.Action,
task.PrivateData, and task.PrivateData.BillingContext) to call the same
resolution used elsewhere (e.g., processHeaderOverride or the function that
returns the resolved header map) and assign its result to
task.PrivateData.ResolvedHeaderOverride so the resolved headers are stored on
the task at creation and available for async follow-ups.

@seefs001 seefs001 closed this Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants