diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go
index c59d87c..4f455d5 100644
--- a/apps/api/internal/httpapi/handlers.go
+++ b/apps/api/internal/httpapi/handlers.go
@@ -867,7 +867,7 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
- model, _ := body["model"].(string)
+ model := requestModelName(body)
kind, _ := body["kind"].(string)
if kind == "" {
kind = "chat.completions"
@@ -997,7 +997,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeError(w, status, err.Error(), clients.ErrorCode(err))
return
}
- model, _ := body["model"].(string)
+ model := requestModelName(body)
if model == "" {
writeError(w, http.StatusBadRequest, "model is required")
return
@@ -1254,6 +1254,27 @@ func apiKeyScopeAllowed(user *auth.User, kind string) bool {
return false
}
+func requestModelName(body map[string]any) string {
+ if body == nil {
+ return ""
+ }
+ return modelNameFromValue(body["model"])
+}
+
+func modelNameFromValue(value any) string {
+ switch typed := value.(type) {
+ case string:
+ return strings.TrimSpace(typed)
+ case map[string]any:
+ for _, key := range []string{"modelId", "model_id", "modelName", "model_name", "id", "name"} {
+ if model := modelNameFromValue(typed[key]); model != "" {
+ return model
+ }
+ }
+ }
+ return ""
+}
+
func scopeForTaskKind(kind string) string {
switch kind {
case "chat.completions", "responses":
diff --git a/apps/api/internal/httpapi/request_preparation_test.go b/apps/api/internal/httpapi/request_preparation_test.go
index 0ef8c3b..ff15157 100644
--- a/apps/api/internal/httpapi/request_preparation_test.go
+++ b/apps/api/internal/httpapi/request_preparation_test.go
@@ -37,6 +37,18 @@ func TestRequestAssetFromValueDetectsDataURLAndRawBase64(t *testing.T) {
}
}
+func TestRequestModelNameSupportsObjectModelReference(t *testing.T) {
+ got := requestModelName(map[string]any{
+ "model": map[string]any{
+ "providerId": "easyai",
+ "modelId": "doubao-seedream-5-0-lite-260128",
+ },
+ })
+ if got != "doubao-seedream-5-0-lite-260128" {
+ t.Fatalf("expected modelId from object model reference, got %q", got)
+ }
+}
+
func TestRequestAssetFromValueDetectsGeminiInlineData(t *testing.T) {
payload := base64.StdEncoding.EncodeToString([]byte("inline gemini image"))
decoded, ok, err := requestAssetFromValue(
diff --git a/apps/api/internal/runner/param_processor_media.go b/apps/api/internal/runner/param_processor_media.go
index 7083f9b..7fd3af6 100644
--- a/apps/api/internal/runner/param_processor_media.go
+++ b/apps/api/internal/runner/param_processor_media.go
@@ -126,7 +126,7 @@ func (aspectRatioProcessor) Process(params map[string]any, modelType string, con
return true
}
- processed, ok := validateAndAdjustAspectRatio(aspectRatio, capability, allowed)
+ processed, ok := validateExplicitAspectRatio(aspectRatio, capability, allowed, modelType)
if !ok {
before := params["aspect_ratio"]
delete(params, "aspect_ratio")
@@ -172,6 +172,18 @@ func (aspectRatioProcessor) Process(params map[string]any, modelType string, con
return true
}
+func validateExplicitAspectRatio(aspectRatio string, capability map[string]any, allowed []string, modelType string) (string, bool) {
+ if isVideoModelType(modelType) && len(allowed) > 0 && !containsString(allowed, aspectRatio) {
+ if aspectRatio == "adaptive" || aspectRatio == "keep_ratio" {
+ return "", false
+ }
+ if _, ok := numberPair(capability["aspect_ratio_range"]); !ok {
+ return allowed[0], true
+ }
+ }
+ return validateAndAdjustAspectRatio(aspectRatio, capability, allowed)
+}
+
type imageSizeProcessor struct{}
func (imageSizeProcessor) Name() string { return "ImageSizeProcessor" }
diff --git a/apps/api/internal/store/candidates_test.go b/apps/api/internal/store/candidates_test.go
index 6c9e54a..b5ce0f6 100644
--- a/apps/api/internal/store/candidates_test.go
+++ b/apps/api/internal/store/candidates_test.go
@@ -9,7 +9,7 @@ func TestNormalizeModelMatchKeyRemovesWhitespace(t *testing.T) {
}
}
-func TestTaskBillingModelIdentityPrefersSystemAlias(t *testing.T) {
+func TestTaskBillingModelIdentityKeepsRequestedModelPrimary(t *testing.T) {
identity := taskBillingModelIdentity(GatewayTask{
Model: "doubao-5.0 图像编辑",
RequestedModel: "doubao-5.0 图像编辑",
@@ -21,11 +21,11 @@ func TestTaskBillingModelIdentityPrefersSystemAlias(t *testing.T) {
},
})
- if identity.Model != "doubao-5.0图像编辑" || identity.ResolvedModel != "doubao-5.0图像编辑" {
- t.Fatalf("expected persisted model to use system alias, got %+v", identity)
+ if identity.Model != "doubao-5.0 图像编辑" || identity.RequestedModel != "doubao-5.0 图像编辑" {
+ t.Fatalf("expected persisted model to keep requested model, got %+v", identity)
}
- if identity.RequestedModel != "doubao-5.0 图像编辑" {
- t.Fatalf("expected requested model to preserve original request, got %+v", identity)
+ if identity.ResolvedModel != "doubao-5.0图像编辑" {
+ t.Fatalf("expected resolved model to keep system alias, got %+v", identity)
}
if identity.ModelName != "doubao-image-real" || identity.ProviderModelName != "doubao-provider-image" {
t.Fatalf("expected model name/provider model to stay available, got %+v", identity)
@@ -43,7 +43,7 @@ func TestTaskBillingModelIdentityFallsBackToBillingLines(t *testing.T) {
},
})
- if identity.Model != "System Model Alias" || identity.ModelName != "system-model-name" {
+ if identity.Model != "front end alias" || identity.ModelName != "system-model-name" || identity.ResolvedModel != "System Model Alias" {
t.Fatalf("expected billing lines to provide system model identity, got %+v", identity)
}
}
diff --git a/apps/api/internal/store/wallet.go b/apps/api/internal/store/wallet.go
index 2a58d63..b699412 100644
--- a/apps/api/internal/store/wallet.go
+++ b/apps/api/internal/store/wallet.go
@@ -506,35 +506,35 @@ SELECT t.id::text, t.account_id::text, a.currency, COALESCE(t.gateway_tenant_id:
t.metadata || jsonb_strip_nulls(jsonb_build_object(
'taskId', task.id::text,
'kind', task.kind,
- 'model', COALESCE(NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', ''), NULLIF(task.resolved_model, ''), NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', ''), task.model),
- 'requestedModel', task.requested_model,
- 'resolvedModel', COALESCE(NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', ''), NULLIF(task.resolved_model, ''), NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', '')),
- 'modelName', COALESCE(NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', ''), NULLIF(task.resolved_model, '')),
- 'modelAlias', COALESCE(NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', '')),
- 'modelType', task.model_type,
- 'taskStatus', task.status,
- 'runMode', task.run_mode,
- 'requestId', COALESCE(task.request_id, attempt.request_id),
- 'apiKeyId', task.api_key_id,
- 'apiKeyName', task.api_key_name,
- 'apiKeyPrefix', task.api_key_prefix,
- 'provider', COALESCE(platform.provider, task.metrics->>'provider'),
- 'platformId', COALESCE(platform.id::text, task.metrics->>'platformId'),
- 'platformName', COALESCE(platform.name, task.metrics->>'platformName'),
- 'platformKey', platform.platform_key,
- 'platformModelId', COALESCE(platform_model.id::text, task.metrics->>'platformModelId'),
- 'platformModelName', platform_model.model_name,
- 'platformModelAlias', platform_model.model_alias,
- 'providerModel', COALESCE(platform_model.provider_model_name, task.metrics->>'providerModel'),
- 'clientId', attempt.client_id,
- 'usage', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.usage, attempt.usage, '{}'::jsonb) END,
- 'billings', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billings, '[]'::jsonb) END,
- 'billingSummary', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billing_summary, '{}'::jsonb) END,
- 'finalChargeAmount', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.final_charge_amount, 0)::float8 END,
- 'responseStartedAt', COALESCE(task.response_started_at::text, attempt.response_started_at::text),
- 'responseFinishedAt', COALESCE(task.response_finished_at::text, attempt.response_finished_at::text),
- 'responseDurationMs', COALESCE(task.response_duration_ms, attempt.response_duration_ms)
- )), t.created_at
+ 'model', COALESCE(NULLIF(task.requested_model, ''), NULLIF(task.model, ''), NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', ''), NULLIF(task.resolved_model, ''), NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', '')),
+ 'requestedModel', COALESCE(NULLIF(task.requested_model, ''), NULLIF(task.model, '')),
+ 'resolvedModel', COALESCE(NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', ''), NULLIF(task.resolved_model, ''), NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', '')),
+ 'modelName', COALESCE(NULLIF(platform_model.model_name, ''), NULLIF(task.metrics->>'modelName', ''), NULLIF(task.resolved_model, '')),
+ 'modelAlias', COALESCE(NULLIF(platform_model.model_alias, ''), NULLIF(task.metrics->>'modelAlias', '')),
+ 'modelType', task.model_type,
+ 'taskStatus', task.status,
+ 'runMode', task.run_mode,
+ 'requestId', COALESCE(task.request_id, attempt.request_id),
+ 'apiKeyId', task.api_key_id,
+ 'apiKeyName', task.api_key_name,
+ 'apiKeyPrefix', task.api_key_prefix,
+ 'provider', COALESCE(platform.provider, task.metrics->>'provider'),
+ 'platformId', COALESCE(platform.id::text, task.metrics->>'platformId'),
+ 'platformName', COALESCE(platform.name, task.metrics->>'platformName'),
+ 'platformKey', platform.platform_key,
+ 'platformModelId', COALESCE(platform_model.id::text, task.metrics->>'platformModelId'),
+ 'platformModelName', platform_model.model_name,
+ 'platformModelAlias', platform_model.model_alias,
+ 'providerModel', COALESCE(platform_model.provider_model_name, task.metrics->>'providerModel'),
+ 'clientId', attempt.client_id,
+ 'usage', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.usage, attempt.usage, '{}'::jsonb) END,
+ 'billings', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billings, '[]'::jsonb) END,
+ 'billingSummary', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.billing_summary, '{}'::jsonb) END,
+ 'finalChargeAmount', CASE WHEN task.id IS NULL THEN NULL ELSE COALESCE(task.final_charge_amount, 0)::float8 END,
+ 'responseStartedAt', COALESCE(task.response_started_at::text, attempt.response_started_at::text),
+ 'responseFinishedAt', COALESCE(task.response_finished_at::text, attempt.response_finished_at::text),
+ 'responseDurationMs', COALESCE(task.response_duration_ms, attempt.response_duration_ms)
+ )), t.created_at
FROM gateway_wallet_transactions t
JOIN gateway_wallet_accounts a ON a.id = t.account_id
LEFT JOIN gateway_tasks task ON t.reference_type = 'gateway_task' AND t.reference_id = task.id::text
@@ -953,10 +953,11 @@ func taskBillingModelIdentity(task GatewayTask) billingModelIdentity {
taskBillingString(task.Metrics["providerModel"]),
firstBillingLineString(task.Billings, "providerModel"),
)
- systemModel := firstNonEmpty(modelAlias, modelName, task.ResolvedModel, task.Model)
+ requestedModel := firstNonEmpty(task.RequestedModel, task.Model)
+ systemModel := firstNonEmpty(modelAlias, modelName, task.ResolvedModel, requestedModel)
return billingModelIdentity{
- Model: systemModel,
- RequestedModel: firstNonEmpty(task.RequestedModel, task.Model),
+ Model: firstNonEmpty(requestedModel, systemModel),
+ RequestedModel: requestedModel,
ResolvedModel: systemModel,
ModelName: modelName,
ModelAlias: modelAlias,
diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx
index b7a6460..aa0e354 100644
--- a/apps/web/src/pages/WorkspacePage.tsx
+++ b/apps/web/src/pages/WorkspacePage.tsx
@@ -333,8 +333,8 @@ function WalletTransactionRecord(props: { transaction: GatewayWalletTransaction
const metadata = transaction.metadata;
const referenceId = transaction.referenceId || metadataString(transaction.metadata, 'taskId');
const requestId = metadataString(metadata, 'requestId');
- const model = metadataString(metadata, 'resolvedModel') || metadataString(metadata, 'platformModelAlias') || metadataString(metadata, 'providerModel') || metadataString(metadata, 'model');
- const requestedModel = metadataString(metadata, 'requestedModel');
+ const model = walletTransactionCalledModel(metadata);
+ const resolvedModel = walletTransactionResolvedModel(metadata);
const platform = metadataString(metadata, 'platformName') || metadataString(metadata, 'platformKey') || metadataString(metadata, 'provider') || metadataString(metadata, 'clientId');
const provider = metadataString(metadata, 'provider');
const taskType = metadataString(metadata, 'modelType') || metadataString(metadata, 'kind');
@@ -347,7 +347,7 @@ function WalletTransactionRecord(props: { transaction: GatewayWalletTransaction
{model || '-'}
- {requestedModel && requestedModel !== model && {requestedModel}}
+ {resolvedModel && resolvedModel !== model && {resolvedModel}}
@@ -926,7 +926,8 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId:
const usage = props.task.usage ?? {};
const tokenUsage = formatTokenUsage(usage);
const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-';
- const resolvedModel = props.task.resolvedModel || props.task.model;
+ const calledModel = taskCalledModelLabel(props.task);
+ const resolvedModel = taskResolvedModelLabel(props.task);
const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary';
return (
@@ -957,8 +958,8 @@ function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId:
{props.task.status}
- {resolvedModel}
- {props.task.requestedModel && props.task.requestedModel !== resolvedModel && {props.task.requestedModel}}
+ {calledModel || '-'}
+ {resolvedModel && resolvedModel !== calledModel && {resolvedModel}}
@@ -1463,6 +1464,58 @@ function firstText(...values: Array) {
return '';
}
+function walletTransactionCalledModel(metadata: Record | undefined) {
+ return firstText(
+ metadataModelReference(metadata, 'requestedModel'),
+ metadataModelReference(metadata, 'model'),
+ metadataModelReference(metadata, 'modelId'),
+ );
+}
+
+function walletTransactionResolvedModel(metadata: Record | undefined) {
+ return firstText(
+ metadataString(metadata, 'resolvedModel'),
+ metadataString(metadata, 'platformModelAlias'),
+ metadataString(metadata, 'providerModel'),
+ metadataString(metadata, 'modelName'),
+ );
+}
+
+function taskCalledModelLabel(task: GatewayTask) {
+ return firstText(
+ task.requestedModel,
+ modelReferenceLabel(task.request?.model),
+ task.model,
+ );
+}
+
+function taskResolvedModelLabel(task: GatewayTask) {
+ return firstText(
+ task.resolvedModel,
+ metadataString(task.metrics, 'resolvedModel'),
+ metadataString(task.metrics, 'providerModel'),
+ metadataString(task.metrics, 'modelName'),
+ );
+}
+
+function metadataModelReference(metadata: Record | undefined, key: string) {
+ return modelReferenceLabel(metadata?.[key]);
+}
+
+function modelReferenceLabel(value: unknown) {
+ if (typeof value === 'string' && value.trim()) return value.trim();
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
+ const record = value as Record;
+ return firstText(
+ objectString(record, 'modelId'),
+ objectString(record, 'model_id'),
+ objectString(record, 'modelName'),
+ objectString(record, 'model_name'),
+ objectString(record, 'id'),
+ objectString(record, 'name'),
+ );
+}
+
function metadataString(metadata: Record | undefined, key: string) {
const value = metadata?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : '';