Compare commits

...

5 Commits

Author SHA1 Message Date
nityanandagohain
6d1355071e test(llm-observability): keep only the rule-walk and decode cases for pricing patterns
Pattern semantics are covered once in the collector's pkg/llmpricing.

Assisted-by: Claude Fable 5.1
2026-11-02 10:34:04 +05:30
nityanandagohain
dc1b3608f5 refactor(llm-observability): use the collector's llmpricing matcher
Assisted-by: Claude Fable 5.1
2026-11-02 10:29:10 +05:30
nityanandagohain
03184576bf chore(deps): pin signoz-otel-collector to f531882
Pseudo-version until pkg/llmpricing ships in a tag.

Assisted-by: Claude Fable 5.1
2026-11-02 10:29:09 +05:30
Nityananda Gohain
bb1fd30b10 Merge branch 'main' into issue_nerve_517 2026-11-02 09:28:14 +05:30
nityanandagohain
a6627445d0 fix(llm-observability): mirror the collector's bounded prefix match for pricing patterns 2026-10-30 20:16:02 +05:30
6 changed files with 47 additions and 24 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>
);

2
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/clickhouse-go-mock v0.14.0
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/SigNoz/signoz-otel-collector v0.144.16-0.20261102045710-f531882cb133
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0

4
go.sum
View File

@@ -100,8 +100,8 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.6 h1:AgblCIDlLKGtssS4ywvyEByH9fbJ7hFqZvPvUVJKwp8=
github.com/SigNoz/signoz-otel-collector v0.144.6/go.mod h1:YRQw6ETaq7fm8ts4Itgb6wC0bVhc0+nC6PFI0MaiH9k=
github.com/SigNoz/signoz-otel-collector v0.144.16-0.20261102045710-f531882cb133 h1:45SI7q1cTPE/rjDfLevdlTprXHiKcT5YiH5b48WdXCU=
github.com/SigNoz/signoz-otel-collector v0.144.16-0.20261102045710-f531882cb133/go.mod h1:aOTO7iet9Ga9d3v/EkgeSZdSXEkjgVq2L0pK4tjgTEE=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=

View File

@@ -3,9 +3,9 @@ package llmpricingruletypes
import (
"database/sql/driver"
"encoding/json"
"path"
"time"
"github.com/SigNoz/signoz-otel-collector/pkg/llmpricing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types"
@@ -220,18 +220,14 @@ func (u *UpdatableLLMPricingRule) UnmarshalJSON(data []byte) error {
return u.Validate()
}
// Validate mirrors the collector's pattern check: at least one pattern, none
// empty, all valid path.Match globs.
// Validate requires at least one pattern and holds each to the collector's rules.
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)
}
for _, p := range u.ModelPattern {
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 err := llmpricing.ValidatePattern(p); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodePricingRuleInvalidInput, "model %q: %v", u.Model, err)
}
}
return nil
@@ -270,7 +266,7 @@ 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 llmpricing.ParsePattern(pattern).Matches(model) {
return true
}
}

View File

@@ -0,0 +1,32 @@
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"}}
testCases := []struct {
name string
rules []*LLMPricingRule
model string
want bool
}{
{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: "SiblingModel_NoMatch", rules: []*LLMPricingRule{gpt5}, model: "gpt-5-mini-2025-08-07", want: false},
{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

@@ -138,8 +138,7 @@ func TestUpdatableLLMPricingRuleUnmarshalJSON(t *testing.T) {
{name: "missing", pattern: ``, wantErr: true},
{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},
}
for _, tc := range tests {