feat(task): add model redirection, per-call billing, and multipart retry fix for async tasks

1. Async task model redirection (aligned with sync tasks):
   - Integrate ModelMappedHelper in RelayTaskSubmit after model name
     determination, populating OriginModelName / UpstreamModelName on RelayInfo.
   - All task adaptors now send UpstreamModelName to upstream providers:
     - Gemini & Vertex: BuildRequestURL uses UpstreamModelName.
     - Doubao & Ali: BuildRequestBody conditionally overwrites body.Model.
     - Vidu, Kling, Hailuo, Jimeng: convertToRequestPayload accepts RelayInfo
       and unconditionally uses info.UpstreamModelName.
     - Sora: BuildRequestBody parses JSON and multipart bodies to replace
       the "model" field with UpstreamModelName.
   - Frontend log visibility: LogTaskConsumption and taskBillingOther now
     emit is_model_mapped / upstream_model_name in the "other" JSON field.
   - Billing safety: RecalculateTaskQuotaByTokens reads model name from
     BillingContext.OriginModelName (via taskModelName) instead of
     task.Data["model"], preventing billing leaks from upstream model names.

2. Per-call billing (TaskPricePatches lifecycle):
   - Rename TaskBillingContext.ModelName → OriginModelName; add PerCallBilling
     bool field, populated from TaskPricePatches at submission time.
   - settleTaskBillingOnComplete short-circuits when PerCallBilling is true,
     skipping both adaptor adjustments and token-based recalculation.
   - Remove ModelName from TaskSubmitResult; use relayInfo.OriginModelName
     consistently in controller/relay.go for billing context and logging.

3. Multipart retry boundary mismatch fix:
   - Root cause: after Sora (or OpenAI audio) rebuilds a multipart body with a
     new boundary and overwrites c.Request.Header["Content-Type"], subsequent
     calls to ParseMultipartFormReusable on retry would parse the cached
     original body with the wrong boundary, causing "NextPart: EOF".
   - Fix: ParseMultipartFormReusable now caches the original Content-Type in
     gin context key "_original_multipart_ct" on first call and reuses it for
     all subsequent parses, making multipart parsing retry-safe globally.
   - Sora adaptor reverted to the standard pattern (direct header set/get),
     which is now safe thanks to the root fix.

4. Tests:
   - task_billing_test.go: update makeTask to use OriginModelName; add
     PerCallBilling settlement tests (skip adaptor adjust, skip token recalc);
     add non-per-call adaptor adjustment test with refund verification.
This commit is contained in:
CaIon
2026-02-22 15:32:33 +08:00
parent 9976b311ef
commit ec5c6b28ea
19 changed files with 277 additions and 78 deletions

View File

@@ -84,8 +84,8 @@ function renderDuration(submit_time, finishTime) {
// 返回带有样式的颜色标签
return (
<Tag color={color} shape='circle' prefixIcon={<Clock size={14} />}>
{durationSec}
<Tag color={color} shape='circle'>
{durationSec} s
</Tag>
);
}
@@ -149,7 +149,7 @@ const renderPlatform = (platform, t) => {
);
if (option) {
return (
<Tag color={option.color} shape='circle' prefixIcon={<Video size={14} />}>
<Tag color={option.color} shape='circle'>
{option.label}
</Tag>
);
@@ -157,13 +157,13 @@ const renderPlatform = (platform, t) => {
switch (platform) {
case 'suno':
return (
<Tag color='green' shape='circle' prefixIcon={<Music size={14} />}>
<Tag color='green' shape='circle'>
Suno
</Tag>
);
default:
return (
<Tag color='white' shape='circle' prefixIcon={<HelpCircle size={14} />}>
<Tag color='white' shape='circle'>
{t('未知')}
</Tag>
);
@@ -240,7 +240,6 @@ export const getTaskLogsColumns = ({
openContentModal,
isAdminUser,
openVideoModal,
showUserInfoFunc,
}) => {
return [
{
@@ -278,7 +277,6 @@ export const getTaskLogsColumns = ({
color={colors[parseInt(text) % colors.length]}
size='large'
shape='circle'
prefixIcon={<Hash size={14} />}
onClick={() => {
copyText(text);
}}
@@ -294,7 +292,7 @@ export const getTaskLogsColumns = ({
{
key: COLUMN_KEYS.USERNAME,
title: t('用户'),
dataIndex: 'user_id',
dataIndex: 'username',
render: (userId, record, index) => {
if (!isAdminUser) {
return <></>;
@@ -302,22 +300,14 @@ export const getTaskLogsColumns = ({
const displayText = String(record.username || userId || '?');
return (
<Space>
<Tooltip content={displayText}>
<Avatar
size='extra-small'
color={stringToColor(displayText)}
style={{ cursor: 'pointer' }}
onClick={() => showUserInfoFunc && showUserInfoFunc(userId)}
>
{displayText.slice(0, 1)}
</Avatar>
</Tooltip>
<Typography.Text
ellipsis={{ showTooltip: true }}
style={{ cursor: 'pointer', color: 'var(--semi-color-primary)' }}
onClick={() => showUserInfoFunc && showUserInfoFunc(userId)}
<Avatar
size='extra-small'
color={stringToColor(displayText)}
>
{userId}
{displayText.slice(0, 1)}
</Avatar>
<Typography.Text>
{displayText}
</Typography.Text>
</Space>
);

View File

@@ -25,7 +25,6 @@ import TaskLogsActions from './TaskLogsActions';
import TaskLogsFilters from './TaskLogsFilters';
import ColumnSelectorModal from './modals/ColumnSelectorModal';
import ContentModal from './modals/ContentModal';
import UserInfoModal from '../usage-logs/modals/UserInfoModal';
import { useTaskLogsData } from '../../../hooks/task-logs/useTaskLogsData';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
import { createCardProPagination } from '../../../helpers/utils';
@@ -46,7 +45,6 @@ const TaskLogsPage = () => {
modalContent={taskLogsData.videoUrl}
isVideo={true}
/>
<UserInfoModal {...taskLogsData} />
<Layout>
<CardPro