diff --git a/.github/workflows/scripts/run-migration-tests.sh b/.github/workflows/scripts/run-migration-tests.sh index c99563a0e40..e81b52c13d7 100755 --- a/.github/workflows/scripts/run-migration-tests.sh +++ b/.github/workflows/scripts/run-migration-tests.sh @@ -2074,6 +2074,27 @@ append_dynamic_columns_postgres() { fi done + # governance_model_pricing per-size and joint size+quality per-image output rates + # (added via add_image_size_quality_pricing_columns) + for col in output_cost_per_image_above_1024_and_1536_pixels output_cost_per_image_above_1536_and_1024_pixels \ + output_cost_per_image_above_1024_and_1024_pixels_low_quality \ + output_cost_per_image_above_1024_and_1536_pixels_low_quality \ + output_cost_per_image_above_1536_and_1024_pixels_low_quality \ + output_cost_per_image_above_1024_and_1024_pixels_medium_quality \ + output_cost_per_image_above_1024_and_1536_pixels_medium_quality \ + output_cost_per_image_above_1536_and_1024_pixels_medium_quality \ + output_cost_per_image_above_1024_and_1024_pixels_high_quality \ + output_cost_per_image_above_1024_and_1536_pixels_high_quality \ + output_cost_per_image_above_1536_and_1024_pixels_high_quality \ + output_cost_per_image_above_1024x1024_pixels_standard_quality \ + output_cost_per_image_above_1024x1536_pixels_standard_quality \ + output_cost_per_image_above_1536x1024_pixels_standard_quality; do + if column_exists_postgres "governance_model_pricing" "$col"; then + echo "UPDATE governance_model_pricing SET $col = NULL WHERE id = 1;" >> "$output_file" + echo "UPDATE governance_model_pricing SET $col = NULL WHERE id = 2;" >> "$output_file" + fi + done + # logs.redaction_mapping (added via logs_add_redaction_mapping_column) if column_exists_postgres "logs" "redaction_mapping"; then echo "UPDATE logs SET redaction_mapping = '' WHERE id = 'log-migration-test-001';" >> "$output_file" @@ -3558,6 +3579,27 @@ append_dynamic_columns_sqlite() { echo "UPDATE governance_model_pricing SET $col = NULL WHERE id = 2;" >> "$output_file" fi done + + # governance_model_pricing per-size and joint size+quality per-image output rates + # (added via add_image_size_quality_pricing_columns) + for col in output_cost_per_image_above_1024_and_1536_pixels output_cost_per_image_above_1536_and_1024_pixels \ + output_cost_per_image_above_1024_and_1024_pixels_low_quality \ + output_cost_per_image_above_1024_and_1536_pixels_low_quality \ + output_cost_per_image_above_1536_and_1024_pixels_low_quality \ + output_cost_per_image_above_1024_and_1024_pixels_medium_quality \ + output_cost_per_image_above_1024_and_1536_pixels_medium_quality \ + output_cost_per_image_above_1536_and_1024_pixels_medium_quality \ + output_cost_per_image_above_1024_and_1024_pixels_high_quality \ + output_cost_per_image_above_1024_and_1536_pixels_high_quality \ + output_cost_per_image_above_1536_and_1024_pixels_high_quality \ + output_cost_per_image_above_1024x1024_pixels_standard_quality \ + output_cost_per_image_above_1024x1536_pixels_standard_quality \ + output_cost_per_image_above_1536x1024_pixels_standard_quality; do + if column_exists_sqlite "$config_db" "governance_model_pricing" "$col"; then + echo "UPDATE governance_model_pricing SET $col = NULL WHERE id = 1;" >> "$output_file" + echo "UPDATE governance_model_pricing SET $col = NULL WHERE id = 2;" >> "$output_file" + fi + done fi # logs.redaction_mapping (added via logs_add_redaction_mapping_column) diff --git a/core/providers/anthropic/anthropic.go b/core/providers/anthropic/anthropic.go index 6da866aeee4..03fd0102ec7 100644 --- a/core/providers/anthropic/anthropic.go +++ b/core/providers/anthropic/anthropic.go @@ -782,7 +782,7 @@ func HandleAnthropicChatCompletionStreaming( providerUtils.SetExtraHeaders(ctx, req, extraHeaders, []string{AnthropicBetaHeader}) // OAuth passthrough: forward the caller's raw headers, whose token is the upstream // credential. Skips anthropic-beta — MergeBetaHeaders below owns the final value. - providerUtils.SetPassthroughHeaders(ctx, req, providerName, []string{AnthropicBetaHeader}) + providerUtils.SetPassthroughHeadersForStreaming(ctx, req, providerName, []string{AnthropicBetaHeader}) if betaHeaders := FilterBetaHeadersForProvider(MergeBetaHeaders(ctx, extraHeaders), providerName, betaHeaderOverrides); len(betaHeaders) > 0 { req.Header.Set(AnthropicBetaHeader, strings.Join(betaHeaders, ",")) @@ -1436,7 +1436,7 @@ func HandleAnthropicResponsesStream( providerUtils.SetExtraHeaders(ctx, req, extraHeaders, []string{AnthropicBetaHeader}) // OAuth passthrough: forward the caller's raw headers, whose token is the upstream // credential. Skips anthropic-beta — MergeBetaHeaders below owns the final value. - providerUtils.SetPassthroughHeaders(ctx, req, providerName, []string{AnthropicBetaHeader}) + providerUtils.SetPassthroughHeadersForStreaming(ctx, req, providerName, []string{AnthropicBetaHeader}) if betaHeaders := FilterBetaHeadersForProvider(MergeBetaHeaders(ctx, extraHeaders), providerName, betaHeaderOverrides); len(betaHeaders) > 0 { req.Header.Set(AnthropicBetaHeader, strings.Join(betaHeaders, ",")) diff --git a/core/providers/anthropic/passthroughheaders_test.go b/core/providers/anthropic/passthroughheaders_test.go index 1258f336d6f..762827cf983 100644 --- a/core/providers/anthropic/passthroughheaders_test.go +++ b/core/providers/anthropic/passthroughheaders_test.go @@ -19,6 +19,7 @@ func clientHeaders() map[string][]string { "anthropic-beta": {"interleaved-thinking-2025-05-14"}, "anthropic-version": {"2023-06-01"}, "content-type": {"text/plain"}, + "accept-encoding": {"gzip, deflate, br"}, "x-app": {"cli"}, "x-stainless-lang": {"js"}, "x-bf-vk": {"sk-bf-must-never-leave-the-gateway"}, @@ -41,6 +42,7 @@ func TestSetPassthroughHeaders_ForwardsCallerCredentialButNotInternals(t *testin forwarded := map[string]string{ "authorization": "Bearer sk-ant-oat01-caller-token", "anthropic-version": "2023-06-01", + "accept-encoding": "gzip, deflate, br", "x-app": "cli", "x-stainless-lang": "js", } @@ -59,10 +61,69 @@ func TestSetPassthroughHeaders_ForwardsCallerCredentialButNotInternals(t *testin if got := string(req.Header.Peek(AnthropicBetaHeader)); got != "" { t.Errorf("anthropic-beta must be left to MergeBetaHeaders, got %q", got) } - // Bifrost owns the body, so it owns Content-Type — a caller value must never override it, - // regardless of where callers invoke this relative to their own SetContentType. - if got := string(req.Header.Peek("Content-Type")); got != "" { - t.Errorf("caller Content-Type must not be forwarded, got %q", got) + // Bifrost owns the request body, so the caller cannot override its content type. + if got := string(req.Header.Peek("content-type")); got != "" { + t.Errorf("caller content-type must not be forwarded, got %q", got) + } +} + +func TestSetPassthroughHeaders_FiltersAcceptEncodingToSupportedCodecs(t *testing.T) { + tests := []struct { + name string + values []string + streaming bool + wantForward string + }{ + { + name: "supported list is preserved", + values: []string{"gzip, deflate, br, zstd"}, + wantForward: "gzip, deflate, br, zstd", + }, + { + name: "unsupported tokens and wildcard are removed", + values: []string{"gzip, snappy;q=0.8", "br;q=0.5, *;q=0"}, + wantForward: "gzip, br;q=0.5", + }, + { + name: "unsupported-only header pins identity", + values: []string{"snappy"}, + wantForward: "identity", + }, + { + name: "streaming keeps only incrementally decoded codecs", + values: []string{"gzip, deflate, br, zstd"}, + streaming: true, + wantForward: "gzip", + }, + { + // Dropping the header here would let the upstream answer in br, which the + // streaming decoder cannot handle. + name: "streaming pins identity when no codec survives", + values: []string{"br, zstd"}, + streaming: true, + wantForward: "identity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + ctx.SetValue(schemas.BifrostContextKeyPassthroughHeaders, map[string][]string{ + "accept-encoding": tt.values, + }) + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + if tt.streaming { + providerUtils.SetPassthroughHeadersForStreaming(ctx, req, schemas.Anthropic, nil) + } else { + providerUtils.SetPassthroughHeaders(ctx, req, schemas.Anthropic, nil) + } + + if got := string(req.Header.Peek("accept-encoding")); got != tt.wantForward { + t.Errorf("Accept-Encoding = %q, want %q", got, tt.wantForward) + } + }) } } diff --git a/core/providers/utils/utils.go b/core/providers/utils/utils.go index 2bc994895dd..d3fc2bc847c 100644 --- a/core/providers/utils/utils.go +++ b/core/providers/utils/utils.go @@ -1011,12 +1011,68 @@ func SetExtraHeaders(ctx context.Context, req *fasthttp.Request, extraHeaders ma // gateway credentials and must never be forwarded to a provider. const internalHeaderPrefix = "x-bf-" +// supportedBufferedContentEncodings are the response codings handled by +// CheckAndDecodeBody. Accept-Encoding passthrough is intersected with this set +// so Bifrost never advertises an upstream response format it cannot parse. +var supportedBufferedContentEncodings = map[string]struct{}{ + "identity": {}, + "gzip": {}, + "x-gzip": {}, + "deflate": {}, + "br": {}, + "zstd": {}, +} + +// supportedStreamingContentEncodings are the codings handled incrementally by +// DecompressStreamBody. Keep this separate from the buffered set: advertising +// Brotli or zstd on an SSE request would be unsafe until the stream reader can +// decode those formats without buffering the whole response. +var supportedStreamingContentEncodings = map[string]struct{}{ + "identity": {}, + "gzip": {}, + "x-gzip": {}, +} + +func filterSupportedAcceptEncodings(values []string, supportedEncodings map[string]struct{}) []string { + filtered := make([]string, 0, len(values)) + for _, value := range values { + for item := range strings.SplitSeq(value, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + + coding := item + if separator := strings.IndexByte(coding, ';'); separator >= 0 { + coding = coding[:separator] + } + coding = strings.ToLower(strings.TrimSpace(coding)) + if _, ok := supportedEncodings[coding]; ok { + // Preserve parameters such as q-values while normalizing the list into + // one header value below. + filtered = append(filtered, item) + } + } + } + return filtered +} + // SetPassthroughHeaders applies the caller's raw request headers captured for Anthropic OAuth // passthrough, where the caller's token is the upstream credential. ONLY the Anthropic provider // may call this: every other provider authenticates with its own configured credentials, so // forwarding these would leak x-bf-* upstream and, on Bedrock, break SigV4 when a hop rewrites // x-forwarded-for. Hop-by-hop headers are dropped by filterHeaders, x-bf-* never leaves the gateway. func SetPassthroughHeaders(ctx context.Context, req *fasthttp.Request, provider schemas.ModelProvider, skipHeaders []string) { + setPassthroughHeaders(ctx, req, provider, skipHeaders, supportedBufferedContentEncodings) +} + +// SetPassthroughHeadersForStreaming applies OAuth passthrough headers while +// advertising only response codings that Bifrost can decode incrementally. +func SetPassthroughHeadersForStreaming(ctx context.Context, req *fasthttp.Request, provider schemas.ModelProvider, skipHeaders []string) { + setPassthroughHeaders(ctx, req, provider, skipHeaders, supportedStreamingContentEncodings) +} + +func setPassthroughHeaders(ctx context.Context, req *fasthttp.Request, provider schemas.ModelProvider, skipHeaders []string, supportedEncodings map[string]struct{}) { // Gate on the provider here rather than at the call sites: the Anthropic request handlers // are shared with azure, vertex, bedrockmantle, vllm, sgl, deepseek and fireworks, so a // call-site check would silently forward the caller's credential to all of them. @@ -1032,13 +1088,25 @@ func SetPassthroughHeaders(ctx context.Context, req *fasthttp.Request, provider if strings.HasPrefix(lower, internalHeaderPrefix) { continue } - // Bifrost owns the body it sends, so it owns Content-Type. Dropping it here keeps a - // caller value from overriding the JSON content type no matter where callers invoke - // this relative to their own SetContentType. + if skipHeaders != nil && slices.Contains(skipHeaders, lower) { + continue + } + // Bifrost owns the body it sends, so a caller cannot override its content type. if lower == "content-type" { continue } - if skipHeaders != nil && slices.Contains(skipHeaders, lower) { + if lower == "accept-encoding" { + // Filtering must never widen what the upstream may send: an omitted + // Accept-Encoding means any coding is acceptable (RFC 9110 12.5.3), so a + // caller list that filters down to nothing pins identity instead of + // dropping the header. Without this, a streaming caller asking for br + // alone would let the upstream answer in br, which DecompressStreamBody + // cannot decode, and the SSE parser would see raw compressed bytes. + if supported := filterSupportedAcceptEncodings(values, supportedEncodings); len(supported) > 0 { + req.Header.Set(k, strings.Join(supported, ", ")) + } else { + req.Header.Set(k, "identity") + } continue } for i, v := range values { @@ -2052,33 +2120,79 @@ func CheckOperationAllowed(defaultProvider schemas.ModelProvider, config *schema } // CheckAndDecodeBody checks the content encoding and decodes the body accordingly. -// It returns a copy of the body to avoid race conditions when the response is released -// back to fasthttp's buffer pool. Uses pooled gzip readers to reduce GC pressure. +// It returns an owned body to avoid races when the response is released back to +// fasthttp's buffer pool. Content codings are decoded in reverse application order, +// as required by RFC 9110, using the shared pooled readers. func CheckAndDecodeBody(resp *fasthttp.Response) ([]byte, error) { + body := resp.Body() + if len(body) == 0 { + return nil, nil + } + + result := append([]byte(nil), body...) contentEncoding := strings.ToLower(strings.TrimSpace(string(resp.Header.Peek("Content-Encoding")))) - if strings.Contains(contentEncoding, "gzip") { - body := resp.Body() - if len(body) == 0 { - return nil, nil - } + if contentEncoding == "" || contentEncoding == "identity" { + return result, nil + } - reader := bytes.NewReader(body) - gz, err := AcquireGzipReader(reader) - if err != nil { - return nil, err - } - defer ReleaseGzipReader(gz) + encodings := strings.Split(contentEncoding, ",") + for i := len(encodings) - 1; i >= 0; i-- { + encoding := strings.TrimSpace(encodings[i]) + reader := bytes.NewReader(result) - decompressed, err := io.ReadAll(gz) - if err != nil { - return nil, err + // Release on the Acquire error paths too: zstd.NewReader's contract is to + // return the decoder alongside its error, so a bare return could drop one + // from the pool. The gzip and deflate constructors return nil there, where + // Release is a no-op. + switch encoding { + case "", "identity": + continue + case "gzip", "x-gzip": + gz, err := AcquireGzipReader(reader) + if err != nil { + ReleaseGzipReader(gz) + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + result, err = io.ReadAll(gz) + ReleaseGzipReader(gz) + if err != nil { + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + case "deflate": + fr, err := AcquireFlateReader(reader) + if err != nil { + ReleaseFlateReader(fr) + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + result, err = io.ReadAll(fr) + ReleaseFlateReader(fr) + if err != nil { + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + case "br": + br := AcquireBrotliReader(reader) + var err error + result, err = io.ReadAll(br) + ReleaseBrotliReader(br) + if err != nil { + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + case "zstd": + dec, err := AcquireZstdDecoder(reader) + if err != nil { + ReleaseZstdDecoder(dec) + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + result, err = io.ReadAll(dec) + ReleaseZstdDecoder(dec) + if err != nil { + return nil, fmt.Errorf("decode %s response body: %w", encoding, err) + } + default: + return nil, fmt.Errorf("unsupported Content-Encoding %q", encoding) } - return decompressed, nil } - // Copy the body to avoid race conditions when response is released back to pool - body := resp.Body() - result := make([]byte, len(body)) - copy(result, body) + return result, nil } diff --git a/core/providers/utils/utils_test.go b/core/providers/utils/utils_test.go index 9b67cc190a4..499100fcf50 100644 --- a/core/providers/utils/utils_test.go +++ b/core/providers/utils/utils_test.go @@ -658,9 +658,10 @@ func TestMarshalSorted_Deterministic(t *testing.T) { } } -// TestCheckAndDecodeBody_PooledGzip verifies that CheckAndDecodeBody correctly -// decompresses gzip-encoded responses using pooled gzip readers. -func TestCheckAndDecodeBody_PooledGzip(t *testing.T) { +// TestCheckAndDecodeBody_ContentEncodings verifies that unary provider responses +// are decoded with the shared pooled readers before JSON parsing. +func TestCheckAndDecodeBody_ContentEncodings(t *testing.T) { + chainedPayload := gzipCompress([]byte(`{"message":"chained"}`)) tests := []struct { name string body []byte @@ -689,6 +690,36 @@ func TestCheckAndDecodeBody_PooledGzip(t *testing.T) { wantBody: `trimmed`, wantErr: false, }, + { + name: "brotli encoded body", + body: compressBrotli([]byte(`{"input_tokens":16998}`)), + contentEncoding: "br", + wantBody: `{"input_tokens":16998}`, + }, + { + name: "deflate encoded body", + body: compressFlate([]byte(`{"message":"deflate"}`)), + contentEncoding: "deflate", + wantBody: `{"message":"deflate"}`, + }, + { + name: "zstd encoded body", + body: compressZstd([]byte(`{"message":"zstd"}`)), + contentEncoding: "zstd", + wantBody: `{"message":"zstd"}`, + }, + { + name: "identity encoded body", + body: []byte(`{"message":"identity"}`), + contentEncoding: "identity", + wantBody: `{"message":"identity"}`, + }, + { + name: "chained gzip then brotli body", + body: compressBrotli(chainedPayload), + contentEncoding: "gzip, br", + wantBody: `{"message":"chained"}`, + }, { name: "no encoding - plain body", body: []byte(`plain text`), @@ -709,6 +740,30 @@ func TestCheckAndDecodeBody_PooledGzip(t *testing.T) { contentEncoding: "gzip", wantErr: true, }, + { + name: "invalid brotli data", + body: []byte{0xFF, 0xFE, 0xFD}, + contentEncoding: "br", + wantErr: true, + }, + { + name: "invalid deflate data", + body: []byte{0xFF, 0xFE, 0xFD}, + contentEncoding: "deflate", + wantErr: true, + }, + { + name: "invalid zstd data", + body: []byte{0xFF, 0xFE, 0xFD}, + contentEncoding: "zstd", + wantErr: true, + }, + { + name: "unsupported encoding", + body: []byte(`encoded somehow`), + contentEncoding: "snappy", + wantErr: true, + }, } for _, tt := range tests { diff --git a/core/schemas/headers.go b/core/schemas/headers.go index dc9d8ffde6b..c7973135d5f 100644 --- a/core/schemas/headers.go +++ b/core/schemas/headers.go @@ -20,6 +20,20 @@ func MatchHeaderPattern(headerName, pattern string) bool { return headerName == pattern } +// RedactSensitiveHeaders replaces the value of every credential-bearing header +// (per IsSensitiveHeader) with RedactedAttrValue, keeping the key so header +// presence is still visible in telemetry. Mutates and returns the given map; +// safe on nil. Call this before exporting captured request headers to any +// observability backend, since FilterHeaders itself does no redaction. +func RedactSensitiveHeaders(headers map[string]string) map[string]string { + for name := range headers { + if IsSensitiveHeader(name) { + headers[name] = RedactedAttrValue + } + } + return headers +} + // FilterHeaders returns the subset of headers whose (lowercased) keys match any of the // given patterns (exact name or wildcard like "x-custom-*" or "*"). Header keys are // expected to already be lowercased by the capture layer. Returns nil when nothing matches. diff --git a/core/schemas/headers_test.go b/core/schemas/headers_test.go new file mode 100644 index 00000000000..ea6a2009358 --- /dev/null +++ b/core/schemas/headers_test.go @@ -0,0 +1,38 @@ +package schemas + +import "testing" + +// TestRedactSensitiveHeaders pins that captured request headers exported to +// observability backends have credential-bearing values replaced (keys kept), +// covering the identity-aware-proxy headers IsSensitiveHeader now matches. +func TestRedactSensitiveHeaders(t *testing.T) { + in := map[string]string{ + "cf-access-jwt-assertion": "eyJ.jwt.sig", + "x-amzn-oidc-data": "eyJ.jwt.sig", + "authorization": "Bearer sk-ant", + "x-api-key": "sk-key", + "x-app": "cli", + "anthropic-beta": "interleaved-thinking-2025-05-14", + } + out := RedactSensitiveHeaders(in) + + redacted := []string{"cf-access-jwt-assertion", "x-amzn-oidc-data", "authorization", "x-api-key"} + for _, k := range redacted { + if out[k] != RedactedAttrValue { + t.Errorf("%q = %q, want %q", k, out[k], RedactedAttrValue) + } + } + if out["x-app"] != "cli" { + t.Errorf("x-app = %q, want it preserved", out["x-app"]) + } + if out["anthropic-beta"] != "interleaved-thinking-2025-05-14" { + t.Errorf("anthropic-beta = %q, want it preserved", out["anthropic-beta"]) + } +} + +// Redacting a nil map must not panic and returns nil. +func TestRedactSensitiveHeaders_Nil(t *testing.T) { + if got := RedactSensitiveHeaders(nil); got != nil { + t.Errorf("got %v, want nil", got) + } +} diff --git a/core/schemas/trace.go b/core/schemas/trace.go index 97cd56c94ed..f440d62867f 100644 --- a/core/schemas/trace.go +++ b/core/schemas/trace.go @@ -1024,6 +1024,11 @@ const RedactedAttrValue = "REDACTED" // well-known exact names, substring/suffix patterns catch credential-bearing // variants like x-auth-token, x-amz-security-token, and provider-specific // *-api-key headers. +// +// Identity-aware-proxy headers are covered explicitly: Cloudflare Access +// (cf-access-*, incl. the cf-access-jwt-assertion signed JWT) and AWS ALB OIDC +// (x-amzn-oidc-*) inject signed identity tokens the substring rules would miss, +// plus generic jwt/assertion-bearing headers. func IsSensitiveHeader(name string) bool { normalized := strings.ToLower(strings.TrimSpace(name)) @@ -1035,6 +1040,10 @@ func IsSensitiveHeader(name string) bool { return strings.Contains(normalized, "api-key") || strings.Contains(normalized, "authorization") || strings.Contains(normalized, "secret") || + strings.Contains(normalized, "assertion") || + strings.Contains(normalized, "jwt") || + strings.HasPrefix(normalized, "cf-access-") || + strings.HasPrefix(normalized, "x-amzn-oidc-") || strings.HasSuffix(normalized, "-token") || strings.HasSuffix(normalized, "_token") } diff --git a/core/utils_extraheader_test.go b/core/utils_extraheader_test.go index 082ae8255f0..fad09694e5d 100644 --- a/core/utils_extraheader_test.go +++ b/core/utils_extraheader_test.go @@ -24,6 +24,9 @@ func TestExtraHeaderSpanAttribute(t *testing.T) { {"other x-bf-* still exported", "x-bf-custom", []string{"v"}, true, "v"}, {"authorization redacted", "authorization", []string{"Bearer sk-ant-oat01"}, true, schemas.RedactedAttrValue}, {"api key redacted", "x-api-key", []string{"sk-ant-key"}, true, schemas.RedactedAttrValue}, + {"cloudflare access jwt redacted", "Cf-Access-Jwt-Assertion", []string{"eyJ.jwt.sig"}, true, schemas.RedactedAttrValue}, + {"cloudflare access family redacted", "cf-access-client-secret", []string{"cf-secret"}, true, schemas.RedactedAttrValue}, + {"aws alb oidc data redacted", "x-amzn-oidc-data", []string{"eyJ.jwt.sig"}, true, schemas.RedactedAttrValue}, {"attribution header forwarded", "x-claude-code-session-id", []string{"sess-1"}, true, "sess-1"}, {"client telemetry forwarded", "x-app", []string{"cli"}, true, "cli"}, {"beta header forwarded", "anthropic-beta", []string{"interleaved-thinking-2025-05-14"}, true, "interleaved-thinking-2025-05-14"}, diff --git a/docs/architecture/framework/model-catalog.mdx b/docs/architecture/framework/model-catalog.mdx index 6e7728f58f5..88a70005ccb 100644 --- a/docs/architecture/framework/model-catalog.mdx +++ b/docs/architecture/framework/model-catalog.mdx @@ -156,6 +156,8 @@ type PricingEntry struct { OutputCostPerImageAbove512x512PixelsPremium *float64 `json:"output_cost_per_image_above_512_and_512_pixels_and_premium_image,omitempty"` OutputCostPerImageAbove1024x1024Pixels *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels,omitempty"` OutputCostPerImageAbove1024x1024PixelsPremium *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_and_premium_image,omitempty"` + OutputCostPerImageAbove1024x1536Pixels *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels,omitempty"` + OutputCostPerImageAbove1536x1024Pixels *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels,omitempty"` OutputCostPerImageAbove2048x2048Pixels *float64 `json:"output_cost_per_image_above_2048_and_2048_pixels,omitempty"` OutputCostPerImageAbove4096x4096Pixels *float64 `json:"output_cost_per_image_above_4096_and_4096_pixels,omitempty"` OutputCostPerImageAbove4Megapixels *float64 `json:"output_cost_per_image_above_4_megapixels,omitempty"` @@ -167,6 +169,18 @@ type PricingEntry struct { OutputCostPerImageMediumQuality *float64 `json:"output_cost_per_image_medium_quality,omitempty"` OutputCostPerImageHighQuality *float64 `json:"output_cost_per_image_high_quality,omitempty"` OutputCostPerImageAutoQuality *float64 `json:"output_cost_per_image_auto_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsLowQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsLowQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsLowQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsHighQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsHighQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsHighQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_standard_quality,omitempty"` // Costs - Audio/Video InputCostPerAudioToken *float64 `json:"input_cost_per_audio_token,omitempty"` InputCostPerAudioPerSecond *float64 `json:"input_cost_per_audio_per_second,omitempty"` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index bccb15476d8..8b620337e51 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -98026,6 +98026,14 @@ "type": "number", "minimum": 0 }, + "output_cost_per_image_above_1024_and_1536_pixels": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1536_and_1024_pixels": { + "type": "number", + "minimum": 0 + }, "output_cost_per_image_above_2048_and_2048_pixels": { "type": "number", "minimum": 0 @@ -98070,6 +98078,54 @@ "type": "number", "minimum": 0 }, + "output_cost_per_image_above_1024_and_1024_pixels_low_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1536_pixels_low_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1536_and_1024_pixels_low_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1024_pixels_medium_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1536_pixels_medium_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1536_and_1024_pixels_medium_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1024_pixels_high_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1536_pixels_high_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1536_and_1024_pixels_high_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1024_pixels_standard_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1024_and_1536_pixels_standard_quality": { + "type": "number", + "minimum": 0 + }, + "output_cost_per_image_above_1536_and_1024_pixels_standard_quality": { + "type": "number", + "minimum": 0 + }, "input_cost_per_image_token": { "type": "number", "minimum": 0 diff --git a/docs/openapi/schemas/management/governance.yaml b/docs/openapi/schemas/management/governance.yaml index cc453675e8e..04a238308d8 100644 --- a/docs/openapi/schemas/management/governance.yaml +++ b/docs/openapi/schemas/management/governance.yaml @@ -1855,6 +1855,12 @@ PricingPatch: output_cost_per_image_above_1024_and_1024_pixels_and_premium_image: type: number minimum: 0 + output_cost_per_image_above_1024_and_1536_pixels: + type: number + minimum: 0 + output_cost_per_image_above_1536_and_1024_pixels: + type: number + minimum: 0 output_cost_per_image_above_2048_and_2048_pixels: type: number minimum: 0 @@ -1888,6 +1894,42 @@ PricingPatch: output_cost_per_image_auto_quality: type: number minimum: 0 + output_cost_per_image_above_1024_and_1024_pixels_low_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1536_pixels_low_quality: + type: number + minimum: 0 + output_cost_per_image_above_1536_and_1024_pixels_low_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1024_pixels_medium_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1536_pixels_medium_quality: + type: number + minimum: 0 + output_cost_per_image_above_1536_and_1024_pixels_medium_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1024_pixels_high_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1536_pixels_high_quality: + type: number + minimum: 0 + output_cost_per_image_above_1536_and_1024_pixels_high_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1024_pixels_standard_quality: + type: number + minimum: 0 + output_cost_per_image_above_1024_and_1536_pixels_standard_quality: + type: number + minimum: 0 + output_cost_per_image_above_1536_and_1024_pixels_standard_quality: + type: number + minimum: 0 input_cost_per_image_token: type: number minimum: 0 diff --git a/docs/providers/custom-pricing.mdx b/docs/providers/custom-pricing.mdx index 7ed980ae543..03cd84048ba 100644 --- a/docs/providers/custom-pricing.mdx +++ b/docs/providers/custom-pricing.mdx @@ -374,6 +374,8 @@ Any field you set (including `0`) is applied as an override; omitted fields are | `output_cost_per_image_auto_quality` | Generated image - auto quality | | `output_cost_per_image_above_512_and_512_pixels` | Generated image at or above 512×512 | | `output_cost_per_image_above_1024_and_1024_pixels` | Generated image at or above 1024×1024 | +| `output_cost_per_image_above_1024_and_1536_pixels` | Generated image at or above 1024×1536 | +| `output_cost_per_image_above_1536_and_1024_pixels` | Generated image at or above 1536×1024 | | `output_cost_per_image_above_2048_and_2048_pixels` | Generated image at or above 2048×2048 | | `output_cost_per_image_above_4096_and_4096_pixels` | Generated image at or above 4096×4096 | | `output_cost_per_image_above_4_megapixels` | Generated image output at or above 4 megapixels | @@ -384,6 +386,24 @@ Any field you set (including `0`) is applied as an override; omitted fields are | `output_cost_per_image_premium_image` | Generated image - premium image | | `output_cost_per_image_above_512_and_512_pixels_and_premium_image` | Generated image at or above 512×512, premium image | | `output_cost_per_image_above_1024_and_1024_pixels_and_premium_image` | Generated image at or above 1024×1024, premium image | +| `output_cost_per_image_above_1024_and_1024_pixels_low_quality` | Generated image at or above 1024×1024, low quality | +| `output_cost_per_image_above_1024_and_1536_pixels_low_quality` | Generated image at or above 1024×1536, low quality | +| `output_cost_per_image_above_1536_and_1024_pixels_low_quality` | Generated image at or above 1536×1024, low quality | +| `output_cost_per_image_above_1024_and_1024_pixels_medium_quality` | Generated image at or above 1024×1024, medium quality | +| `output_cost_per_image_above_1024_and_1536_pixels_medium_quality` | Generated image at or above 1024×1536, medium quality | +| `output_cost_per_image_above_1536_and_1024_pixels_medium_quality` | Generated image at or above 1536×1024, medium quality | +| `output_cost_per_image_above_1024_and_1024_pixels_high_quality` | Generated image at or above 1024×1024, high quality | +| `output_cost_per_image_above_1024_and_1536_pixels_high_quality` | Generated image at or above 1024×1536, high quality | +| `output_cost_per_image_above_1536_and_1024_pixels_high_quality` | Generated image at or above 1536×1024, high quality | +| `output_cost_per_image_above_1024_and_1024_pixels_standard_quality` | Generated image at or above 1024×1024, standard quality | +| `output_cost_per_image_above_1024_and_1536_pixels_standard_quality` | Generated image at or above 1024×1536, standard quality | +| `output_cost_per_image_above_1536_and_1024_pixels_standard_quality` | Generated image at or above 1536×1024, standard quality | + +When several per-image rates could apply, the most specific one wins: a joint size and quality +rate first, then a quality-only rate, then a size-only rate, then the flat `output_cost_per_image`. +The 1024×1536 and 1536×1024 thresholds have the same pixel count, so they are matched on width +and height rather than on the total. An image that clears both (width at least 1536 and height at +least 1536) is billed at the 1536×1024 rate, which is checked first. ### Audio and video costs diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 7c8bb51ab2c..4d712893d0e 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -473,6 +473,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"add_image_megapixel_tier_pricing_columns"}, run: migrationAddImageMegapixelTierPricingColumns}, {IDs: []string{"add_input_cost_per_query_column"}, run: migrationAddInputCostPerQueryColumn}, {IDs: []string{"add_ultrafast_pricing_columns"}, run: migrationAddUltrafastPricingColumns}, + {IDs: []string{"add_image_size_quality_pricing_columns"}, run: migrationAddImageSizeQualityPricingColumns}, } func migrationAddNotificationsTable(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { @@ -12157,3 +12158,52 @@ func migrationAddUltrafastPricingColumns(ctx context.Context, db *gorm.DB, logge } return nil } + +// migrationAddImageSizeQualityPricingColumns adds the per-size and joint +// size+quality per-image output rate columns to the model pricing table. +func migrationAddImageSizeQualityPricingColumns(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_image_size_quality_pricing_columns" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + columns := []string{ + "output_cost_per_image_above_1024_and_1536_pixels", + "output_cost_per_image_above_1536_and_1024_pixels", + "output_cost_per_image_above_1024_and_1024_pixels_low_quality", + "output_cost_per_image_above_1024_and_1536_pixels_low_quality", + "output_cost_per_image_above_1536_and_1024_pixels_low_quality", + "output_cost_per_image_above_1024_and_1024_pixels_medium_quality", + "output_cost_per_image_above_1024_and_1536_pixels_medium_quality", + "output_cost_per_image_above_1536_and_1024_pixels_medium_quality", + "output_cost_per_image_above_1024_and_1024_pixels_high_quality", + "output_cost_per_image_above_1024_and_1536_pixels_high_quality", + "output_cost_per_image_above_1536_and_1024_pixels_high_quality", + "output_cost_per_image_above_1024x1024_pixels_standard_quality", + "output_cost_per_image_above_1024x1536_pixels_standard_quality", + "output_cost_per_image_above_1536x1024_pixels_standard_quality", + } + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + for _, field := range columns { + if err := addColumnIfNotExists(tx, logger, &tables.TableModelPricing{}, field); err != nil { + return fmt.Errorf("failed to add column %s: %w", field, err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + for _, field := range columns { + if err := dropColumnIfExists(tx, logger, &tables.TableModelPricing{}, field); err != nil { + return fmt.Errorf("failed to drop column %s: %w", field, err) + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running %s migration: %s", migrationName, err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index ac665715006..6b332316fab 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -2796,6 +2796,20 @@ var pricingSyncUpdateColumns = []string{ "output_cost_per_image_medium_quality", "output_cost_per_image_high_quality", "output_cost_per_image_auto_quality", + "output_cost_per_image_above_1024_and_1536_pixels", + "output_cost_per_image_above_1536_and_1024_pixels", + "output_cost_per_image_above_1024_and_1024_pixels_low_quality", + "output_cost_per_image_above_1024_and_1536_pixels_low_quality", + "output_cost_per_image_above_1536_and_1024_pixels_low_quality", + "output_cost_per_image_above_1024_and_1024_pixels_medium_quality", + "output_cost_per_image_above_1024_and_1536_pixels_medium_quality", + "output_cost_per_image_above_1536_and_1024_pixels_medium_quality", + "output_cost_per_image_above_1024_and_1024_pixels_high_quality", + "output_cost_per_image_above_1024_and_1536_pixels_high_quality", + "output_cost_per_image_above_1536_and_1024_pixels_high_quality", + "output_cost_per_image_above_1024x1024_pixels_standard_quality", + "output_cost_per_image_above_1024x1536_pixels_standard_quality", + "output_cost_per_image_above_1536x1024_pixels_standard_quality", "input_cost_per_image_token", "output_cost_per_image_token", // Costs - Audio/Video diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index c2aa20d2066..ab45536edd6 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -3714,3 +3714,70 @@ func TestUpsertModelPricesBatch_InputCostPerQuerySurvivesResync(t *testing.T) { require.NotNil(t, found.InputCostPerQuery, "input_cost_per_query missing from pricingSyncUpdateColumns") assert.Equal(t, 0.002, *found.InputCostPerQuery) } + +// TestUpsertModelPricesBatch_SizeQualityImageColumnsSurviveResync pins the +// ON CONFLICT DO UPDATE path: a column missing from pricingSyncUpdateColumns is +// written on the initial Create but silently dropped on every later sync of an +// existing row. +func TestUpsertModelPricesBatch_SizeQualityImageColumnsSurviveResync(t *testing.T) { + s := setupRDBTestStore(t) + require.NoError(t, s.DB().AutoMigrate(&tables.TableModelPricing{})) + + ctx := context.Background() + cost := func(f float64) *float64 { return &f } + + // First sync: the row exists with none of the new rates set. + row := tables.TableModelPricing{Model: "gpt-image-1", Provider: "openai", Mode: "image_generation"} + require.NoError(t, s.UpsertModelPricesBatch(ctx, []tables.TableModelPricing{row})) + + // Second sync of the same row now carries them, exercising the update path. + row.OutputCostPerImageAbove1024x1536Pixels = cost(0.001) + row.OutputCostPerImageAbove1536x1024Pixels = cost(0.002) + row.OutputCostPerImageAbove1024x1024PixelsLowQuality = cost(0.003) + row.OutputCostPerImageAbove1024x1536PixelsLowQuality = cost(0.004) + row.OutputCostPerImageAbove1536x1024PixelsLowQuality = cost(0.005) + row.OutputCostPerImageAbove1024x1024PixelsMediumQuality = cost(0.006) + row.OutputCostPerImageAbove1024x1536PixelsMediumQuality = cost(0.007) + row.OutputCostPerImageAbove1536x1024PixelsMediumQuality = cost(0.008) + row.OutputCostPerImageAbove1024x1024PixelsHighQuality = cost(0.009) + row.OutputCostPerImageAbove1024x1536PixelsHighQuality = cost(0.010) + row.OutputCostPerImageAbove1536x1024PixelsHighQuality = cost(0.011) + row.OutputCostPerImageAbove1024x1024PixelsStandardQuality = cost(0.012) + row.OutputCostPerImageAbove1024x1536PixelsStandardQuality = cost(0.013) + row.OutputCostPerImageAbove1536x1024PixelsStandardQuality = cost(0.014) + require.NoError(t, s.UpsertModelPricesBatch(ctx, []tables.TableModelPricing{row})) + + got, err := s.GetModelPrices(ctx) + require.NoError(t, err) + require.Len(t, got, 1) + updated := got[0] + + require.NotNil(t, updated.OutputCostPerImageAbove1024x1536Pixels, "OutputCostPerImageAbove1024x1536Pixels was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.001, *updated.OutputCostPerImageAbove1024x1536Pixels, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1536x1024Pixels, "OutputCostPerImageAbove1536x1024Pixels was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.002, *updated.OutputCostPerImageAbove1536x1024Pixels, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1024PixelsLowQuality, "OutputCostPerImageAbove1024x1024PixelsLowQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.003, *updated.OutputCostPerImageAbove1024x1024PixelsLowQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1536PixelsLowQuality, "OutputCostPerImageAbove1024x1536PixelsLowQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.004, *updated.OutputCostPerImageAbove1024x1536PixelsLowQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1536x1024PixelsLowQuality, "OutputCostPerImageAbove1536x1024PixelsLowQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.005, *updated.OutputCostPerImageAbove1536x1024PixelsLowQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1024PixelsMediumQuality, "OutputCostPerImageAbove1024x1024PixelsMediumQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.006, *updated.OutputCostPerImageAbove1024x1024PixelsMediumQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1536PixelsMediumQuality, "OutputCostPerImageAbove1024x1536PixelsMediumQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.007, *updated.OutputCostPerImageAbove1024x1536PixelsMediumQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1536x1024PixelsMediumQuality, "OutputCostPerImageAbove1536x1024PixelsMediumQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.008, *updated.OutputCostPerImageAbove1536x1024PixelsMediumQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1024PixelsHighQuality, "OutputCostPerImageAbove1024x1024PixelsHighQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.009, *updated.OutputCostPerImageAbove1024x1024PixelsHighQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1536PixelsHighQuality, "OutputCostPerImageAbove1024x1536PixelsHighQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.010, *updated.OutputCostPerImageAbove1024x1536PixelsHighQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1536x1024PixelsHighQuality, "OutputCostPerImageAbove1536x1024PixelsHighQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.011, *updated.OutputCostPerImageAbove1536x1024PixelsHighQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1024PixelsStandardQuality, "OutputCostPerImageAbove1024x1024PixelsStandardQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.012, *updated.OutputCostPerImageAbove1024x1024PixelsStandardQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1024x1536PixelsStandardQuality, "OutputCostPerImageAbove1024x1536PixelsStandardQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.013, *updated.OutputCostPerImageAbove1024x1536PixelsStandardQuality, 1e-9) + require.NotNil(t, updated.OutputCostPerImageAbove1536x1024PixelsStandardQuality, "OutputCostPerImageAbove1536x1024PixelsStandardQuality was dropped by the ON CONFLICT update (missing from pricingSyncUpdateColumns)") + assert.InDelta(t, 0.014, *updated.OutputCostPerImageAbove1536x1024PixelsStandardQuality, 1e-9) +} diff --git a/framework/configstore/tables/modelpricing.go b/framework/configstore/tables/modelpricing.go index fd5b982ff4b..cd9be06fc26 100644 --- a/framework/configstore/tables/modelpricing.go +++ b/framework/configstore/tables/modelpricing.go @@ -92,6 +92,8 @@ type TableModelPricing struct { OutputCostPerImageAbove512x512PixelsPremium *float64 `gorm:"default:null;column:output_cost_per_image_above_512x512_pixels_premium" json:"output_cost_per_image_above_512_and_512_pixels_and_premium_image,omitempty"` OutputCostPerImageAbove1024x1024Pixels *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1024_pixels" json:"output_cost_per_image_above_1024_and_1024_pixels,omitempty"` OutputCostPerImageAbove1024x1024PixelsPremium *float64 `gorm:"default:null;column:output_cost_per_image_above_1024x1024_pixels_premium" json:"output_cost_per_image_above_1024_and_1024_pixels_and_premium_image,omitempty"` + OutputCostPerImageAbove1024x1536Pixels *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1536_pixels" json:"output_cost_per_image_above_1024_and_1536_pixels,omitempty"` + OutputCostPerImageAbove1536x1024Pixels *float64 `gorm:"default:null;column:output_cost_per_image_above_1536_and_1024_pixels" json:"output_cost_per_image_above_1536_and_1024_pixels,omitempty"` OutputCostPerImageAbove2048x2048Pixels *float64 `gorm:"default:null;column:output_cost_per_image_above_2048_and_2048_pixels" json:"output_cost_per_image_above_2048_and_2048_pixels,omitempty"` OutputCostPerImageAbove4096x4096Pixels *float64 `gorm:"default:null;column:output_cost_per_image_above_4096_and_4096_pixels" json:"output_cost_per_image_above_4096_and_4096_pixels,omitempty"` OutputCostPerImageAbove4Megapixels *float64 `gorm:"default:null;column:output_cost_per_image_above_4_megapixels" json:"output_cost_per_image_above_4_megapixels,omitempty"` @@ -106,6 +108,22 @@ type TableModelPricing struct { InputCostPerImageToken *float64 `gorm:"default:null;column:input_cost_per_image_token" json:"input_cost_per_image_token,omitempty"` OutputCostPerImageToken *float64 `gorm:"default:null;column:output_cost_per_image_token" json:"output_cost_per_image_token,omitempty"` + // Costs - Image, joint size and quality. The standard-quality columns use + // the compact WxH form because the full name exceeds the 63-byte Postgres + // identifier limit. + OutputCostPerImageAbove1024x1024PixelsLowQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1024_pixels_low_quality" json:"output_cost_per_image_above_1024_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsLowQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1536_pixels_low_quality" json:"output_cost_per_image_above_1024_and_1536_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsLowQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1536_and_1024_pixels_low_quality" json:"output_cost_per_image_above_1536_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsMediumQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1024_pixels_medium_quality" json:"output_cost_per_image_above_1024_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsMediumQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1536_pixels_medium_quality" json:"output_cost_per_image_above_1024_and_1536_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsMediumQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1536_and_1024_pixels_medium_quality" json:"output_cost_per_image_above_1536_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsHighQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1024_pixels_high_quality" json:"output_cost_per_image_above_1024_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsHighQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024_and_1536_pixels_high_quality" json:"output_cost_per_image_above_1024_and_1536_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsHighQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1536_and_1024_pixels_high_quality" json:"output_cost_per_image_above_1536_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsStandardQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024x1024_pixels_standard_quality" json:"output_cost_per_image_above_1024_and_1024_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsStandardQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1024x1536_pixels_standard_quality" json:"output_cost_per_image_above_1024_and_1536_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsStandardQuality *float64 `gorm:"default:null;column:output_cost_per_image_above_1536x1024_pixels_standard_quality" json:"output_cost_per_image_above_1536_and_1024_pixels_standard_quality,omitempty"` + // Costs - Audio/Video InputCostPerAudioToken *float64 `gorm:"default:null;column:input_cost_per_audio_token" json:"input_cost_per_audio_token,omitempty"` InputCostPerAudioPerSecond *float64 `gorm:"default:null;column:input_cost_per_audio_per_second" json:"input_cost_per_audio_per_second,omitempty"` diff --git a/framework/modelcatalog/datasheet/cost.go b/framework/modelcatalog/datasheet/cost.go index 8ea3e52b79b..46064167478 100644 --- a/framework/modelcatalog/datasheet/cost.go +++ b/framework/modelcatalog/datasheet/cost.go @@ -1231,16 +1231,18 @@ func computeAudioOutputCost(pricing *configstoreTables.TableModelPricing, usage // computeImageCost handles image generation requests. // Input and output are calculated independently — each tries token-based pricing first, // then per-pixel pricing, falling back to per-image count pricing. -// imageQuality must be one of "low", "medium", "high", "auto" to use quality-specific rates; other values use base rates. +// imageQuality must be one of "low", "medium", "high", "standard", "auto" to use +// quality-specific rates; other values use base rates. func computeImageCost(pricing *configstoreTables.TableModelPricing, imageUsage *schemas.ImageUsage, imageSize string, imageQuality string, tier serviceTier) *schemas.BifrostCost { if imageUsage == nil { return nil } tierTokens := imageInputTierTokens(imageUsage) - pixels := parseImagePixels(imageSize) + width, height := parseImageDimensions(imageSize) + pixels := width * height inputCost := computeImageInputCost(pricing, imageUsage, tierTokens, pixels, tier) - outputCost := computeImageOutputCost(pricing, imageUsage, tierTokens, pixels, imageQuality, tier) + outputCost := computeImageOutputCost(pricing, imageUsage, tierTokens, width, height, pixels, imageQuality, tier) return newInputOutputCost(inputCost, outputCost) } @@ -1275,8 +1277,8 @@ func computeImageInputCost(pricing *configstoreTables.TableModelPricing, imageUs } // computeImageOutputCost calculates output cost: tokens first, then per-pixel, then per-image count fallback. -// imageQuality: "low", "medium", "high", "auto" use quality-specific rates when available; other values use base/size-tier rates. -func computeImageOutputCost(pricing *configstoreTables.TableModelPricing, imageUsage *schemas.ImageUsage, totalTokens int, pixels int, imageQuality string, tier serviceTier) float64 { +// imageQuality: "low", "medium", "high", "standard", "auto" use quality-specific rates when available; other values use base/size-tier rates. +func computeImageOutputCost(pricing *configstoreTables.TableModelPricing, imageUsage *schemas.ImageUsage, totalTokens int, width, height, pixels int, imageQuality string, tier serviceTier) float64 { // Try token-based pricing first var outputTextTokens, outputImageTokens int if imageUsage.OutputTokensDetails != nil { @@ -1306,31 +1308,21 @@ func computeImageOutputCost(pricing *configstoreTables.TableModelPricing, imageU if imageUsage.OutputTokensDetails != nil && imageUsage.OutputTokensDetails.NImages > 0 { numOutputImages = imageUsage.OutputTokensDetails.NImages } - var perImageRate *float64 q := imageQuality if q == "" { q = "auto" } - switch q { - case "low": - if pricing.OutputCostPerImageLowQuality != nil { - perImageRate = pricing.OutputCostPerImageLowQuality - } - case "medium": - if pricing.OutputCostPerImageMediumQuality != nil { - perImageRate = pricing.OutputCostPerImageMediumQuality - } - case "high": - if pricing.OutputCostPerImageHighQuality != nil { - perImageRate = pricing.OutputCostPerImageHighQuality - } - case "auto": - if pricing.OutputCostPerImageAutoQuality != nil { - perImageRate = pricing.OutputCostPerImageAutoQuality - } + // Most specific rate wins: joint size+quality, then quality-only, then + // size-only, then the flat per-image rate. + perImageRate := imageSizeRatesForQuality(pricing, q).rateForSize(width, height, pixels) + if perImageRate == nil { + perImageRate = imageQualityRate(pricing, q) + } + if perImageRate == nil { + perImageRate = baseImageSizeRates(pricing).rateForSize(width, height, pixels) } if perImageRate == nil { - perImageRate = selectImageSizeTierRate(pricing, pixels) + perImageRate = pricing.OutputCostPerImage } if perImageRate != nil { return float64(numOutputImages) * *perImageRate @@ -1339,26 +1331,38 @@ func computeImageOutputCost(pricing *configstoreTables.TableModelPricing, imageU return 0 } -// imageSizeTier is one step of the per-image size-tier ladder: the pixel -// count above which the given rate applies. -type imageSizeTier struct { - pixels int - rate *float64 -} - -// selectImageSizeTierRate picks the flat per-image rate for the given output -// pixel count, from whichever size-tier fields a model's pricing row has -// populated. Two independent tier families exist because providers publish -// resolution-based pricing in different units: some tier by exact -// width×height threshold (output_cost_per_image_above_x_pixels), -// others (e.g. Replicate's upscaler models) tier by total output megapixels -// (output_cost_per_image_above__megapixels). A given model is expected to -// populate only one family; both are checked here, interleaved by their -// actual pixel threshold largest-first, so either (or in principle both) -// resolve correctly without the caller needing to know which unit a model -// uses. Falls back to pricing.OutputCostPerImage (via the nil return) when -// no tier's threshold is met or no tier fields are populated. -func selectImageSizeTierRate(pricing *configstoreTables.TableModelPricing, pixels int) *float64 { +// imageSizeRates holds the per-image output rates for each size threshold: +// either the base set or the set belonging to one image quality. +type imageSizeRates struct { + above512x512 *float64 + above1024x1024 *float64 + above1024x1536 *float64 + above1536x1024 *float64 + above2048x2048 *float64 + above4096x4096 *float64 + above4MP *float64 + above8MP *float64 + above16MP *float64 + above32MP *float64 + above64MP *float64 +} + +// rateForSize returns the rate for the largest size threshold the generated +// image meets, or nil when no matching threshold is configured. +// +// Two independent tier families exist because providers publish resolution-based +// pricing in different units: some tier by exact width×height threshold +// (output_cost_per_image_above_x_pixels), others (e.g. Replicate's upscaler +// models) tier by total output megapixels (output_cost_per_image_above__megapixels). +// A given model is expected to populate only one family; both are checked here, +// interleaved strictly by their actual pixel threshold largest-first — NOT by field +// family — since 4096x4096 (16,777,216px) falls between the 16MP and 32MP megapixel +// thresholds, and 2048x2048 (4,194,304px) falls just above the 4MP threshold. +// +// 1536x1024 and 1024x1536 have identical pixel counts, so they are matched on +// width and height rather than on the total, and are checked ahead of the +// square 1024x1024 threshold that both of them exceed. +func (r imageSizeRates) rateForSize(width, height, pixels int) *float64 { const ( pixels512x512 = 512 * 512 pixels1024x1024 = 1024 * 1024 @@ -1370,28 +1374,98 @@ func selectImageSizeTierRate(pricing *configstoreTables.TableModelPricing, pixel pixels32Megapixels = 32_000_000 pixels64Megapixels = 64_000_000 ) - // Ordered by threshold, largest first, so the first match wins. - // NOTE: ordered strictly by actual pixel threshold, largest first — NOT - // by field family — since 4096x4096 (16,777,216px) falls between the 16MP - // and 32MP megapixel thresholds, and 2048x2048 (4,194,304px) falls just - // above the 4MP threshold. - tiers := []imageSizeTier{ - {pixels64Megapixels, pricing.OutputCostPerImageAbove64Megapixels}, - {pixels32Megapixels, pricing.OutputCostPerImageAbove32Megapixels}, - {pixels4096x4096, pricing.OutputCostPerImageAbove4096x4096Pixels}, - {pixels16Megapixels, pricing.OutputCostPerImageAbove16Megapixels}, - {pixels8Megapixels, pricing.OutputCostPerImageAbove8Megapixels}, - {pixels2048x2048, pricing.OutputCostPerImageAbove2048x2048Pixels}, - {pixels4Megapixels, pricing.OutputCostPerImageAbove4Megapixels}, - {pixels1024x1024, pricing.OutputCostPerImageAbove1024x1024Pixels}, - {pixels512x512, pricing.OutputCostPerImageAbove512x512Pixels}, - } - for _, t := range tiers { - if pixels >= t.pixels && t.rate != nil { - return t.rate - } - } - return pricing.OutputCostPerImage + switch { + case pixels >= pixels64Megapixels && r.above64MP != nil: + return r.above64MP + case pixels >= pixels32Megapixels && r.above32MP != nil: + return r.above32MP + case pixels >= pixels4096x4096 && r.above4096x4096 != nil: + return r.above4096x4096 + case pixels >= pixels16Megapixels && r.above16MP != nil: + return r.above16MP + case pixels >= pixels8Megapixels && r.above8MP != nil: + return r.above8MP + case pixels >= pixels2048x2048 && r.above2048x2048 != nil: + return r.above2048x2048 + case pixels >= pixels4Megapixels && r.above4MP != nil: + return r.above4MP + case width >= 1536 && height >= 1024 && r.above1536x1024 != nil: + return r.above1536x1024 + case width >= 1024 && height >= 1536 && r.above1024x1536 != nil: + return r.above1024x1536 + case pixels >= pixels1024x1024 && r.above1024x1024 != nil: + return r.above1024x1024 + case pixels >= pixels512x512 && r.above512x512 != nil: + return r.above512x512 + } + return nil +} + +// baseImageSizeRates collects the size-threshold rates that apply regardless of +// the requested image quality. +func baseImageSizeRates(pricing *configstoreTables.TableModelPricing) imageSizeRates { + return imageSizeRates{ + above512x512: pricing.OutputCostPerImageAbove512x512Pixels, + above1024x1024: pricing.OutputCostPerImageAbove1024x1024Pixels, + above1024x1536: pricing.OutputCostPerImageAbove1024x1536Pixels, + above1536x1024: pricing.OutputCostPerImageAbove1536x1024Pixels, + above2048x2048: pricing.OutputCostPerImageAbove2048x2048Pixels, + above4096x4096: pricing.OutputCostPerImageAbove4096x4096Pixels, + above4MP: pricing.OutputCostPerImageAbove4Megapixels, + above8MP: pricing.OutputCostPerImageAbove8Megapixels, + above16MP: pricing.OutputCostPerImageAbove16Megapixels, + above32MP: pricing.OutputCostPerImageAbove32Megapixels, + above64MP: pricing.OutputCostPerImageAbove64Megapixels, + } +} + +// imageSizeRatesForQuality collects the size-threshold rates that apply only to +// the given image quality. Returns the zero set for a quality that has no +// size-specific rates. +func imageSizeRatesForQuality(pricing *configstoreTables.TableModelPricing, quality string) imageSizeRates { + switch quality { + case "low": + return imageSizeRates{ + above1024x1024: pricing.OutputCostPerImageAbove1024x1024PixelsLowQuality, + above1024x1536: pricing.OutputCostPerImageAbove1024x1536PixelsLowQuality, + above1536x1024: pricing.OutputCostPerImageAbove1536x1024PixelsLowQuality, + } + case "medium": + return imageSizeRates{ + above1024x1024: pricing.OutputCostPerImageAbove1024x1024PixelsMediumQuality, + above1024x1536: pricing.OutputCostPerImageAbove1024x1536PixelsMediumQuality, + above1536x1024: pricing.OutputCostPerImageAbove1536x1024PixelsMediumQuality, + } + case "high": + return imageSizeRates{ + above1024x1024: pricing.OutputCostPerImageAbove1024x1024PixelsHighQuality, + above1024x1536: pricing.OutputCostPerImageAbove1024x1536PixelsHighQuality, + above1536x1024: pricing.OutputCostPerImageAbove1536x1024PixelsHighQuality, + } + case "standard": + return imageSizeRates{ + above1024x1024: pricing.OutputCostPerImageAbove1024x1024PixelsStandardQuality, + above1024x1536: pricing.OutputCostPerImageAbove1024x1536PixelsStandardQuality, + above1536x1024: pricing.OutputCostPerImageAbove1536x1024PixelsStandardQuality, + } + } + return imageSizeRates{} +} + +// imageQualityRate returns the quality-only per-image rate, independent of the +// generated image's size. "standard" has no quality-only rate upstream. +func imageQualityRate(pricing *configstoreTables.TableModelPricing, quality string) *float64 { + switch quality { + case "low": + return pricing.OutputCostPerImageLowQuality + case "medium": + return pricing.OutputCostPerImageMediumQuality + case "high": + return pricing.OutputCostPerImageHighQuality + case "auto": + return pricing.OutputCostPerImageAutoQuality + } + return nil } // computeVideoCost handles video generation requests. @@ -1775,24 +1849,31 @@ func imageOutputTokens(usage *schemas.ImageUsage) int { return usage.OutputTokens } -// parseImagePixels parses a size string like "1024x1024" into total pixel count. -// Returns 0 if the size string is empty or malformed. -func parseImagePixels(size string) int { +// parseImageDimensions parses a size string like "1024x1024" into its width and +// height. Returns 0, 0 if the size string is empty or malformed. +func parseImageDimensions(size string) (int, int) { if size == "" { - return 0 + return 0, 0 } parts := strings.SplitN(size, "x", 2) if len(parts) != 2 { - return 0 + return 0, 0 } w, err := strconv.Atoi(parts[0]) if err != nil || w <= 0 { - return 0 + return 0, 0 } h, err := strconv.Atoi(parts[1]) if err != nil || h <= 0 { - return 0 + return 0, 0 } + return w, h +} + +// parseImagePixels parses a size string like "1024x1024" into total pixel count. +// Returns 0 if the size string is empty or malformed. +func parseImagePixels(size string) int { + w, h := parseImageDimensions(size) return w * h } diff --git a/framework/modelcatalog/datasheet/cost_test.go b/framework/modelcatalog/datasheet/cost_test.go index 95347deb25b..ae320ac3ae7 100644 --- a/framework/modelcatalog/datasheet/cost_test.go +++ b/framework/modelcatalog/datasheet/cost_test.go @@ -5042,3 +5042,174 @@ func TestCalculateCost_RerankPerTokenStillWorks(t *testing.T) { assert.InDelta(t, 0.00005, s.CalculateCost(resp, nil), 1e-12) } + +// ========================================================================= +// Per-image rates keyed jointly by size and quality +// ========================================================================= + +// makeSizedImageResponse builds an image-generation response carrying the size +// and quality parameters that drive per-image rate selection. +func makeSizedImageResponse(provider schemas.ModelProvider, model, size, quality string, nImages int) *schemas.BifrostResponse { + return &schemas.BifrostResponse{ + ImageGenerationResponse: &schemas.BifrostImageGenerationResponse{ + Usage: &schemas.ImageUsage{ + OutputTokensDetails: &schemas.ImageTokenDetails{NImages: nImages}, + }, + ImageGenerationResponseParameters: &schemas.ImageGenerationResponseParameters{ + Size: size, + Quality: quality, + }, + ExtraFields: schemas.BifrostResponseExtraFields{ + RequestType: schemas.ImageGenerationRequest, + RoutingInfo: routingInfoFor(provider, model), + }, + }, + } +} + +// sizeQualityImagePricing mirrors the gpt-image style datasheet rows: a full +// size x quality matrix plus the quality-agnostic size rates. +func sizeQualityImagePricing() configstoreTables.TableModelPricing { + return configstoreTables.TableModelPricing{ + Model: "gpt-image-1", Provider: "openai", Mode: "image_generation", + + OutputCostPerImageAbove1024x1024PixelsLowQuality: bifrost.Ptr(0.009), + OutputCostPerImageAbove1024x1536PixelsLowQuality: bifrost.Ptr(0.013), + OutputCostPerImageAbove1536x1024PixelsLowQuality: bifrost.Ptr(0.013), + + OutputCostPerImageAbove1024x1024PixelsMediumQuality: bifrost.Ptr(0.034), + OutputCostPerImageAbove1024x1536PixelsMediumQuality: bifrost.Ptr(0.05), + OutputCostPerImageAbove1536x1024PixelsMediumQuality: bifrost.Ptr(0.05), + + OutputCostPerImageAbove1024x1024PixelsHighQuality: bifrost.Ptr(0.133), + OutputCostPerImageAbove1024x1536PixelsHighQuality: bifrost.Ptr(0.2), + OutputCostPerImageAbove1536x1024PixelsHighQuality: bifrost.Ptr(0.2), + + OutputCostPerImageAbove1024x1024PixelsStandardQuality: bifrost.Ptr(0.009), + OutputCostPerImageAbove1024x1536PixelsStandardQuality: bifrost.Ptr(0.013), + OutputCostPerImageAbove1536x1024PixelsStandardQuality: bifrost.Ptr(0.013), + + OutputCostPerImageAbove1024x1024Pixels: bifrost.Ptr(0.009), + OutputCostPerImageAbove1024x1536Pixels: bifrost.Ptr(0.013), + OutputCostPerImageAbove1536x1024Pixels: bifrost.Ptr(0.013), + } +} + +func TestCalculateCost_ImageGeneration_SizeAndQualityRates(t *testing.T) { + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("gpt-image-1", "openai", "image_generation"): sizeQualityImagePricing(), + }) + + tests := []struct { + name string + size string + quality string + nImages int + expected float64 + }{ + {"low 1024x1024", "1024x1024", "low", 1, 0.009}, + {"low 1024x1536", "1024x1536", "low", 1, 0.013}, + {"low 1536x1024", "1536x1024", "low", 1, 0.013}, + {"medium 1024x1024", "1024x1024", "medium", 1, 0.034}, + {"medium 1024x1536", "1024x1536", "medium", 1, 0.05}, + {"medium 1536x1024", "1536x1024", "medium", 1, 0.05}, + {"high 1024x1024", "1024x1024", "high", 1, 0.133}, + {"high 1024x1536", "1024x1536", "high", 1, 0.2}, + {"high 1536x1024", "1536x1024", "high", 1, 0.2}, + {"standard 1024x1024", "1024x1024", "standard", 1, 0.009}, + {"standard 1024x1536", "1024x1536", "standard", 1, 0.013}, + {"standard 1536x1024", "1536x1024", "standard", 1, 0.013}, + // No quality given falls through to the quality-agnostic size rates. + {"unspecified quality 1024x1536", "1024x1536", "", 1, 0.013}, + {"unspecified quality 1536x1024", "1536x1024", "", 1, 0.013}, + {"unspecified quality 1024x1024", "1024x1024", "", 1, 0.009}, + // Rates are per generated image. + {"high 1024x1536 x3", "1024x1536", "high", 3, 0.6}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cost := s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", tc.size, tc.quality, tc.nImages), nil) + assert.InDelta(t, tc.expected, cost, 1e-12) + }) + } +} + +// The 1024x1536 and 1536x1024 thresholds have identical pixel counts, so +// selection must key off width and height, not the total. +func TestCalculateCost_ImageGeneration_OrientationDistinguishesEqualPixelCounts(t *testing.T) { + p := sizeQualityImagePricing() + p.OutputCostPerImageAbove1024x1536PixelsHighQuality = bifrost.Ptr(0.21) + p.OutputCostPerImageAbove1536x1024PixelsHighQuality = bifrost.Ptr(0.19) + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("gpt-image-1", "openai", "image_generation"): p, + }) + + portrait := s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "1024x1536", "high", 1), nil) + landscape := s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "1536x1024", "high", 1), nil) + + assert.InDelta(t, 0.21, portrait, 1e-12) + assert.InDelta(t, 0.19, landscape, 1e-12) +} + +// An image that clears both orientation thresholds is billed at the 1536x1024 +// rate, which rateForSize checks ahead of 1024x1536. +func TestCalculateCost_ImageGeneration_BothOrientationsMatchPrefersLandscape(t *testing.T) { + p := sizeQualityImagePricing() + p.OutputCostPerImageAbove1024x1536PixelsHighQuality = bifrost.Ptr(0.21) + p.OutputCostPerImageAbove1536x1024PixelsHighQuality = bifrost.Ptr(0.19) + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("gpt-image-1", "openai", "image_generation"): p, + }) + + cost := s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "1536x1536", "high", 1), nil) + + assert.InDelta(t, 0.19, cost, 1e-12) +} + +// Joint size+quality rates are more specific than quality-only rates and win +// over them; quality-only still applies when no size+quality rate matches. +func TestCalculateCost_ImageGeneration_SizeQualityBeatsQualityOnly(t *testing.T) { + p := sizeQualityImagePricing() + p.OutputCostPerImageHighQuality = bifrost.Ptr(0.5) + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("gpt-image-1", "openai", "image_generation"): p, + }) + + // 1024x1536 has a high-quality size rate, which wins over the quality-only 0.5. + assert.InDelta(t, 0.2, s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "1024x1536", "high", 1), nil), 1e-12) + // 512x512 has no high-quality size rate, so it falls back to the quality-only rate. + assert.InDelta(t, 0.5, s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "512x512", "high", 1), nil), 1e-12) +} + +// A quality with no configured size rates falls through quality-only, then +// size-only, then the flat per-image rate. +func TestCalculateCost_ImageGeneration_FallsBackThroughRateChain(t *testing.T) { + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("gpt-image-1", "openai", "image_generation"): { + Model: "gpt-image-1", Provider: "openai", Mode: "image_generation", + OutputCostPerImageAbove1536x1024Pixels: bifrost.Ptr(0.013), + OutputCostPerImage: bifrost.Ptr(0.004), + }, + }) + + // No standard-quality rates configured, so the size-only rate applies. + assert.InDelta(t, 0.013, s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "1536x1024", "standard", 1), nil), 1e-12) + // Below every configured threshold, so the flat per-image rate applies. + assert.InDelta(t, 0.004, s.CalculateCost(makeSizedImageResponse("openai", "gpt-image-1", "512x512", "standard", 1), nil), 1e-12) +} + +func TestParseImageDimensions(t *testing.T) { + w, h := parseImageDimensions("1024x1536") + assert.Equal(t, 1024, w) + assert.Equal(t, 1536, h) + + w, h = parseImageDimensions("1536x1024") + assert.Equal(t, 1536, w) + assert.Equal(t, 1024, h) + + for _, bad := range []string{"", "invalid", "1024", "0x1024", "-1x1024"} { + w, h = parseImageDimensions(bad) + assert.Equal(t, 0, w, bad) + assert.Equal(t, 0, h, bad) + } +} diff --git a/framework/modelcatalog/datasheet/overrides.go b/framework/modelcatalog/datasheet/overrides.go index d65185b1861..6663d193827 100644 --- a/framework/modelcatalog/datasheet/overrides.go +++ b/framework/modelcatalog/datasheet/overrides.go @@ -545,6 +545,20 @@ func patchPricing(pricing configstoreTables.TableModelPricing, override Options) {dst: &patched.OutputCostPerImageAbove512x512PixelsPremium, src: override.OutputCostPerImageAbove512x512PixelsPremium}, {dst: &patched.OutputCostPerImageAbove1024x1024Pixels, src: override.OutputCostPerImageAbove1024x1024Pixels}, {dst: &patched.OutputCostPerImageAbove1024x1024PixelsPremium, src: override.OutputCostPerImageAbove1024x1024PixelsPremium}, + {dst: &patched.OutputCostPerImageAbove1024x1536Pixels, src: override.OutputCostPerImageAbove1024x1536Pixels}, + {dst: &patched.OutputCostPerImageAbove1536x1024Pixels, src: override.OutputCostPerImageAbove1536x1024Pixels}, + {dst: &patched.OutputCostPerImageAbove1024x1024PixelsLowQuality, src: override.OutputCostPerImageAbove1024x1024PixelsLowQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1536PixelsLowQuality, src: override.OutputCostPerImageAbove1024x1536PixelsLowQuality}, + {dst: &patched.OutputCostPerImageAbove1536x1024PixelsLowQuality, src: override.OutputCostPerImageAbove1536x1024PixelsLowQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1024PixelsMediumQuality, src: override.OutputCostPerImageAbove1024x1024PixelsMediumQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1536PixelsMediumQuality, src: override.OutputCostPerImageAbove1024x1536PixelsMediumQuality}, + {dst: &patched.OutputCostPerImageAbove1536x1024PixelsMediumQuality, src: override.OutputCostPerImageAbove1536x1024PixelsMediumQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1024PixelsHighQuality, src: override.OutputCostPerImageAbove1024x1024PixelsHighQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1536PixelsHighQuality, src: override.OutputCostPerImageAbove1024x1536PixelsHighQuality}, + {dst: &patched.OutputCostPerImageAbove1536x1024PixelsHighQuality, src: override.OutputCostPerImageAbove1536x1024PixelsHighQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1024PixelsStandardQuality, src: override.OutputCostPerImageAbove1024x1024PixelsStandardQuality}, + {dst: &patched.OutputCostPerImageAbove1024x1536PixelsStandardQuality, src: override.OutputCostPerImageAbove1024x1536PixelsStandardQuality}, + {dst: &patched.OutputCostPerImageAbove1536x1024PixelsStandardQuality, src: override.OutputCostPerImageAbove1536x1024PixelsStandardQuality}, {dst: &patched.OutputCostPerImageAbove2048x2048Pixels, src: override.OutputCostPerImageAbove2048x2048Pixels}, {dst: &patched.OutputCostPerImageAbove4096x4096Pixels, src: override.OutputCostPerImageAbove4096x4096Pixels}, {dst: &patched.OutputCostPerImageAbove4Megapixels, src: override.OutputCostPerImageAbove4Megapixels}, diff --git a/framework/modelcatalog/datasheet/overrides_test.go b/framework/modelcatalog/datasheet/overrides_test.go index 09140763e1e..e7e72e1b0dc 100644 --- a/framework/modelcatalog/datasheet/overrides_test.go +++ b/framework/modelcatalog/datasheet/overrides_test.go @@ -1001,3 +1001,46 @@ func TestPatchPricing_InputCostPerQuery(t *testing.T) { require.NotNil(t, patched.InputCostPerQuery) assert.Equal(t, 0.002, *patched.InputCostPerQuery) } + +func TestPatchPricing_SizeAndQualityImageRates(t *testing.T) { + base := configstoreTables.TableModelPricing{ + Model: "gpt-image-1", + Provider: "openai", + Mode: "image_generation", + OutputCostPerImageAbove1024x1024PixelsHighQuality: bifrost.Ptr(0.133), + OutputCostPerImageAbove1024x1536Pixels: bifrost.Ptr(0.013), + } + + patched := patchPricing(base, Options{ + OutputCostPerImageAbove1024x1536Pixels: bifrost.Ptr(0.015), + OutputCostPerImageAbove1536x1024Pixels: bifrost.Ptr(0.016), + OutputCostPerImageAbove1024x1024PixelsLowQuality: bifrost.Ptr(0.009), + OutputCostPerImageAbove1024x1536PixelsLowQuality: bifrost.Ptr(0.013), + OutputCostPerImageAbove1536x1024PixelsLowQuality: bifrost.Ptr(0.013), + OutputCostPerImageAbove1024x1024PixelsMediumQuality: bifrost.Ptr(0.034), + OutputCostPerImageAbove1024x1536PixelsMediumQuality: bifrost.Ptr(0.05), + OutputCostPerImageAbove1536x1024PixelsMediumQuality: bifrost.Ptr(0.05), + OutputCostPerImageAbove1024x1536PixelsHighQuality: bifrost.Ptr(0.2), + OutputCostPerImageAbove1536x1024PixelsHighQuality: bifrost.Ptr(0.2), + OutputCostPerImageAbove1024x1024PixelsStandardQuality: bifrost.Ptr(0.009), + OutputCostPerImageAbove1024x1536PixelsStandardQuality: bifrost.Ptr(0.013), + OutputCostPerImageAbove1536x1024PixelsStandardQuality: bifrost.Ptr(0.013), + }) + + assert.Equal(t, 0.015, *patched.OutputCostPerImageAbove1024x1536Pixels) + assert.Equal(t, 0.016, *patched.OutputCostPerImageAbove1536x1024Pixels) + assert.Equal(t, 0.009, *patched.OutputCostPerImageAbove1024x1024PixelsLowQuality) + assert.Equal(t, 0.013, *patched.OutputCostPerImageAbove1024x1536PixelsLowQuality) + assert.Equal(t, 0.013, *patched.OutputCostPerImageAbove1536x1024PixelsLowQuality) + assert.Equal(t, 0.034, *patched.OutputCostPerImageAbove1024x1024PixelsMediumQuality) + assert.Equal(t, 0.05, *patched.OutputCostPerImageAbove1024x1536PixelsMediumQuality) + assert.Equal(t, 0.05, *patched.OutputCostPerImageAbove1536x1024PixelsMediumQuality) + assert.Equal(t, 0.2, *patched.OutputCostPerImageAbove1024x1536PixelsHighQuality) + assert.Equal(t, 0.2, *patched.OutputCostPerImageAbove1536x1024PixelsHighQuality) + assert.Equal(t, 0.009, *patched.OutputCostPerImageAbove1024x1024PixelsStandardQuality) + assert.Equal(t, 0.013, *patched.OutputCostPerImageAbove1024x1536PixelsStandardQuality) + assert.Equal(t, 0.013, *patched.OutputCostPerImageAbove1536x1024PixelsStandardQuality) + + // Unpatched fields keep their base values. + assert.Equal(t, 0.133, *patched.OutputCostPerImageAbove1024x1024PixelsHighQuality) +} diff --git a/framework/modelcatalog/datasheet/types.go b/framework/modelcatalog/datasheet/types.go index 93173e44f72..62e88724e07 100644 --- a/framework/modelcatalog/datasheet/types.go +++ b/framework/modelcatalog/datasheet/types.go @@ -167,6 +167,8 @@ type Options struct { OutputCostPerImageAbove512x512PixelsPremium *float64 `json:"output_cost_per_image_above_512_and_512_pixels_and_premium_image,omitempty"` OutputCostPerImageAbove1024x1024Pixels *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels,omitempty"` OutputCostPerImageAbove1024x1024PixelsPremium *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_and_premium_image,omitempty"` + OutputCostPerImageAbove1024x1536Pixels *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels,omitempty"` + OutputCostPerImageAbove1536x1024Pixels *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels,omitempty"` OutputCostPerImageAbove2048x2048Pixels *float64 `json:"output_cost_per_image_above_2048_and_2048_pixels,omitempty"` OutputCostPerImageAbove4096x4096Pixels *float64 `json:"output_cost_per_image_above_4096_and_4096_pixels,omitempty"` OutputCostPerImageAbove4Megapixels *float64 `json:"output_cost_per_image_above_4_megapixels,omitempty"` @@ -181,6 +183,22 @@ type Options struct { InputCostPerImageToken *float64 `json:"input_cost_per_image_token,omitempty"` OutputCostPerImageToken *float64 `json:"output_cost_per_image_token,omitempty"` + // Costs - Image, joint size and quality. These are the most specific + // per-image rates: they win over the quality-only and size-only rates + // above, since upstream prices size and quality together. + OutputCostPerImageAbove1024x1024PixelsLowQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsLowQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsLowQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_low_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsMediumQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_medium_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsHighQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsHighQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsHighQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_high_quality,omitempty"` + OutputCostPerImageAbove1024x1024PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1024_and_1024_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1024x1536PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1024_and_1536_pixels_standard_quality,omitempty"` + OutputCostPerImageAbove1536x1024PixelsStandardQuality *float64 `json:"output_cost_per_image_above_1536_and_1024_pixels_standard_quality,omitempty"` + // Costs - Audio/Video InputCostPerAudioToken *float64 `json:"input_cost_per_audio_token,omitempty"` InputCostPerAudioPerSecond *float64 `json:"input_cost_per_audio_per_second,omitempty"` @@ -687,6 +705,21 @@ func convertEntryToTablePricing(modelKey string, entry Entry) configstoreTables. InputCostPerImageToken: entry.InputCostPerImageToken, OutputCostPerImageToken: entry.OutputCostPerImageToken, + OutputCostPerImageAbove1024x1536Pixels: entry.OutputCostPerImageAbove1024x1536Pixels, + OutputCostPerImageAbove1536x1024Pixels: entry.OutputCostPerImageAbove1536x1024Pixels, + OutputCostPerImageAbove1024x1024PixelsLowQuality: entry.OutputCostPerImageAbove1024x1024PixelsLowQuality, + OutputCostPerImageAbove1024x1536PixelsLowQuality: entry.OutputCostPerImageAbove1024x1536PixelsLowQuality, + OutputCostPerImageAbove1536x1024PixelsLowQuality: entry.OutputCostPerImageAbove1536x1024PixelsLowQuality, + OutputCostPerImageAbove1024x1024PixelsMediumQuality: entry.OutputCostPerImageAbove1024x1024PixelsMediumQuality, + OutputCostPerImageAbove1024x1536PixelsMediumQuality: entry.OutputCostPerImageAbove1024x1536PixelsMediumQuality, + OutputCostPerImageAbove1536x1024PixelsMediumQuality: entry.OutputCostPerImageAbove1536x1024PixelsMediumQuality, + OutputCostPerImageAbove1024x1024PixelsHighQuality: entry.OutputCostPerImageAbove1024x1024PixelsHighQuality, + OutputCostPerImageAbove1024x1536PixelsHighQuality: entry.OutputCostPerImageAbove1024x1536PixelsHighQuality, + OutputCostPerImageAbove1536x1024PixelsHighQuality: entry.OutputCostPerImageAbove1536x1024PixelsHighQuality, + OutputCostPerImageAbove1024x1024PixelsStandardQuality: entry.OutputCostPerImageAbove1024x1024PixelsStandardQuality, + OutputCostPerImageAbove1024x1536PixelsStandardQuality: entry.OutputCostPerImageAbove1024x1536PixelsStandardQuality, + OutputCostPerImageAbove1536x1024PixelsStandardQuality: entry.OutputCostPerImageAbove1536x1024PixelsStandardQuality, + InputCostPerAudioToken: entry.InputCostPerAudioToken, InputCostPerAudioPerSecond: entry.InputCostPerAudioPerSecond, InputCostPerSecond: entry.InputCostPerSecond, @@ -786,6 +819,21 @@ func convertTablePricingToEntry(pricing *configstoreTables.TableModelPricing) *E InputCostPerImageToken: pricing.InputCostPerImageToken, OutputCostPerImageToken: pricing.OutputCostPerImageToken, + OutputCostPerImageAbove1024x1536Pixels: pricing.OutputCostPerImageAbove1024x1536Pixels, + OutputCostPerImageAbove1536x1024Pixels: pricing.OutputCostPerImageAbove1536x1024Pixels, + OutputCostPerImageAbove1024x1024PixelsLowQuality: pricing.OutputCostPerImageAbove1024x1024PixelsLowQuality, + OutputCostPerImageAbove1024x1536PixelsLowQuality: pricing.OutputCostPerImageAbove1024x1536PixelsLowQuality, + OutputCostPerImageAbove1536x1024PixelsLowQuality: pricing.OutputCostPerImageAbove1536x1024PixelsLowQuality, + OutputCostPerImageAbove1024x1024PixelsMediumQuality: pricing.OutputCostPerImageAbove1024x1024PixelsMediumQuality, + OutputCostPerImageAbove1024x1536PixelsMediumQuality: pricing.OutputCostPerImageAbove1024x1536PixelsMediumQuality, + OutputCostPerImageAbove1536x1024PixelsMediumQuality: pricing.OutputCostPerImageAbove1536x1024PixelsMediumQuality, + OutputCostPerImageAbove1024x1024PixelsHighQuality: pricing.OutputCostPerImageAbove1024x1024PixelsHighQuality, + OutputCostPerImageAbove1024x1536PixelsHighQuality: pricing.OutputCostPerImageAbove1024x1536PixelsHighQuality, + OutputCostPerImageAbove1536x1024PixelsHighQuality: pricing.OutputCostPerImageAbove1536x1024PixelsHighQuality, + OutputCostPerImageAbove1024x1024PixelsStandardQuality: pricing.OutputCostPerImageAbove1024x1024PixelsStandardQuality, + OutputCostPerImageAbove1024x1536PixelsStandardQuality: pricing.OutputCostPerImageAbove1024x1536PixelsStandardQuality, + OutputCostPerImageAbove1536x1024PixelsStandardQuality: pricing.OutputCostPerImageAbove1536x1024PixelsStandardQuality, + InputCostPerAudioToken: pricing.InputCostPerAudioToken, InputCostPerAudioPerSecond: pricing.InputCostPerAudioPerSecond, InputCostPerSecond: pricing.InputCostPerSecond, diff --git a/framework/tracing/tracer.go b/framework/tracing/tracer.go index 1b5dc45a754..da466d5d2cb 100644 --- a/framework/tracing/tracer.go +++ b/framework/tracing/tracer.go @@ -170,7 +170,10 @@ func (t *Tracer) SetTraceRequestHeaders(traceID string, headers map[string]strin return } patterns := t.CollectRequestHeaderPatterns() - matched := schemas.FilterHeaders(headers, patterns) + // Redact credential-bearing headers once, here at the single capture point, so + // every connector reading trace.RequestHeaders (Datadog, OTEL, BigQuery, Kafka, + // Pub/Sub) exports redacted values even under a broad pattern like "*". + matched := schemas.RedactSensitiveHeaders(schemas.FilterHeaders(headers, patterns)) if len(matched) == 0 { return } diff --git a/framework/vectorstore/redis.go b/framework/vectorstore/redis.go index ed1ff7471d1..0ab3cf464cb 100644 --- a/framework/vectorstore/redis.go +++ b/framework/vectorstore/redis.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" "github.com/maximhq/bifrost/core/schemas" "github.com/redis/go-redis/v9" @@ -1557,35 +1558,26 @@ func (s *RedisStore) RequiresVectors() bool { } // escapeSearchValue escapes special characters in search values. +// RediSearch treats every punctuation character as special inside a TAG query +// (@field:{value}, DIALECT 2), so escape all non-alphanumeric ASCII characters +// instead of enumerating specials: an unescaped character either fails the +// query with a syntax error (e.g. ":" in "gemma31b-q6:latest") or silently +// matches nothing (e.g. "/" in "openai/gpt-4o"). +// Iterates bytes, not runes, so non-ASCII and even malformed UTF-8 pass +// through byte-for-byte, matching the raw bytes Redis stored. func escapeSearchValue(value string) string { - // Escape special RediSearch characters - replacer := strings.NewReplacer( - "(", "\\(", - ")", "\\)", - "[", "\\[", - "]", "\\]", - "{", "\\{", - "}", "\\}", - "*", "\\*", - "?", "\\?", - "|", "\\|", - "&", "\\&", - "!", "\\!", - "@", "\\@", - "#", "\\#", - "$", "\\$", - "%", "\\%", - "^", "\\^", - "~", "\\~", - "`", "\\`", - "\"", "\\\"", - "'", "\\'", - " ", "\\ ", - "-", "\\-", - ".", "\\.", - ",", "\\,", - ) - return replacer.Replace(value) + var b strings.Builder + b.Grow(len(value) * 2) + for i := 0; i < len(value); i++ { + c := value[i] + isSafe := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c >= utf8.RuneSelf + if !isSafe { + b.WriteByte('\\') + } + b.WriteByte(c) + } + return b.String() } // Binary embedding conversion helpers diff --git a/framework/vectorstore/redis_test.go b/framework/vectorstore/redis_test.go index 481e5e5e160..db90b1b612b 100644 --- a/framework/vectorstore/redis_test.go +++ b/framework/vectorstore/redis_test.go @@ -143,6 +143,9 @@ func (ts *RedisTestSetup) ensureNamespaceExists(t *testing.T) { "content": { DataType: VectorStorePropertyTypeString, }, + "model": { + DataType: VectorStorePropertyTypeString, + }, "response": { DataType: VectorStorePropertyTypeString, }, @@ -826,6 +829,117 @@ func TestBuildRedisQueryCondition_NumericEquality(t *testing.T) { } } +func TestEscapeSearchValue(t *testing.T) { + tests := []struct { + name string + value string + expected string + }{ + { + name: "plain alphanumeric unchanged", + value: "gpt4o_mini", + expected: "gpt4o_mini", + }, + { + name: "colon in ollama-style model tag", + value: "gemma31b-q6:latest", + expected: `gemma31b\-q6\:latest`, + }, + { + name: "slash in provider-prefixed model", + value: "openai/gpt-4o", + expected: `openai\/gpt\-4o`, + }, + { + name: "equals plus and semicolon", + value: "a=b+c;d", + expected: `a\=b\+c\;d`, + }, + { + name: "angle brackets", + value: "ac", + expected: `a\c`, + }, + { + name: "literal backslash", + value: `a\b`, + expected: `a\\b`, + }, + { + name: "space and punctuation", + value: "my model.v1,x", + expected: `my\ model\.v1\,x`, + }, + { + name: "previously covered specials still escaped", + value: `(){}[]*?|&!@#$%^~` + "`" + `"'`, + expected: `\(\)\{\}\[\]\*\?\|\&\!\@\#\$\%\^\~\` + "`" + `\"\'`, + }, + { + name: "non-ascii unchanged", + value: "модель-テスト", + expected: `модель\-テスト`, + }, + { + name: "malformed utf-8 bytes preserved", + value: "a\xffb", + expected: "a\xffb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, escapeSearchValue(tt.value)) + }) + } +} + +func TestBuildRedisQueryCondition_TagValueEscaping(t *testing.T) { + fieldTypes := map[string]VectorStorePropertyType{ + "model": VectorStorePropertyTypeString, + } + + tests := []struct { + name string + query Query + expected string + }{ + { + name: "equal with colon in model tag", + query: Query{ + Field: "model", + Operator: QueryOperatorEqual, + Value: "gemma31b-q6:latest", + }, + expected: `@model:{gemma31b\-q6\:latest}`, + }, + { + name: "not equal with slash-prefixed model", + query: Query{ + Field: "model", + Operator: QueryOperatorNotEqual, + Value: "openai/gpt-4o", + }, + expected: `-@model:{openai\/gpt\-4o}`, + }, + { + name: "contains any escapes each value", + query: Query{ + Field: "model", + Operator: QueryOperatorContainsAny, + Value: []interface{}{"a:b", "c/d"}, + }, + expected: `(@model:{a\:b} | @model:{c\/d})`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, buildRedisQueryCondition(tt.query, fieldTypes)) + }) + } +} + func ptr(v string) *string { return bifrost.Ptr(v) } @@ -1378,6 +1492,41 @@ func TestRedisStore_VectorSearch(t *testing.T) { // Should return fewer results due to high threshold t.Logf("High threshold search returned %d results", len(results)) }) + + t.Run("Vector search with special characters in tag filter", func(t *testing.T) { + // Regression test for #5333: TAG values containing ":" (e.g. Ollama model + // names like "gemma31b-q6:latest") or "/" (provider-prefixed models like + // "openai/gpt-4o") must be escaped or FT.SEARCH fails with a syntax error + // or silently returns no results. + specialDocs := []struct { + key string + embedding []float32 + model string + }{ + {generateUUID(), generateTestEmbedding(RedisTestDimension), "gemma31b-q6:latest"}, + {generateUUID(), generateTestEmbedding(RedisTestDimension), "openai/gpt-4o"}, + } + + for _, doc := range specialDocs { + err := setup.Store.Add(setup.ctx, TestNamespace, doc.key, doc.embedding, map[string]interface{}{ + "type": "tech", + "model": doc.model, + }) + require.NoError(t, err) + } + + time.Sleep(500 * time.Millisecond) + + for _, doc := range specialDocs { + queries := []Query{ + {Field: "model", Operator: QueryOperatorEqual, Value: doc.model}, + } + results, err := setup.Store.GetNearest(setup.ctx, TestNamespace, doc.embedding, queries, []string{"type", "model"}, 0.1, 10) + require.NoError(t, err, "search must not fail for model %q", doc.model) + require.Len(t, results, 1, "expected exactly the doc tagged %q", doc.model) + assert.Equal(t, doc.model, results[0].Properties["model"]) + } + }) } func TestRedisStore_CompleteUseCases(t *testing.T) { diff --git a/plugins/maxim/main.go b/plugins/maxim/main.go index 1071bce0f4e..d368d1f0c9e 100644 --- a/plugins/maxim/main.go +++ b/plugins/maxim/main.go @@ -594,7 +594,9 @@ func (plugin *Plugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas.B var reqHeaders map[string]string if len(plugin.requestHeaders) > 0 { allHeaders, _ := ctx.Value(schemas.BifrostContextKeyRequestHeaders).(map[string]string) - reqHeaders = schemas.FilterHeaders(allHeaders, plugin.requestHeaders) + // Maxim captures from ctx directly (not trace.RequestHeaders), so redact + // credential-bearing headers here before forwarding them as tags. + reqHeaders = schemas.RedactSensitiveHeaders(schemas.FilterHeaders(allHeaders, plugin.requestHeaders)) } hasReqHeaders := len(reqHeaders) > 0 diff --git a/pulse.yaml b/pulse.yaml index db01aadec9b..164aae603a6 100644 --- a/pulse.yaml +++ b/pulse.yaml @@ -10,6 +10,7 @@ services: kill_timeout: 10s proxy: addr: ":${PORT:-8080}" + write_timeout: 900s healthcheck: path: /health interval: 2s diff --git a/ui/app/workspace/custom-pricing/overrides/pricingFields.test.ts b/ui/app/workspace/custom-pricing/overrides/pricingFields.test.ts index 56725a5f36b..f73c32030b0 100644 --- a/ui/app/workspace/custom-pricing/overrides/pricingFields.test.ts +++ b/ui/app/workspace/custom-pricing/overrides/pricingFields.test.ts @@ -84,7 +84,7 @@ describe("pricingFieldUnit", () => { expect(byUnit[unit], `${field.key} resolved to unexpected unit ${unit}`).toBeDefined(); byUnit[unit].push(field.key); } - expect(PRICING_FIELDS).toHaveLength(87); + expect(PRICING_FIELDS).toHaveLength(101); expect(byUnit.multiplier).toEqual(["inference_geo_us_multiplier"]); expect(byUnit.character).toEqual(["input_cost_per_character"]); // Sanity: the split is real, not everything collapsing into one bucket. diff --git a/ui/app/workspace/custom-pricing/overrides/pricingFields.ts b/ui/app/workspace/custom-pricing/overrides/pricingFields.ts index f29b2c622e2..852c3720350 100644 --- a/ui/app/workspace/custom-pricing/overrides/pricingFields.ts +++ b/ui/app/workspace/custom-pricing/overrides/pricingFields.ts @@ -465,6 +465,18 @@ export const PRICING_FIELDS = [ group: "image", requestTypeGroups: ["image"], }, + { + key: "output_cost_per_image_above_1024_and_1536_pixels", + label: "Output / image (>1024x1536px)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1536_and_1024_pixels", + label: "Output / image (>1536x1024px)", + group: "image", + requestTypeGroups: ["image"], + }, { key: "output_cost_per_image_above_2048_and_2048_pixels", label: "Output / image (>2048px)", @@ -531,6 +543,78 @@ export const PRICING_FIELDS = [ group: "image", requestTypeGroups: ["image"], }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels_low_quality", + label: "Output / image (>1024x1024px, low quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1536_pixels_low_quality", + label: "Output / image (>1024x1536px, low quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1536_and_1024_pixels_low_quality", + label: "Output / image (>1536x1024px, low quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels_medium_quality", + label: "Output / image (>1024x1024px, medium quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1536_pixels_medium_quality", + label: "Output / image (>1024x1536px, medium quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1536_and_1024_pixels_medium_quality", + label: "Output / image (>1536x1024px, medium quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels_high_quality", + label: "Output / image (>1024x1024px, high quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1536_pixels_high_quality", + label: "Output / image (>1024x1536px, high quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1536_and_1024_pixels_high_quality", + label: "Output / image (>1536x1024px, high quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1024_pixels_standard_quality", + label: "Output / image (>1024x1024px, standard quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1024_and_1536_pixels_standard_quality", + label: "Output / image (>1024x1536px, standard quality)", + group: "image", + requestTypeGroups: ["image"], + }, + { + key: "output_cost_per_image_above_1536_and_1024_pixels_standard_quality", + label: "Output / image (>1536x1024px, standard quality)", + group: "image", + requestTypeGroups: ["image"], + }, { key: "cache_read_input_image_token_cost", label: "Cache read / image token", diff --git a/ui/app/workspace/logs/page.tsx b/ui/app/workspace/logs/page.tsx index a9072c20de3..6cd945fde1b 100644 --- a/ui/app/workspace/logs/page.tsx +++ b/ui/app/workspace/logs/page.tsx @@ -148,20 +148,20 @@ export default function LogsPage() { cache_hit_types: urlState.cache_hit_types, metadata_filters: urlState.metadata_filters ? (() => { - try { - return JSON.parse(urlState.metadata_filters); - } catch { - return undefined; - } - })() + try { + return JSON.parse(urlState.metadata_filters); + } catch { + return undefined; + } + })() : undefined, // Use a period if present ...(urlState.period ? { period: urlState.period } : { - start_time: dateUtils.toISOString(urlState.start_time), - end_time: dateUtils.toISOString(urlState.end_time), - }), + start_time: dateUtils.toISOString(urlState.start_time), + end_time: dateUtils.toISOString(urlState.end_time), + }), }), // Only re-derive filters when filter-related URL params change (not pagination) [ diff --git a/ui/lib/types/governance.ts b/ui/lib/types/governance.ts index 6f75c3ba4e7..0de6c3af3d8 100644 --- a/ui/lib/types/governance.ts +++ b/ui/lib/types/governance.ts @@ -547,6 +547,8 @@ export interface PricingOverridePatch { output_cost_per_image_above_512_and_512_pixels_and_premium_image?: number; output_cost_per_image_above_1024_and_1024_pixels?: number; output_cost_per_image_above_1024_and_1024_pixels_and_premium_image?: number; + output_cost_per_image_above_1024_and_1536_pixels?: number; + output_cost_per_image_above_1536_and_1024_pixels?: number; output_cost_per_image_above_2048_and_2048_pixels?: number; output_cost_per_image_above_4096_and_4096_pixels?: number; output_cost_per_image_above_4_megapixels?: number; @@ -558,6 +560,18 @@ export interface PricingOverridePatch { output_cost_per_image_medium_quality?: number; output_cost_per_image_high_quality?: number; output_cost_per_image_auto_quality?: number; + output_cost_per_image_above_1024_and_1024_pixels_low_quality?: number; + output_cost_per_image_above_1024_and_1536_pixels_low_quality?: number; + output_cost_per_image_above_1536_and_1024_pixels_low_quality?: number; + output_cost_per_image_above_1024_and_1024_pixels_medium_quality?: number; + output_cost_per_image_above_1024_and_1536_pixels_medium_quality?: number; + output_cost_per_image_above_1536_and_1024_pixels_medium_quality?: number; + output_cost_per_image_above_1024_and_1024_pixels_high_quality?: number; + output_cost_per_image_above_1024_and_1536_pixels_high_quality?: number; + output_cost_per_image_above_1536_and_1024_pixels_high_quality?: number; + output_cost_per_image_above_1024_and_1024_pixels_standard_quality?: number; + output_cost_per_image_above_1024_and_1536_pixels_standard_quality?: number; + output_cost_per_image_above_1536_and_1024_pixels_standard_quality?: number; // Audio/Video input_cost_per_audio_token?: number; input_cost_per_audio_per_second?: number;