Compare commits

...

3 Commits

Author SHA1 Message Date
nityanandagohain
1eb3ef40e1 feat: add endpoint for fetching unpriced models 2026-06-17 21:21:28 +05:30
Swapnil Nakade
03796f012f chore: bumping agent version to v0.0.13 (#11757)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-06-17 12:05:28 +00:00
Abhi kumar
a06900bbff chore: added fix for infinite query call on services page (#11755) 2026-06-17 11:17:17 +00:00
12 changed files with 435 additions and 43 deletions

View File

@@ -4889,6 +4889,19 @@ components:
- offset
- limit
type: object
LlmpricingruletypesGettableUnmappedModels:
properties:
items:
items:
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
nullable: true
type: array
total:
type: integer
required:
- items
- total
type: object
LlmpricingruletypesLLMPricingCacheCosts:
properties:
mode:
@@ -4978,6 +4991,19 @@ components:
type: string
nullable: true
type: array
LlmpricingruletypesUnmappedModel:
properties:
modelName:
type: string
provider:
type: string
spanCount:
minimum: 0
type: integer
required:
- modelName
- spanCount
type: object
LlmpricingruletypesUpdatableLLMPricingRule:
properties:
enabled:
@@ -10451,6 +10477,60 @@ paths:
summary: Get a pricing rule
tags:
- llmpricingrules
/api/v1/llm_pricing_rules/unmapped_models:
get:
deprecated: false
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
that no pricing rule pattern matches, so the user can add them to an existing
rule or create a new one.
operationId: ListUnmappedLLMModels
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List unmapped models
tags:
- llmpricingrules
/api/v1/logs/promote_paths:
get:
deprecated: false

View File

@@ -23,6 +23,7 @@ import type {
GetLLMPricingRulePathParameters,
ListLLMPricingRules200,
ListLLMPricingRulesParams,
ListUnmappedLLMModels200,
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -393,3 +394,87 @@ export const invalidateGetLLMPricingRule = async (
return queryClient;
};
/**
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
* @summary List unmapped models
*/
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
url: `/api/v1/llm_pricing_rules/unmapped_models`,
method: 'GET',
signal,
});
};
export const getListUnmappedLLMModelsQueryKey = () => {
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
};
export const getListUnmappedLLMModelsQueryOptions = <
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
> = ({ signal }) => listUnmappedLLMModels(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListUnmappedLLMModelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listUnmappedLLMModels>>
>;
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List unmapped models
*/
export function useListUnmappedLLMModels<
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List unmapped models
*/
export const invalidateListUnmappedLLMModels = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListUnmappedLLMModelsQueryKey() },
options,
);
return queryClient;
};

View File

