From 02ba5d3cdd0f24ed3f8a2d04e3a6f4e13ab33251 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 15 Jun 2026 00:17:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A8=A1=E5=9E=8B=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E8=B4=A6=E5=8D=95=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/internal/httpapi/handlers.go | 25 ++++++- .../httpapi/request_preparation_test.go | 12 ++++ .../internal/runner/param_processor_media.go | 14 +++- apps/api/internal/store/candidates_test.go | 12 ++-- apps/api/internal/store/wallet.go | 65 ++++++++++--------- apps/web/src/pages/WorkspacePage.tsx | 65 +++++++++++++++++-- 6 files changed, 146 insertions(+), 47 deletions(-) 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() : '';