Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/deck/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func DefaultPricing() PricingTable {
"claude-sonnet-4": {Input: 3.00, Output: 15.00, CacheRead: 0.30, CacheWrite: 3.75},
"claude-sonnet-3.7": {Input: 3.00, Output: 15.00, CacheRead: 0.30, CacheWrite: 3.75},
"claude-haiku-4.5": {Input: 1.00, Output: 5.00, CacheRead: 0.10, CacheWrite: 1.25},
"claude-haiku-4.6": {Input: 1.00, Output: 5.00, CacheRead: 0.10, CacheWrite: 1.25},
"claude-3.5-sonnet": {Input: 3.00, Output: 15.00, CacheRead: 0.30, CacheWrite: 3.75},
"claude-3.5-haiku": {Input: 0.80, Output: 4.00, CacheRead: 0.08, CacheWrite: 1.00},
"claude-3-opus": {Input: 15.00, Output: 75.00, CacheRead: 1.50, CacheWrite: 18.75},
Expand Down
13 changes: 13 additions & 0 deletions pkg/deck/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ var _ = Describe("PricingForModel", func() {
Expect(p.CacheRead).To(Equal(0.14))
})

It("resolves claude-haiku-4.6", func() {
p, ok := PricingForModel(pricing, "claude-haiku-4.6")
Expect(ok).To(BeTrue())
Expect(p.Input).To(Equal(1.00))
Expect(p.Output).To(Equal(5.00))
})

It("resolves claude-haiku-4-6 with date suffix", func() {
p, ok := PricingForModel(pricing, "claude-haiku-4-6-20260219")
Expect(ok).To(BeTrue())
Expect(p.Input).To(Equal(1.00))
})

It("returns false for unknown models", func() {
_, ok := PricingForModel(pricing, "totally-unknown-model")
Expect(ok).To(BeFalse())
Expand Down
5 changes: 5 additions & 0 deletions pkg/deck/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,7 @@ func (q *Query) buildSessionSummaryFromNodes(nodes []*ent.Node) (SessionSummary,

hasToolError := false
hasGitActivity := false
var lastModel string
for _, n := range nodes {
blocks, _ := parseContentBlocks(n.Content)
toolCalls += countToolCalls(blocks)
Expand All @@ -782,9 +783,13 @@ func (q *Query) buildSessionSummaryFromNodes(nodes []*ent.Node) (SessionSummary,
outputTokens += t.Output

model := normalizeModel(n.Model)
if model == "" {
model = lastModel
}
if model == "" {
continue
}
lastModel = model

pricing, ok := PricingForModel(q.pricing, model)
if !ok {
Expand Down
60 changes: 60 additions & 0 deletions pkg/deck/query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,66 @@ var _ = Describe("Session labels", func() {
})
})

var _ = Describe("Empty-model cost fallback", func() {
intPtr := func(v int) *int { return &v }

It("uses last-seen model for response nodes with empty model", func() {
pricing := DefaultPricing()
q := &Query{pricing: pricing}

nodes := []*ent.Node{
{
ID: "node-1",
Role: "user",
Model: "claude-opus-4-6-20260219",
Content: []map[string]any{{
"text": "Hello",
"type": "text",
}},
PromptTokens: intPtr(100),
CompletionTokens: intPtr(0),
},
{
ID: "node-2",
Role: "assistant",
Model: "", // empty model — the bug
Content: []map[string]any{{"text": "Hi!", "type": "text"}},
PromptTokens: intPtr(0),
CompletionTokens: intPtr(50),
},
}

summary, modelCosts, _, err := q.buildSessionSummaryFromNodes(nodes)
Expect(err).NotTo(HaveOccurred())

// The assistant node should have been costed using the user node's model
Expect(summary.TotalCost).To(BeNumerically(">", 0))
Expect(modelCosts).To(HaveKey("claude-opus-4.6"))
cost := modelCosts["claude-opus-4.6"]
Expect(cost.OutputTokens).To(Equal(int64(50)))
Expect(cost.TotalCost).To(BeNumerically(">", 0))
})

It("skips nodes when no model has been seen yet", func() {
pricing := DefaultPricing()
q := &Query{pricing: pricing}

nodes := []*ent.Node{
{
ID: "node-1",
Role: "assistant",
Model: "", // no model, and no prior model
Content: []map[string]any{{"text": "orphan", "type": "text"}},
CompletionTokens: intPtr(50),
},
}

_, modelCosts, _, err := q.buildSessionSummaryFromNodes(nodes)
Expect(err).NotTo(HaveOccurred())
Expect(modelCosts).To(BeEmpty())
})
})

var _ = Describe("Analytics helper functions", func() {
Describe("buildDurationBuckets", func() {
It("distributes sessions into correct duration buckets", func() {
Expand Down
3 changes: 3 additions & 0 deletions proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ func (p *Proxy) enqueueStreamedResponse(allChunks [][]byte, fullContent string,

finalResp := p.reconstructStreamedResponse(allChunks, fullContent, streamUsage, meta, prov)
if finalResp != nil {
if finalResp.Model == "" && parsedReq.Model != "" {
finalResp.Model = parsedReq.Model
}
p.workerPool.Enqueue(worker.Job{
Provider: prov.Name(),
AgentName: agentName,
Expand Down