@@ -6537,6 +6537,33 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
total: number;
}
export interface LlmpricingruletypesUnmappedModelDTO {
/**
* @type string
*/
modelName: string;
/**
* @type string
*/
provider?: string;
/**
* @type integer
* @minimum 0
*/
spanCount: number;
}
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
/**
* @type array,null
*/
items: LlmpricingruletypesUnmappedModelDTO[] | null;
/**
* @type integer
*/
total: number;
}
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
/**
* @type boolean
@@ -9474,6 +9501,14 @@ export type GetLLMPricingRule200 = {
status: string;
};
export type ListUnmappedLLMModels200 = {
data: LlmpricingruletypesGettableUnmappedModelsDTO;
/**
* @type string
*/
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { FeatureKeys } from 'constants/features';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -124,24 +123,14 @@ function ServiceOverview({
/>
<Card data-testid="service_latency">
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
onDragSelect={onDragSelect}
widget={latencyWidget}
onClickHandler={handleGraphClick('Service')}
isQueryEnabled={isQueryEnabled}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
</Card>
</>

View File

@@ -1,4 +1,3 @@
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -29,24 +28,14 @@ function TopLevelOperation({
</Typography>
) : (
<GraphContainer>
{topLevelOperationsIsLoading && (
<Skeleton
style={{
height: '100%',
padding: '16px',
}}
/>
)}
{!topLevelOperationsIsLoading && (
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
)}
<Graph
widget={widget}
onClickHandler={handleGraphClick(opName)}
onDragSelect={onDragSelect}
isQueryEnabled={!topLevelOperationsIsLoading}
version={ENTITY_VERSION_V4}
enableDrillDown={SERVICE_DETAIL_DRILLDOWN_ENABLED}
/>
</GraphContainer>
)}
</Card>

View File

@@ -49,6 +49,26 @@ func (provider *provider) addLLMPricingRuleRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/unmapped_models", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.ListUnmappedModels),
handler.OpenAPIDef{
ID: "ListUnmappedLLMModels",
Tags: []string{"llmpricingrules"},
Summary: "List unmapped models",
Description: "Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.",
Request: nil,
RequestContentType: "",
Response: new(llmpricingruletypes.GettableUnmappedModels),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/llm_pricing_rules/{id}", handler.New(
provider.authzMiddleware.ViewAccess(provider.llmPricingRuleHandler.Get),
handler.OpenAPIDef{

View File

@@ -22,7 +22,7 @@ func newConfig() factory.Config {
Agent: AgentConfig{
// we will maintain the latest version of cloud integration agent from here,
// till we automate it externally or figure out a way to validate it.
Version: "v0.0.12",
Version: "v0.0.13",
},
}
}

View File

@@ -118,6 +118,28 @@ func (h *handler) CreateOrUpdate(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusNoContent, nil)
}
// ListUnmappedModels handles GET /api/v1/llm_pricing_rules/unmapped_models.
func (h *handler) ListUnmappedModels(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
models, err := h.module.ListUnmappedModels(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, llmpricingruletypes.NewGettableUnmappedModels(models))
}
// Delete handles DELETE /api/v1/llm_pricing_rules/{id}.
func (h *handler) Delete(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)

View File

@@ -3,22 +3,30 @@ package impllmpricingrule
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// unmappedModelsLookback is the trace data window scanned to discover models in use.
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
store llmpricingruletypes.Store
querier querier.Querier
}
func NewModule(store llmpricingruletypes.Store) llmpricingrule.Module {
return &module{store: store}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -29,6 +37,28 @@ func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID
return module.store.Get(ctx, orgID, id)
}
// ListUnmappedModels discovers the models present in the last hour of trace data
// (gen_ai.request.model) and returns the ones that no pricing rule pattern matches.
func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
models, err := module.discoverModels(ctx, orgID)
if err != nil {
return nil, err
}
rules, _, err := module.store.List(ctx, orgID, 0, 10000)
if err != nil {
return nil, err
}
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
}
return unmapped, nil
}
// CreateOrUpdate applies a batch of pricing rule changes:
// - ID set → match by id, overwrite fields.
// - SourceID set → match by source_id; if found overwrite, else insert.
@@ -135,3 +165,108 @@ func (module *module) findExisting(ctx context.Context, orgID valuer.UUID, u *ll
return nil, errors.Newf(errors.TypeNotFound, llmpricingruletypes.ErrCodePricingRuleNotFound, "rule has neither id nor sourceId")
}
}
// discoverModels runs a QBv5 traces aggregation grouped by gen_ai.request.model
// over the lookback window and returns each distinct model with its span count.
func (module *module) discoverModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error) {
now := time.Now()
req := &qbtypes.QueryRangeRequest{
Start: uint64(now.Add(-unmappedModelsLookback).UnixMilli()),
End: uint64(now.UnixMilli()),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: fmt.Sprintf("%s EXISTS", llmpricingruletypes.GenAIRequestModel)},
Aggregations: []qbtypes.TraceAggregation{
{Expression: "count()", Alias: "spanCount"},
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIRequestModel,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: llmpricingruletypes.GenAIProviderName,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
},
Limit: 1000,
},
},
},
},
}
resp, err := module.querier.QueryRange(ctx, orgID, req)
if err != nil {
return nil, err
}
return parseModels(resp), nil
}
// parseModels extracts the grouped model names and their span counts from a scalar response.
func parseModels(resp *qbtypes.QueryRangeResponse) []*llmpricingruletypes.UnmappedModel {
if resp == nil || len(resp.Data.Results) == 0 {
return nil
}
sd, ok := resp.Data.Results[0].(*qbtypes.ScalarData)
if !ok || sd == nil {
return nil
}
modelIdx, providerIdx, countIdx := -1, -1, -1
for i, c := range sd.Columns {
switch c.Type {
case qbtypes.ColumnTypeGroup:
switch c.Name {
case llmpricingruletypes.GenAIRequestModel:
modelIdx = i
case llmpricingruletypes.GenAIProviderName:
providerIdx = i
}
case qbtypes.ColumnTypeAggregation:
countIdx = i
}
}
if modelIdx == -1 {
return nil
}
models := make([]*llmpricingruletypes.UnmappedModel, 0, len(sd.Data))
for _, row := range sd.Data {
name, _ := row[modelIdx].(string)
if name == "" {
continue
}
provider := ""
if providerIdx != -1 {
provider, _ = row[providerIdx].(string)
}
models = append(models, &llmpricingruletypes.UnmappedModel{ModelName: name, Provider: provider, SpanCount: toUint64(row, countIdx)})
}
return models
}
func toUint64(row []any, idx int) uint64 {
if idx < 0 || idx >= len(row) {
return 0
}
switch v := row[idx].(type) {
case uint64:
return v
case int64:
return uint64(v)
case float64:
return uint64(v)
default:
return 0
}
}

