fix(pricing): 图片规格积分 - 按分辨率匹配画布价格
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -10,6 +13,72 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestImageSpecificationQuoteAgreesWithTaskBilling(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "image-channel-model", ChannelID: "image-channel", ModelKey: "image-model", Capability: "image",
|
||||
Protocol: model.ChannelInterfaceOpenAIImage, Enabled: true, PriceConfigured: true,
|
||||
}
|
||||
for operationIndex, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for index, quality := range []string{"1k", "2k", "4k"} {
|
||||
selector := fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality)
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, ChannelModelID: channelModel.ID, SelectorKey: selector, SelectorJSON: selector,
|
||||
Resolution: "*", BillingMode: "fixed_request", UnitPriceMicrocredits: int64(index+1+operationIndex*3) * 1_000_000,
|
||||
Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channelModel.PriceTiers).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := CapabilitySpec{Version: 1, Capability: "image", Inputs: map[string]InputConstraint{"image": {Max: 9}}, Options: map[string]OptionConstraint{"quality": {Values: []any{"1k", "2k", "4k"}}}}
|
||||
cached := cachedLogicalModel{
|
||||
Model: model.LogicalModel{ID: "logical-image", PricePolicy: "channel", Capability: "image"},
|
||||
ProductSpec: spec, Defaults: map[string]any{"quality": "2k"},
|
||||
Routes: []cachedLogicalRoute{{Route: model.LogicalModelRoute{ID: "image-route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: channelModel}},
|
||||
}
|
||||
svc := &Service{repo: repository.New(db), routeCatalogTTL: time.Hour, routeCatalog: &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{"logical-image": cached}}}
|
||||
for _, tier := range channelModel.PriceTiers {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
imageCount := 0
|
||||
if selector["operation"] == "image_to_image" {
|
||||
imageCount = 1
|
||||
}
|
||||
input := map[string]any{"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{
|
||||
"channelId": channelModel.ChannelID, "model": channelModel.ModelKey, "quality": strings.ToUpper(selector["quality"]),
|
||||
}}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", intent)
|
||||
if err != nil {
|
||||
t.Fatalf("quote %s: %v", tier.ID, err)
|
||||
}
|
||||
order, err := svc.taskBillingOrder("user", &model.Task{ID: "task", Type: "canvas_image", Operation: "image"}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("billing %s: %v", tier.ID, err)
|
||||
}
|
||||
if quote.AmountMicrocredits != tier.UnitPriceMicrocredits || order.AmountMicrocredits != quote.AmountMicrocredits || order.PriceTierID != tier.ID {
|
||||
t.Fatalf("tier %s: quote=%#v order=%#v", tier.ID, quote, order)
|
||||
}
|
||||
}
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image"})
|
||||
if err != nil || quote.AmountMicrocredits != 2_000_000 {
|
||||
t.Fatalf("default resolution quote=%#v error=%v", quote, err)
|
||||
}
|
||||
if _, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); err == nil {
|
||||
t.Fatal("unconfigured resolution must not be quoted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -85,7 +85,14 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
|
||||
}
|
||||
|
||||
func normalizeModelRequestOption(name string, value any) any {
|
||||
if canonicalCapabilityOptionName(name) != "vquality" {
|
||||
canonicalName := canonicalCapabilityOptionName(name)
|
||||
if canonicalName == "quality" || canonicalName == "size" {
|
||||
if text, ok := value.(string); ok {
|
||||
return strings.ToLower(strings.TrimSpace(text))
|
||||
}
|
||||
return value
|
||||
}
|
||||
if canonicalName != "vquality" {
|
||||
return value
|
||||
}
|
||||
resolution, ok := value.(string)
|
||||
@@ -702,7 +709,18 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
selector["videoSeconds"] = strconv.Itoa(seconds)
|
||||
}
|
||||
case "image":
|
||||
if intent.Inputs["image"] > 0 {
|
||||
selector["operation"] = "image_to_image"
|
||||
} else {
|
||||
selector["operation"] = "text_to_image"
|
||||
}
|
||||
if quality := normalizeImagePriceQuality(fmt.Sprint(intent.Options["quality"]), fmt.Sprint(intent.Options["size"])); quality != "" {
|
||||
selector["quality"] = quality
|
||||
}
|
||||
for _, key := range []string{"quality", "size"} {
|
||||
if key == "quality" && selector["quality"] != "" {
|
||||
continue
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
|
||||
selector[key] = value
|
||||
}
|
||||
@@ -711,6 +729,33 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
return selector
|
||||
}
|
||||
|
||||
func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
|
||||
quality := strings.ToLower(strings.TrimSpace(rawQuality))
|
||||
if quality != "" && quality != "auto" && quality != "any" {
|
||||
return quality
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
|
||||
return ""
|
||||
}
|
||||
pixels := width * height
|
||||
switch {
|
||||
case pixels <= 2_000_000:
|
||||
return "1k"
|
||||
case pixels <= 4_300_000:
|
||||
return "2k"
|
||||
case pixels <= 8_294_400:
|
||||
return "4k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
if len(selector) == 0 {
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) {
|
||||
channelModel := model.ChannelModel{}
|
||||
for _, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1k", "2k", "4k"} {
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, SelectorJSON: fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality), Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{"", "image", "text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1K", "2K", "4K"} {
|
||||
for _, imageCount := range []int{0, 1, 3} {
|
||||
intent := ModelRequestIntentFromTaskInput(map[string]any{
|
||||
"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{"quality": quality},
|
||||
}, "canvas_image", operation)
|
||||
wantOperation := "text_to_image"
|
||||
if imageCount > 0 {
|
||||
wantOperation = "image_to_image"
|
||||
}
|
||||
tier := channelModelPriceTierForIntent(channelModel, intent)
|
||||
if tier == nil || tier.ID != wantOperation+"-"+skuSelectorForIntent(intent)["quality"] {
|
||||
t.Fatalf("operation=%q quality=%q imageCount=%d: tier=%#v", operation, quality, imageCount, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tier := channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); tier != nil {
|
||||
t.Fatalf("unconfigured resolution matched tier: %#v", tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "video",
|
||||
@@ -17,6 +49,36 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesImageSpecificationValues(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "image",
|
||||
"config": map[string]any{"quality": "1K", "size": "3:2"},
|
||||
}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
if got := intent.Options["quality"]; got != "1k" {
|
||||
t.Fatalf("quality = %#v, want 1k", got)
|
||||
}
|
||||
if got := intent.Options["size"]; got != "3:2" {
|
||||
t.Fatalf("size = %#v, want 3:2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
size string
|
||||
want string
|
||||
}{
|
||||
{size: "1024x1024", want: "1k"},
|
||||
{size: "2048x2048", want: "2k"},
|
||||
{size: "2880x2880", want: "4k"},
|
||||
} {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "auto", "size": test.size}})
|
||||
if selector["quality"] != test.want {
|
||||
t.Fatalf("size %s quality = %q, want %q", test.size, selector["quality"], test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
|
||||
if selector["imageCount"] != "5" || selector["vquality"] != "720p" {
|
||||
|
||||
@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 图片规格价格
|
||||
|
||||
- [ ] 仅配置 1K、2K、4K 三档价格(无统一兜底),画布逐一切换分辨率,显示与对应档位一致的积分价格。
|
||||
- [ ] 分别配置文生图、图生图规格价格;添加、移除参考图或角色参考后,报价与实际生成命中对应档位。
|
||||
- [ ] 新建图片节点时,报价使用节点模型默认规格,而不是全局旧参数;刷新后再次核对。
|
||||
- [ ] 配置统一价格时仍正常兜底;未配置且没有兜底的规格不能被计为其他档位价格。
|
||||
|
||||
## 编辑器(M2 命令状态机)
|
||||
|
||||
- [x] 命令协议黄金文件:`editor-commands` 8 个黄金 op(addClip/moveClip/trimClip/splitClip/removeClip/setClipProperty/addSubtitle/removeSubtitle)全量快照比对通过;未知 op、非法 payload 一律抛错(fail-closed)且输入不可变。
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -107,6 +108,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
}, mode),
|
||||
};
|
||||
const config = buildNodeConfig(globalConfig, node, mode, requirements);
|
||||
const resolvedRequirements: ModelRequirements = {
|
||||
...requirements,
|
||||
options: modelRequestOptions(config, mode),
|
||||
videoSeconds: mode === "video" ? config.videoSeconds : undefined,
|
||||
};
|
||||
const promptOptimizerProvider = useMemo(() => {
|
||||
if (!promptOptimizerEnabled || !promptOptimizerInstallation || !promptOptimizerPlugin.createPromptOptimizer) return null;
|
||||
return promptOptimizerPlugin.createPromptOptimizer(createPluginHostContext(promptOptimizerPlugin, promptOptimizerInstallation, globalConfig));
|
||||
@@ -121,9 +127,9 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
seconds: mode === "video" ? config.videoSeconds : 1,
|
||||
capability: mode,
|
||||
config,
|
||||
requirements,
|
||||
requirements: resolvedRequirements,
|
||||
});
|
||||
const quoteRequest = modelQuoteRequest(config, config.model, mode, requirements);
|
||||
const quoteRequest = modelQuoteRequest(config, config.model, mode, resolvedRequirements);
|
||||
const quoteRequestKey = JSON.stringify(quoteRequest || null);
|
||||
const [quotedCredits, setQuotedCredits] = useState<number | null>(null);
|
||||
const credits = quotedCredits ?? configuredCredits;
|
||||
@@ -345,7 +351,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
value={config.model}
|
||||
onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })}
|
||||
capability={mode}
|
||||
requirements={requirements}
|
||||
requirements={resolvedRequirements}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
showSelectedPrice={false}
|
||||
variant="creation"
|
||||
@@ -734,10 +740,11 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
videoWatermark: globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
},
|
||||
);
|
||||
const imageQuality = mode === "image" ? resolvePricedImageQuality({ ...globalConfig, size: defaults.size ?? globalConfig.size ?? defaultConfig.size }, model, defaults.quality || globalConfig.quality || defaultConfig.quality) : undefined;
|
||||
return {
|
||||
...globalConfig,
|
||||
model,
|
||||
quality: defaults.quality || globalConfig.quality || defaultConfig.quality,
|
||||
quality: imageQuality || defaults.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: defaults.size ?? globalConfig.size ?? defaultConfig.size,
|
||||
transparentBackground: defaults.transparentBackground || "false",
|
||||
videoSeconds: defaults.videoSeconds || normalizeVideoDuration(globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
@@ -752,6 +759,19 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePricedImageQuality(config: AiConfig, model: string, quality: string) {
|
||||
if (quality && quality !== "auto") return quality;
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const sizeTier = imageResolutionOption(buildImageResolutionOptions([config.size]), config.size)?.tier;
|
||||
if (sizeTier) return sizeTier;
|
||||
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
const tier = cost?.logicalPriceTiers?.find((item) => {
|
||||
const value = item.selector?.quality || "";
|
||||
return value && value !== "*";
|
||||
});
|
||||
return tier?.selector?.quality || quality;
|
||||
}
|
||||
|
||||
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
|
||||
if (mode === "video") return "描述要生成的视频内容";
|
||||
if (mode === "audio") return "描述要生成的音频内容";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
import type { ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
@@ -79,7 +80,7 @@ export function modelQuoteRequest(config: AiConfig, value: string, capability?:
|
||||
const input = requirements?.input;
|
||||
const intent: ModelRequestIntent = {
|
||||
capability,
|
||||
operation: capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
|
||||
operation: capability === "image" ? imagePriceOperation(requirements) : capability === "video" && input ? resolveVideoOperation(input, requirements?.videoOperation) : requirements?.videoOperation,
|
||||
inputs: {
|
||||
image: (input?.imageCount || 0) + (input?.characterCount || 0),
|
||||
video: input?.videoCount || 0,
|
||||
@@ -115,12 +116,27 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
|
||||
if (seconds > 0) requested.videoSeconds = String(seconds);
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (config.quality && config.quality !== "auto") requested.quality = config.quality.toLowerCase();
|
||||
if (config.size && config.size !== "auto") requested.size = config.size.toLowerCase();
|
||||
requested.operation = imagePriceOperation(requirements);
|
||||
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
|
||||
const imageSize = String(options.size ?? "").trim();
|
||||
if ((imageQuality === "" || imageQuality === "auto" || imageQuality === "any") && imageSize) {
|
||||
const resolution = imageResolutionOption(buildImageResolutionOptions([imageSize]), imageSize)?.tier;
|
||||
if (resolution) requested.quality = resolution;
|
||||
}
|
||||
for (const key of ["quality", "size"] as const) {
|
||||
const value = String(options[key] ?? "").trim().toLowerCase();
|
||||
if (value && value !== "auto" && value !== "any" && !requested[key]) requested[key] = value;
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
function imagePriceOperation(requirements?: ModelRequirements) {
|
||||
const input = requirements?.input;
|
||||
return (input?.imageCount || 0) + (input?.characterCount || 0) > 0 ? "image_to_image" : "text_to_image";
|
||||
}
|
||||
|
||||
function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
const selector = { ...(tier.selector || {}) };
|
||||
if (!Object.keys(selector).length) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import { modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection, requestCreditCost } from "../src/lib/model-pricing";
|
||||
import type { ModelRequirements } from "../src/lib/model-selection";
|
||||
import { createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelChannel, type AiConfig } from "../src/stores/use-config-store";
|
||||
import { resolvePricedImageQuality } from "../src/components/canvas/canvas-node-prompt-panel";
|
||||
|
||||
function systemConfig(input: { capability?: "image" | "video"; logicalModelId?: string; tiers: Array<{ selector: Record<string, string>; billingMode: "fixed_request" | "per_second" | "token"; unitPriceMicrocredits: number }> }) {
|
||||
const capability = input.capability || "video";
|
||||
@@ -58,6 +59,78 @@ const textVideoRequirements: ModelRequirements = {
|
||||
};
|
||||
|
||||
describe("model request pricing", () => {
|
||||
test("resolves automatic image quality to the first configured specification", () => {
|
||||
const config = systemConfig({
|
||||
capability: "image",
|
||||
tiers: [
|
||||
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
|
||||
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 4_000_000 },
|
||||
],
|
||||
});
|
||||
expect(resolvePricedImageQuality(config, config.model, "auto")).toBe("1k");
|
||||
expect(resolvePricedImageQuality(config, config.model, "2k")).toBe("2k");
|
||||
});
|
||||
|
||||
test("uses image dimensions to match resolution pricing when quality is automatic", () => {
|
||||
const config = systemConfig({
|
||||
capability: "image",
|
||||
tiers: [
|
||||
{ selector: { quality: "1k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
|
||||
{ selector: { quality: "4k" }, billingMode: "fixed_request", unitPriceMicrocredits: 5_000_000 },
|
||||
],
|
||||
});
|
||||
for (const [size, price] of [["1024x1024", 2], ["2048x2048", 3], ["2880x2880", 5]] as const) {
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config: { ...config, quality: "auto", size }, requirements: { capability: "image", options: { quality: "auto", size } }, count: 1 })).toBe(price);
|
||||
}
|
||||
});
|
||||
|
||||
test("matches each image resolution and generation operation for quotes and displayed costs", () => {
|
||||
const qualities = ["1k", "2k", "4k"];
|
||||
const config = systemConfig({
|
||||
capability: "image",
|
||||
logicalModelId: "logical-image",
|
||||
tiers: ["text_to_image", "image_to_image"].flatMap((operation, operationIndex) =>
|
||||
qualities.map((quality, index) => ({
|
||||
selector: { operation, quality },
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: (index + 1 + operationIndex * 3) * 1_000_000,
|
||||
})),
|
||||
),
|
||||
});
|
||||
const channel = resolveModelChannel(config, config.model);
|
||||
for (const imageCount of [0, 1]) {
|
||||
for (const [index, quality] of qualities.entries()) {
|
||||
const requirements: ModelRequirements = {
|
||||
capability: "image",
|
||||
input: { textCount: 1, imageCount, characterCount: 0, videoCount: 0, audioCount: 0 },
|
||||
options: { quality: quality.toUpperCase(), size: "16:9" },
|
||||
};
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(index + 1 + imageCount * 3);
|
||||
expect(modelQuoteRequest(config, config.model, "image", requirements)?.intent).toMatchObject({
|
||||
operation: imageCount ? "image_to_image" : "text_to_image",
|
||||
inputs: { image: imageCount },
|
||||
options: { quality: quality.toUpperCase() },
|
||||
});
|
||||
}
|
||||
}
|
||||
const characterRequirements: ModelRequirements = { capability: "image", input: { textCount: 1, imageCount: 0, characterCount: 1, videoCount: 0, audioCount: 0 }, options: { quality: "2k" } };
|
||||
expect(modelQuoteRequest(config, config.model, "image", characterRequirements)?.intent).toMatchObject({ operation: "image_to_image", inputs: { image: 1 } });
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: channel.modelCosts, model: "image-model", capability: "image", config, requirements: characterRequirements, count: 1 })).toBe(5);
|
||||
expect(priceTiersForCurrentSelection(channel.modelCosts![0]!.logicalPriceTiers!, "image", { ...config, quality: "8k" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("prefers a matching image specification over the uniform fallback", () => {
|
||||
const config = systemConfig({ capability: "image", tiers: [
|
||||
{ selector: {}, billingMode: "fixed_request", unitPriceMicrocredits: 1_000_000 },
|
||||
{ selector: { operation: "text_to_image", quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
|
||||
] });
|
||||
const tiers = resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!;
|
||||
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "2k" })[0]?.unitPriceMicrocredits).toBe(2_000_000);
|
||||
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
test("preserves provider-specific resolution enums when matching price tiers", () => {
|
||||
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
|
||||
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
|
||||
|
||||
Reference in New Issue
Block a user