| @@ -0,0 +1,126 @@ | |||||
| package helper | |||||
| import ( | |||||
| "fmt" | |||||
| "math" | |||||
| "strconv" | |||||
| "strings" | |||||
| ) | |||||
| func init() { | |||||
| registerDerivedPricingFunc("megapixels", derivedMegapixels) | |||||
| registerDerivedPricingFunc("aspect_ratio", derivedAspectRatio) | |||||
| registerDerivedPricingFunc("input_mode", derivedInputMode) | |||||
| } | |||||
| func derivedMegapixels(req, resp map[string]any) (any, error) { | |||||
| resolution, ok, _ := getNestedValue(req, "resolution") | |||||
| if !ok || !hasNonEmptyValue(resolution) { | |||||
| resolution, ok, _ = getNestedValue(req, "options.size") | |||||
| } | |||||
| if !ok || !hasNonEmptyValue(resolution) { | |||||
| return nil, nil | |||||
| } | |||||
| width, height, err := parseResolution(fmt.Sprintf("%v", resolution)) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| return float64(width*height) / 1_000_000, nil | |||||
| } | |||||
| func derivedAspectRatio(req, resp map[string]any) (any, error) { | |||||
| resolution, ok, _ := getNestedValue(req, "resolution") | |||||
| if !ok || !hasNonEmptyValue(resolution) { | |||||
| resolution, ok, _ = getNestedValue(req, "options.size") | |||||
| } | |||||
| if !ok || !hasNonEmptyValue(resolution) { | |||||
| return nil, nil | |||||
| } | |||||
| width, height, err := parseResolution(fmt.Sprintf("%v", resolution)) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if height == 0 { | |||||
| return nil, fmt.Errorf("resolution height must be greater than zero") | |||||
| } | |||||
| gcd := intGCD(width, height) | |||||
| return fmt.Sprintf("%d:%d", width/gcd, height/gcd), nil | |||||
| } | |||||
| func derivedInputMode(req, resp map[string]any) (any, error) { | |||||
| if hasAnyNonEmpty(req, "video", "video_url", "reference_video", "first_frame_video") { | |||||
| return "v2v", nil | |||||
| } | |||||
| if hasAnyNonEmpty(req, "image", "image_url", "image[]", "images", "first_frame_image", "last_frame_image") { | |||||
| return "i2v", nil | |||||
| } | |||||
| return "t2v", nil | |||||
| } | |||||
| func hasNonEmptyValue(v any) bool { | |||||
| switch value := v.(type) { | |||||
| case nil: | |||||
| return false | |||||
| case string: | |||||
| return strings.TrimSpace(value) != "" | |||||
| case []any: | |||||
| return len(value) > 0 | |||||
| case []string: | |||||
| return len(value) > 0 | |||||
| case map[string]any: | |||||
| return len(value) > 0 | |||||
| default: | |||||
| return true | |||||
| } | |||||
| } | |||||
| func hasAnyNonEmpty(req map[string]any, keys ...string) bool { | |||||
| for _, key := range keys { | |||||
| value, ok, _ := getNestedValue(req, key) | |||||
| if ok && hasNonEmptyValue(value) { | |||||
| return true | |||||
| } | |||||
| } | |||||
| return false | |||||
| } | |||||
| func parseResolution(raw string) (int, int, error) { | |||||
| normalized := strings.ToLower(strings.TrimSpace(raw)) | |||||
| if strings.HasSuffix(normalized, "p") { | |||||
| height, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) | |||||
| if err != nil || height <= 0 { | |||||
| return 0, 0, fmt.Errorf("invalid resolution %q", raw) | |||||
| } | |||||
| width := int(math.Round(float64(height) * 16 / 9)) | |||||
| return width, height, nil | |||||
| } | |||||
| parts := strings.FieldsFunc(normalized, func(r rune) bool { | |||||
| return r == 'x' || r == '*' || r == '×' | |||||
| }) | |||||
| if len(parts) != 2 { | |||||
| return 0, 0, fmt.Errorf("invalid resolution %q", raw) | |||||
| } | |||||
| width, err := strconv.Atoi(strings.TrimSpace(parts[0])) | |||||
| if err != nil || width <= 0 { | |||||
| return 0, 0, fmt.Errorf("invalid resolution width %q", raw) | |||||
| } | |||||
| height, err := strconv.Atoi(strings.TrimSpace(parts[1])) | |||||
| if err != nil || height <= 0 { | |||||
| return 0, 0, fmt.Errorf("invalid resolution height %q", raw) | |||||
| } | |||||
| return width, height, nil | |||||
| } | |||||
| func intGCD(a, b int) int { | |||||
| for b != 0 { | |||||
| a, b = b, a%b | |||||
| } | |||||
| if a < 0 { | |||||
| return -a | |||||
| } | |||||
| if a == 0 { | |||||
| return 1 | |||||
| } | |||||
| return a | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| package helper | |||||
| import ( | |||||
| "testing" | |||||
| "github.com/stretchr/testify/require" | |||||
| ) | |||||
| func TestDerivedInputMode(t *testing.T) { | |||||
| mode, err := derivedInputMode(map[string]any{}, nil) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "t2v", mode) | |||||
| mode, err = derivedInputMode(map[string]any{"image": "frame.png"}, nil) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "i2v", mode) | |||||
| mode, err = derivedInputMode(map[string]any{"image": "frame.png", "video": "clip.mp4"}, nil) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "v2v", mode) | |||||
| } | |||||
| func TestDerivedResolutionHelpers(t *testing.T) { | |||||
| mp, err := derivedMegapixels(map[string]any{"resolution": "1280x720"}, nil) | |||||
| require.NoError(t, err) | |||||
| require.InDelta(t, 0.9216, mp, 0.0001) | |||||
| ratio, err := derivedAspectRatio(map[string]any{"options": map[string]any{"size": "1280x720"}}, nil) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "16:9", ratio) | |||||
| } | |||||
| func TestHasNonEmptyValue(t *testing.T) { | |||||
| require.False(t, hasNonEmptyValue("")) | |||||
| require.False(t, hasNonEmptyValue(" ")) | |||||
| require.False(t, hasNonEmptyValue(nil)) | |||||
| require.False(t, hasNonEmptyValue([]any{})) | |||||
| require.True(t, hasNonEmptyValue([]any{"x"})) | |||||
| require.True(t, hasNonEmptyValue("x")) | |||||
| require.True(t, hasNonEmptyValue(0)) | |||||
| } | |||||
| @@ -0,0 +1,251 @@ | |||||
| package helper | |||||
| import ( | |||||
| "fmt" | |||||
| "io" | |||||
| "mime/multipart" | |||||
| "strconv" | |||||
| "strings" | |||||
| "github.com/QuantumNous/new-api/common" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | |||||
| ) | |||||
| type derivedPricingFunc func(req, resp map[string]any) (any, error) | |||||
| var derivedPricingFuncs = map[string]derivedPricingFunc{} | |||||
| type ResolvedDimensionValues struct { | |||||
| Values map[string]any | |||||
| Missing map[string]bool | |||||
| RawInput map[string]any | |||||
| } | |||||
| type DimensionResolver struct { | |||||
| c *gin.Context | |||||
| request map[string]any | |||||
| response map[string]any | |||||
| } | |||||
| func NewDimensionResolver(c *gin.Context) (*DimensionResolver, error) { | |||||
| contentType := c.Request.Header.Get("Content-Type") | |||||
| req := map[string]any{} | |||||
| switch { | |||||
| case strings.HasPrefix(contentType, "application/json"): | |||||
| storage, err := common.GetBodyStorage(c) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| body, err := storage.Bytes() | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if len(strings.TrimSpace(string(body))) > 0 { | |||||
| if err := common.Unmarshal(body, &req); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| } | |||||
| if err := resetRequestBody(c, storage); err != nil { | |||||
| return nil, err | |||||
| } | |||||
| case strings.Contains(contentType, gin.MIMEMultipartPOSTForm): | |||||
| form, err := common.ParseMultipartFormReusable(c) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| req = requestMapFromMultipart(form) | |||||
| default: | |||||
| return nil, fmt.Errorf("unsupported content-type for pricing dimensions: %s", contentType) | |||||
| } | |||||
| return &DimensionResolver{ | |||||
| c: c, | |||||
| request: req, | |||||
| response: map[string]any{}, | |||||
| }, nil | |||||
| } | |||||
| func (r *DimensionResolver) ResolveDimensions(dims []types.PricingDimension) (*ResolvedDimensionValues, error) { | |||||
| resolved := &ResolvedDimensionValues{ | |||||
| Values: make(map[string]any, len(dims)), | |||||
| Missing: make(map[string]bool), | |||||
| RawInput: types.CloneMapAny(r.request), | |||||
| } | |||||
| for _, dim := range dims { | |||||
| value, exists, err := r.resolveSource(dim.Source) | |||||
| if err != nil { | |||||
| return nil, err | |||||
| } | |||||
| if !exists || !hasNonEmptyValue(value) { | |||||
| if dim.Optional { | |||||
| resolved.Values[dim.Key] = dim.Default | |||||
| continue | |||||
| } | |||||
| resolved.Values[dim.Key] = nil | |||||
| resolved.Missing[dim.Key] = true | |||||
| continue | |||||
| } | |||||
| converted, err := convertPricingDimensionValue(value, dim.Type) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("dimension %q: %w", dim.Key, err) | |||||
| } | |||||
| resolved.Values[dim.Key] = converted | |||||
| } | |||||
| return resolved, nil | |||||
| } | |||||
| func IsAllowedPricingSource(source string) bool { | |||||
| if strings.HasPrefix(source, "response.") { | |||||
| return false | |||||
| } | |||||
| if strings.HasPrefix(source, "request.") { | |||||
| return len(strings.TrimPrefix(source, "request.")) > 0 | |||||
| } | |||||
| if _, ok := derivedPricingFuncs[strings.TrimPrefix(source, "derived.")]; ok && strings.HasPrefix(source, "derived.") { | |||||
| return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| func registerDerivedPricingFunc(name string, fn derivedPricingFunc) { | |||||
| derivedPricingFuncs[name] = fn | |||||
| } | |||||
| func (r *DimensionResolver) resolveSource(source string) (any, bool, error) { | |||||
| if strings.HasPrefix(source, "response.") { | |||||
| return nil, false, fmt.Errorf("pricing source %q is unsupported in schema v1", source) | |||||
| } | |||||
| if strings.HasPrefix(source, "request.") { | |||||
| return getNestedValue(r.request, strings.TrimPrefix(source, "request.")) | |||||
| } | |||||
| if strings.HasPrefix(source, "derived.") { | |||||
| name := strings.TrimPrefix(source, "derived.") | |||||
| fn, ok := derivedPricingFuncs[name] | |||||
| if !ok { | |||||
| return nil, false, fmt.Errorf("derived pricing source %q is not registered", source) | |||||
| } | |||||
| value, err := fn(r.request, r.response) | |||||
| if err != nil { | |||||
| return nil, false, err | |||||
| } | |||||
| return value, hasNonEmptyValue(value), nil | |||||
| } | |||||
| return nil, false, fmt.Errorf("pricing source %q is not allowed", source) | |||||
| } | |||||
| func getNestedValue(src map[string]any, path string) (any, bool, error) { | |||||
| if path == "" { | |||||
| return nil, false, nil | |||||
| } | |||||
| var cur any = src | |||||
| for _, part := range strings.Split(path, ".") { | |||||
| m, ok := cur.(map[string]any) | |||||
| if !ok { | |||||
| return nil, false, nil | |||||
| } | |||||
| cur, ok = m[part] | |||||
| if !ok { | |||||
| return nil, false, nil | |||||
| } | |||||
| } | |||||
| return cur, true, nil | |||||
| } | |||||
| func convertPricingDimensionValue(value any, typ string) (any, error) { | |||||
| switch typ { | |||||
| case "string": | |||||
| if s, ok := value.(string); ok { | |||||
| return s, nil | |||||
| } | |||||
| return fmt.Sprintf("%v", value), nil | |||||
| case "number": | |||||
| switch v := value.(type) { | |||||
| case float64: | |||||
| return v, nil | |||||
| case float32: | |||||
| return float64(v), nil | |||||
| case int: | |||||
| return float64(v), nil | |||||
| case int64: | |||||
| return float64(v), nil | |||||
| case int32: | |||||
| return float64(v), nil | |||||
| case string: | |||||
| f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("value %q is not a number", v) | |||||
| } | |||||
| return f, nil | |||||
| default: | |||||
| return nil, fmt.Errorf("value is not a number") | |||||
| } | |||||
| case "boolean": | |||||
| switch v := value.(type) { | |||||
| case bool: | |||||
| return v, nil | |||||
| case string: | |||||
| b, err := strconv.ParseBool(strings.TrimSpace(v)) | |||||
| if err != nil { | |||||
| return nil, fmt.Errorf("value %q is not a boolean", v) | |||||
| } | |||||
| return b, nil | |||||
| default: | |||||
| return nil, fmt.Errorf("value is not a boolean") | |||||
| } | |||||
| default: | |||||
| return nil, fmt.Errorf("unknown dimension type %q", typ) | |||||
| } | |||||
| } | |||||
| func requestMapFromMultipart(form *multipart.Form) map[string]any { | |||||
| req := map[string]any{} | |||||
| for key, vals := range form.Value { | |||||
| if len(vals) == 1 { | |||||
| assignNestedValue(req, key, vals[0]) | |||||
| continue | |||||
| } | |||||
| copied := append([]string(nil), vals...) | |||||
| assignNestedValue(req, key, copied) | |||||
| } | |||||
| for key, files := range form.File { | |||||
| if len(files) == 1 { | |||||
| assignNestedValue(req, key, files[0].Filename) | |||||
| continue | |||||
| } | |||||
| names := make([]string, 0, len(files)) | |||||
| for _, file := range files { | |||||
| names = append(names, file.Filename) | |||||
| } | |||||
| assignNestedValue(req, key, names) | |||||
| } | |||||
| return req | |||||
| } | |||||
| func assignNestedValue(dst map[string]any, key string, value any) { | |||||
| parts := strings.Split(key, ".") | |||||
| cur := dst | |||||
| for i, part := range parts { | |||||
| if i == len(parts)-1 { | |||||
| cur[part] = value | |||||
| return | |||||
| } | |||||
| next, ok := cur[part].(map[string]any) | |||||
| if !ok { | |||||
| next = map[string]any{} | |||||
| cur[part] = next | |||||
| } | |||||
| cur = next | |||||
| } | |||||
| } | |||||
| func resetRequestBody(c *gin.Context, storage common.BodyStorage) error { | |||||
| if _, err := storage.Seek(0, io.SeekStart); err != nil { | |||||
| return err | |||||
| } | |||||
| c.Request.Body = io.NopCloser(storage) | |||||
| return nil | |||||
| } | |||||
| @@ -0,0 +1,127 @@ | |||||
| package helper | |||||
| import ( | |||||
| "bytes" | |||||
| "mime/multipart" | |||||
| "net/http" | |||||
| "net/http/httptest" | |||||
| "strings" | |||||
| "testing" | |||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | |||||
| "github.com/stretchr/testify/require" | |||||
| ) | |||||
| func TestDimensionResolver_JSONBody(t *testing.T) { | |||||
| c := newPricingJSONContext(`{"resolution":"720P","duration":"10","options":{"size":"1280x720"},"image_url":"https://example.test/a.png"}`) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| values, err := resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "resolution", Source: "request.resolution", Type: "string"}, | |||||
| {Key: "size", Source: "request.options.size", Type: "string"}, | |||||
| {Key: "duration", Source: "request.duration", Type: "number"}, | |||||
| {Key: "mode", Source: "derived.input_mode", Type: "string"}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "720P", values.Values["resolution"]) | |||||
| require.Equal(t, "1280x720", values.Values["size"]) | |||||
| require.Equal(t, 10.0, values.Values["duration"]) | |||||
| require.Equal(t, "i2v", values.Values["mode"]) | |||||
| require.Empty(t, values.Missing) | |||||
| } | |||||
| func TestDimensionResolver_DerivedInputModePriority(t *testing.T) { | |||||
| c := newPricingJSONContext(`{"image_url":"https://example.test/a.png","video_url":"https://example.test/a.mp4"}`) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| values, err := resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "mode", Source: "derived.input_mode", Type: "string"}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "v2v", values.Values["mode"]) | |||||
| } | |||||
| func TestDimensionResolver_MissingRequiredValue(t *testing.T) { | |||||
| c := newPricingJSONContext(`{"resolution":"720P"}`) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| values, err := resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "duration", Source: "request.duration", Type: "number"}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| require.True(t, values.Missing["duration"]) | |||||
| require.Nil(t, values.Values["duration"]) | |||||
| } | |||||
| func TestDimensionResolver_OptionalDefault(t *testing.T) { | |||||
| c := newPricingJSONContext(`{"resolution":"720P"}`) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| values, err := resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "duration", Source: "request.duration", Type: "number", Optional: true, Default: 5.0}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, 5.0, values.Values["duration"]) | |||||
| require.Empty(t, values.Missing) | |||||
| } | |||||
| func TestDimensionResolver_MultipartFileInputMode(t *testing.T) { | |||||
| body := &bytes.Buffer{} | |||||
| writer := multipart.NewWriter(body) | |||||
| require.NoError(t, writer.WriteField("resolution", "720P")) | |||||
| part, err := writer.CreateFormFile("image", "frame.png") | |||||
| require.NoError(t, err) | |||||
| _, err = part.Write([]byte("fake")) | |||||
| require.NoError(t, err) | |||||
| require.NoError(t, writer.Close()) | |||||
| c := newPricingContext(body.String(), writer.FormDataContentType()) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| values, err := resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "resolution", Source: "request.resolution", Type: "string"}, | |||||
| {Key: "mode", Source: "derived.input_mode", Type: "string"}, | |||||
| }) | |||||
| require.NoError(t, err) | |||||
| require.Equal(t, "720P", values.Values["resolution"]) | |||||
| require.Equal(t, "i2v", values.Values["mode"]) | |||||
| } | |||||
| func TestDimensionResolver_UnsupportedContentType(t *testing.T) { | |||||
| c := newPricingContext("resolution=720P", "application/x-www-form-urlencoded") | |||||
| _, err := NewDimensionResolver(c) | |||||
| require.ErrorContains(t, err, "unsupported content-type") | |||||
| } | |||||
| func TestDimensionResolver_ResponseSourceUnsupported(t *testing.T) { | |||||
| c := newPricingJSONContext(`{"resolution":"720P"}`) | |||||
| resolver, err := NewDimensionResolver(c) | |||||
| require.NoError(t, err) | |||||
| _, err = resolver.ResolveDimensions([]types.PricingDimension{ | |||||
| {Key: "tokens", Source: "response.usage.total_tokens", Type: "number"}, | |||||
| }) | |||||
| require.ErrorContains(t, err, "unsupported in schema v1") | |||||
| } | |||||
| func newPricingJSONContext(body string) *gin.Context { | |||||
| return newPricingContext(body, "application/json") | |||||
| } | |||||
| func newPricingContext(body string, contentType string) *gin.Context { | |||||
| gin.SetMode(gin.TestMode) | |||||
| w := httptest.NewRecorder() | |||||
| c, _ := gin.CreateTestContext(w) | |||||
| req, _ := http.NewRequest(http.MethodPost, "/v1/video", strings.NewReader(body)) | |||||
| req.Header.Set("Content-Type", contentType) | |||||
| c.Request = req | |||||
| return c | |||||
| } | |||||