View File

@@ -17,6 +17,7 @@ type Module interface {
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)
Delete(ctx context.Context, orgID, id valuer.UUID) error
ListUnmappedModels(ctx context.Context, orgID valuer.UUID) ([]*llmpricingruletypes.UnmappedModel, error)
}
// Handler defines the HTTP handler interface for pricing rule endpoints.
@@ -25,4 +26,5 @@ type Handler interface {
Get(rw http.ResponseWriter, r *http.Request)
CreateOrUpdate(rw http.ResponseWriter, r *http.Request)
Delete(rw http.ResponseWriter, r *http.Request)
ListUnmappedModels(rw http.ResponseWriter, r *http.Request)
}

View File

@@ -155,7 +155,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,
}
}

View File

@@ -3,6 +3,7 @@ package llmpricingruletypes
import (
"database/sql/driver"
"encoding/json"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -16,6 +17,7 @@ const (
LLMCostFeatureType agentConf.AgentFeatureType = "llm_pricing"
GenAIRequestModel = "gen_ai.request.model"
GenAIProviderName = "gen_ai.provider.name"
GenAIUsageInputTokens = "gen_ai.usage.input_tokens"
GenAIUsageOutputTokens = "gen_ai.usage.output_tokens"
GenAIUsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens"
@@ -136,6 +138,32 @@ type GettablePricingRules struct {
Limit int `json:"limit" required:"true"`
}
// UnmappedModel is a model observed in trace data (gen_ai.request.model) that
// no pricing rule pattern matches, so no cost is being computed for it.
type UnmappedModel struct {
ModelName string `json:"modelName" required:"true"`
Provider string `json:"provider"`
SpanCount uint64 `json:"spanCount" required:"true"`
}
type GettableUnmappedModels struct {
Items []*UnmappedModel `json:"items" required:"true"`
Total int `json:"total" required:"true"`
}
// ModelMatchesAnyRule reports whether model matches any rule's glob pattern,
// mirroring the path.Match semantics the signozllmpricing OTel processor uses.
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 {
return true
}
}
}
return false
}
func (LLMPricingRuleUnit) Enum() []any {
return []any{UnitPerMillionTokens}
}
@@ -204,6 +232,13 @@ func NewGettableLLMPricingRulesFromLLMPricingRules(items []*LLMPricingRule, tota
}
}
func NewGettableUnmappedModels(items []*UnmappedModel) *GettableUnmappedModels {
return &GettableUnmappedModels{
Items: items,
Total: len(items),
}
}
func NewLLMPricingRuleFromUpdatable(u *UpdatableLLMPricingRule, orgID valuer.UUID, userEmail string, now time.Time) *LLMPricingRule {
isOverride := true
if u.IsOverride != nil {