Compare commits

...

1 Commits

Author SHA1 Message Date
nityanandagohain
a6627445d0 fix(llm-observability): mirror the collector's bounded prefix match for pricing patterns 2026-10-30 20:16:02 +05:30
4 changed files with 72 additions and 16 deletions

View File

@@ -13,7 +13,7 @@ interface PatternEditorProps {
onChange: (patterns: string[]) => void;
}
// Model-name prefix patterns as removable chips + an add input.
// Model-name patterns as removable chips + an add input.
function PatternEditor({
patterns,
isReadOnly,
@@ -37,12 +37,7 @@ function PatternEditor({
return (
<div className={styles.drawerSection}>
<span className={styles.fieldLabel}>
Model name patterns{' '}
<Typography.Text as="span" color="muted">
(prefix match)
</Typography.Text>
</span>
<span className={styles.fieldLabel}>Model name patterns</span>
<div className={styles.patternBox}>
{patterns.length > 0 && (
<div className={styles.patternChips}>
@@ -53,7 +48,7 @@ function PatternEditor({
color="secondary"
variant="outlined"
>
{pattern}*
{pattern}
{!isReadOnly && (
<button
type="button"
@@ -95,8 +90,9 @@ function PatternEditor({
)}
</div>
<Typography.Text as="p" size="small" color="muted">
Each pattern uses <strong>prefix matching</strong> against{' '}
<code>gen_ai.request.model</code>.
A pattern matches <code>gen_ai.request.model</code> exactly, plus its dated
or platform variants such as <code>-20260921</code>, <code>-latest</code> or{' '}
<code>@20260921</code>. End it with <code>*</code> to match any suffix.
</Typography.Text>
</div>
);

View File

@@ -3,7 +3,8 @@ package llmpricingruletypes
import (
"database/sql/driver"
"encoding/json"
"path"
"regexp"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -25,6 +26,10 @@ var (
ErrCodeBuildPricingProcessorConf = errors.MustNewCode("build_pricing_processor_config")
)
// modelSnapshotSuffix mirrors the collector's signozllmpricing matcher: dated
// snapshots, "-latest", Bedrock "-vN:M" and Vertex "@" versions, in any combination.
var modelSnapshotSuffix = regexp.MustCompile(`^(?:-\d{4}-\d{2}-\d{2}|-\d{2}-\d{4}|-\d{2}-\d{2}|-\d{3,8}|-latest|-v\d+(?::\d+)?|@\d{8}|@default)+$`)
type LLMPricingRuleUnit struct {
valuer.String
}
@@ -221,7 +226,7 @@ func (u *UpdatableLLMPricingRule) UnmarshalJSON(data []byte) error {
}
// Validate mirrors the collector's pattern check: at least one pattern, none
// empty, all valid path.Match globs.
// empty, "*" only as a trailing wildcard.
func (u *UpdatableLLMPricingRule) Validate() error {
if len(u.ModelPattern) == 0 {
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern must contain at least one pattern", u.Model)
@@ -230,8 +235,8 @@ func (u *UpdatableLLMPricingRule) Validate() error {
if p == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern must not contain an empty pattern", u.Model)
}
if _, err := path.Match(p, ""); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern %q is not a valid glob", u.Model, p)
if strings.Contains(strings.TrimSuffix(p, "*"), "*") {
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: modelPattern %q may only use \"*\" at the end", u.Model, p)
}
}
return nil
@@ -270,10 +275,21 @@ func NewLLMPricingRuleFromUpdatable(u *UpdatableLLMPricingRule, orgID valuer.UUI
func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
for _, r := range rules {
for _, pattern := range r.ModelPattern {
if ok, err := path.Match(pattern, model); err == nil && ok {
if patternMatchesModel(pattern, model) {
return true
}
}
}
return false
}
// patternMatchesModel accepts the pattern itself or the pattern followed by snapshot
// suffixes only; a trailing "*" accepts any suffix.
func patternMatchesModel(pattern, model string) bool {
prefix := strings.TrimRight(pattern, "*")
rest, ok := strings.CutPrefix(model, prefix)
if !ok {
return false
}
return rest == "" || prefix != pattern || modelSnapshotSuffix.MatchString(rest)
}

View File

@@ -0,0 +1,41 @@
package llmpricingruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestModelMatchesAnyRule(t *testing.T) {
opus5 := &LLMPricingRule{ModelPattern: StringSlice{"anthropic/claude-opus-5", "claude-opus-5"}}
opus55 := &LLMPricingRule{ModelPattern: StringSlice{"anthropic/claude-opus-5.5", "claude-opus-5.5", "claude-opus-5-5"}}
gpt5 := &LLMPricingRule{ModelPattern: StringSlice{"openai/gpt-5", "gpt-5"}}
anyGPT4o := &LLMPricingRule{ModelPattern: StringSlice{"gpt-4o*"}}
testCases := []struct {
name string
rules []*LLMPricingRule
model string
want bool
}{
{name: "ExactModel", rules: []*LLMPricingRule{opus55}, model: "claude-opus-5-5", want: true},
{name: "ProviderQualifiedModel", rules: []*LLMPricingRule{opus55}, model: "anthropic/claude-opus-5.5", want: true},
{name: "DatedSnapshot", rules: []*LLMPricingRule{opus55}, model: "claude-opus-5-5-20260921", want: true},
{name: "DashedDate", rules: []*LLMPricingRule{gpt5}, model: "gpt-5-2025-08-07", want: true},
{name: "LatestAlias", rules: []*LLMPricingRule{opus55}, model: "claude-opus-5-5-latest", want: true},
{name: "BedrockVersion", rules: []*LLMPricingRule{opus55}, model: "claude-opus-5-5-20260921-v1:0", want: true},
{name: "VertexVersion", rules: []*LLMPricingRule{opus55}, model: "claude-opus-5-5@20260921", want: true},
{name: "MinorVersionBump_NoMatch", rules: []*LLMPricingRule{opus5}, model: "claude-opus-5-5", want: false},
{name: "SiblingModel_NoMatch", rules: []*LLMPricingRule{gpt5}, model: "gpt-5-mini-2025-08-07", want: false},
{name: "DifferentCase_NoMatch", rules: []*LLMPricingRule{gpt5}, model: "GPT-5", want: false},
{name: "TrailingStar_MatchesAnySuffix", rules: []*LLMPricingRule{anyGPT4o}, model: "gpt-4o-mini", want: true},
{name: "SecondRuleMatches", rules: []*LLMPricingRule{opus5, opus55}, model: "claude-opus-5-5", want: true},
{name: "NoRules", rules: nil, model: "claude-opus-5-5", want: false},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, ModelMatchesAnyRule(testCase.model, testCase.rules))
})
}
}

View File

@@ -139,7 +139,10 @@ func TestUpdatableLLMPricingRuleUnmarshalJSON(t *testing.T) {
{name: "null", pattern: `null`, wantErr: true},
{name: "empty_list", pattern: `[]`, wantErr: true},
{name: "empty_entry", pattern: `["gpt-4o*", ""]`, wantErr: true},
{name: "bad_glob", pattern: `["gpt-["]`, wantErr: true},
{name: "mid_star", pattern: `["gpt-*-turbo"]`, wantErr: true},
{name: "double_star", pattern: `["gpt-4o**"]`, wantErr: true},
{name: "star_only", pattern: `["*"]`},
{name: "bracket_literal", pattern: `["claude-sonnet-4-5[1m]"]`},
}
for _, tc := range tests {