Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d54db9c
feat(pricing): add embedded model pricing tables with settings overrides
suhaanthayyil Jul 9, 2026
7949f0e
feat(agent): estimate per-model session cost and persist it in checkp…
suhaanthayyil Jul 9, 2026
1e67328
feat(tokens): show stored cost in session, checkpoint, and profile co…
suhaanthayyil Jul 9, 2026
a85618e
feat(pricing): cover the full Claude lineup and provider id forms
suhaanthayyil Jul 9, 2026
fb9e799
feat(pricing): resolve Bedrock global profiles, slash-dated, and Vert…
suhaanthayyil Jul 9, 2026
0887828
fix(agent): carry api_call_count through the remainder bucket
suhaanthayyil Jul 9, 2026
9e6846a
fix(strategy): price fallback buckets with the session model at conde…
suhaanthayyil Jul 9, 2026
73dbbea
fix(pricing): harden override merge, alias validation, and non-Anthro…
suhaanthayyil Jul 9, 2026
573716d
fix(agent): keep the mixed cost-source signal across session merges
suhaanthayyil Jul 9, 2026
3469027
fix(agent): price subagent usage and stop aliasing the subagent subtree
suhaanthayyil Jul 9, 2026
1bbdc1b
fix(pricing): inherit provider and cache rates when an override omits…
suhaanthayyil Jul 9, 2026
e37df9a
fix(agent): price with the backfilled model and skip zero-token buckets
suhaanthayyil Jul 10, 2026
ee31e39
fix(strategy): reprice only on real re-extracted tokens and repoint s…
suhaanthayyil Jul 10, 2026
4b14f6a
feat(pricing): add the GPT-5.6 family (Sol, Terra, Luna)
suhaanthayyil Jul 10, 2026
a8377d9
refactor(strategy): extract the model backfill-and-reprice into a helper
suhaanthayyil Jul 10, 2026
7c21be3
fix(pricing): correct GPT-5.5/5.4 rates and add fast/priority/cursor …
suhaanthayyil Jul 10, 2026
ef129e3
feat(claudecode): detect fast-mode turns and price the fast variant
suhaanthayyil Jul 10, 2026
0bcfd15
feat(pricing): opt-in Codex priority-tier pricing
suhaanthayyil Jul 10, 2026
605aa99
feat(pricing): merge a cached remote pricing table (read path + gating)
suhaanthayyil Jul 10, 2026
e9489ed
feat(pricing): daily remote pricing refresh (detached, hook-safe)
suhaanthayyil Jul 10, 2026
626261e
fix(pricing): apply codex service tier at condensation repricing
suhaanthayyil Jul 10, 2026
ea09fd1
fix(pricing): add composer-2.5 standard rates
suhaanthayyil Jul 10, 2026
ef00692
fix(pricing): add current Gemini rates
suhaanthayyil Jul 10, 2026
ea3ef2b
fix(telemetry): run detached pricing refresh in the project directory
suhaanthayyil Jul 10, 2026
8b9cc07
fix(pricing): retarget calculator buckets onto tier-variant pricing ids
suhaanthayyil Jul 10, 2026
c6c64ac
fix(pricing): downgrade unpriceable tier variants to the base model
suhaanthayyil Jul 10, 2026
1e81204
fix(pricing): downgrade unpriceable fast variants to the base model
suhaanthayyil Jul 10, 2026
5d536dc
Merge remote-tracking branch 'origin/main' into feat/claudecode-cost
suhaanthayyil Jul 13, 2026
60075a9
fix(cost): exclude APICallCount from remainder-bucket emptiness check
suhaanthayyil Jul 14, 2026
154dc2a
fix(cost): reprice Copilot session-state diagnostic total on model re…
suhaanthayyil Jul 14, 2026
e9fed69
docs(pricing): document the opt-in remote pricing refresh layer
suhaanthayyil Jul 14, 2026
bafcb60
feat(tokens): compute cost display locally as an estimate
suhaanthayyil Jul 15, 2026
1f83d7d
refactor(cost): stop persisting cost in the CLI; platform now owns it
suhaanthayyil Jul 15, 2026
521b561
Merge origin/main into feat/cli-cost-tokens-only
suhaanthayyil Jul 16, 2026
2da0b23
fix(cost): align session cost scope, harden pricing overrides, count …
suhaanthayyil Jul 16, 2026
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
68 changes: 68 additions & 0 deletions api/checkpoint/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ type WriteOptions struct {
// TokenUsage contains the token usage for this checkpoint
TokenUsage *types.TokenUsage

// ModelUsage is the per-model breakdown of TokenUsage for this checkpoint.
ModelUsage []types.ModelUsage

// SkillEvents records explicit native skill signals observed in this session.
SkillEvents []types.SkillEvent

Expand Down Expand Up @@ -371,6 +374,12 @@ type Metadata struct {
// Token usage for this checkpoint
TokenUsage *types.TokenUsage `json:"token_usage,omitempty"`

// ModelUsage is the per-model breakdown of TokenUsage for this checkpoint. Each
// entry pairs a model identifier with its token usage. Backends ingest this as
// the canonical per-model shape ("model" + nested "token_usage"). Omitted when
// no per-model data is available.
ModelUsage []types.ModelUsage `json:"model_usage,omitempty"`

// SkillEvents records explicit native skill signals observed in this session.
// Consumers use these anchors to collapse skill-related raw transcript events.
SkillEventsVersion int `json:"skill_events_version,omitempty"`
Expand Down Expand Up @@ -433,6 +442,64 @@ func (m Metadata) GetCompactTranscriptStart() (offset int, ok bool) {
return *m.CompactTranscriptStart, true
}

// WithoutCost returns a copy of the session Metadata with all token-usage cost
// provenance removed (the flat TokenUsage, every per-model ModelUsage bucket, and
// each nested SubagentTokens subtree). Token counts, APICallCount, model ids,
// SessionMetrics, and every other field are preserved.
//
// The CLI no longer persists cost: entire-api prices server-side from the token
// breakdown, so cost is a display-only local estimate (see the token commands).
// Every checkpoint-metadata write funnels through this so no committed blob ever
// carries cost_usd/cost_source, while keeping the four token fields + model id
// per model that the platform's server-side pricing depends on. The returned copy
// never aliases the receiver's TokenUsage/ModelUsage, so callers can keep using
// the originals (e.g. for local diagnostics) after persisting.
func (m Metadata) WithoutCost() Metadata {
m.TokenUsage = tokenUsageWithoutCost(m.TokenUsage)
m.ModelUsage = modelUsageWithoutCost(m.ModelUsage)
return m
}

// WithoutCost returns a copy of the root CheckpointSummary with all token-usage
// cost provenance removed. See Metadata.WithoutCost.
func (s CheckpointSummary) WithoutCost() CheckpointSummary {
s.TokenUsage = tokenUsageWithoutCost(s.TokenUsage)
s.ModelUsage = modelUsageWithoutCost(s.ModelUsage)
return s
}

// tokenUsageWithoutCost deep-copies u with its cost fields cleared at every level
// (flat plus the whole SubagentTokens subtree), preserving the token counts and
// APICallCount. Returns nil for nil so an absent usage stays absent.
func tokenUsageWithoutCost(u *types.TokenUsage) *types.TokenUsage {
if u == nil {
return nil
}
c := *u
c.CostUSD = nil
c.CostSource = ""
c.SubagentTokens = tokenUsageWithoutCost(u.SubagentTokens)
return &c
}

// modelUsageWithoutCost copies the per-model buckets with each bucket's cost
// cleared, preserving the model id and the four token fields the platform prices
// from. Returns nil for a nil/empty slice.
func modelUsageWithoutCost(in []types.ModelUsage) []types.ModelUsage {
if len(in) == 0 {
return in
}
out := make([]types.ModelUsage, len(in))
for i := range in {
tu := in[i].TokenUsage
tu.CostUSD = nil
tu.CostSource = ""
tu.SubagentTokens = tokenUsageWithoutCost(in[i].TokenUsage.SubagentTokens)
out[i] = types.ModelUsage{Model: in[i].Model, TokenUsage: tu}
}
return out
}

// SessionFilePaths contains the absolute paths to session files from the git tree root.
// Paths include the full checkpoint path prefix (e.g., "/a1/b2c3d4e5f6/1/metadata.json").
// Used in CheckpointSummary.Sessions to map session IDs to their file locations.
Expand Down Expand Up @@ -482,6 +549,7 @@ type CheckpointSummary struct {
FilesTouched []string `json:"files_touched"`
Sessions []SessionFilePaths `json:"sessions"`
TokenUsage *types.TokenUsage `json:"token_usage,omitempty"`
ModelUsage []types.ModelUsage `json:"model_usage,omitempty"`
CombinedAttribution *Attribution `json:"combined_attribution,omitempty"`

// HasReview is the umbrella "any review happened" flag: true when at least
Expand Down
215 changes: 215 additions & 0 deletions api/checkpoint/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"strings"
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent/types"
)

func TestImportedFlagsOnSummaryAndInfo(t *testing.T) {
Expand Down Expand Up @@ -67,3 +69,216 @@ func TestCompactTranscriptStart_JSONRoundTrip(t *testing.T) {
t.Fatalf("round-trip: got (%d, %v), want (0, true)", offset, ok)
}
}

// costPtr is a small helper for constructing optional cost values in tests.
func costPtr(v float64) *float64 { return &v }

const testWithoutCostModel = "claude-opus-4-8"

// WithoutCost must strip cost provenance at every level (flat, nested subagent,
// and per-model bucket) while preserving the four token fields, APICallCount,
// model ids, subagent token counts, and all non-cost metadata — and it must not
// mutate the receiver (the CLI keeps the costed copy for local diagnostics).
func TestMetadata_WithoutCost(t *testing.T) {
t.Parallel()

orig := Metadata{
Model: testWithoutCostModel,
TokenUsage: &types.TokenUsage{
InputTokens: 1000,
OutputTokens: 200,
CacheReadTokens: 300,
CacheCreationTokens: 40,
APICallCount: 5,
CostUSD: costPtr(1.23),
CostSource: types.CostSourceEstimated,
SubagentTokens: &types.TokenUsage{
InputTokens: 10,
OutputTokens: 2,
CostUSD: costPtr(0.05),
CostSource: types.CostSourceReported,
},
},
ModelUsage: []types.ModelUsage{
{Model: testWithoutCostModel, TokenUsage: types.TokenUsage{
InputTokens: 1000, OutputTokens: 200, CacheReadTokens: 300, CacheCreationTokens: 40,
CostUSD: costPtr(1.23), CostSource: types.CostSourceEstimated,
}},
},
}

got := orig.WithoutCost()

// Model + token counts preserved.
if got.Model != testWithoutCostModel {
t.Errorf("model = %q, want claude-opus-4-8", got.Model)
}
if got.TokenUsage.InputTokens != 1000 || got.TokenUsage.OutputTokens != 200 ||
got.TokenUsage.CacheReadTokens != 300 || got.TokenUsage.CacheCreationTokens != 40 ||
got.TokenUsage.APICallCount != 5 {
t.Errorf("flat token counts changed: %+v", got.TokenUsage)
}
// Cost stripped at flat, subagent, and per-model levels.
if got.TokenUsage.CostUSD != nil || got.TokenUsage.CostSource != "" {
t.Errorf("flat cost must be cleared, got %v/%q", got.TokenUsage.CostUSD, got.TokenUsage.CostSource)
}
if got.TokenUsage.SubagentTokens == nil {
t.Fatal("subagent tokens must be preserved")
}
if got.TokenUsage.SubagentTokens.InputTokens != 10 || got.TokenUsage.SubagentTokens.OutputTokens != 2 {
t.Errorf("subagent token counts changed: %+v", got.TokenUsage.SubagentTokens)
}
if got.TokenUsage.SubagentTokens.CostUSD != nil || got.TokenUsage.SubagentTokens.CostSource != "" {
t.Errorf("subagent cost must be cleared, got %v/%q", got.TokenUsage.SubagentTokens.CostUSD, got.TokenUsage.SubagentTokens.CostSource)
}
if len(got.ModelUsage) != 1 || got.ModelUsage[0].Model != testWithoutCostModel {
t.Fatalf("per-model breakdown must be preserved with model id, got %+v", got.ModelUsage)
}
mu := got.ModelUsage[0].TokenUsage
if mu.InputTokens != 1000 || mu.OutputTokens != 200 || mu.CacheReadTokens != 300 || mu.CacheCreationTokens != 40 {
t.Errorf("per-model token counts changed: %+v", mu)
}
if mu.CostUSD != nil || mu.CostSource != "" {
t.Errorf("per-model cost must be cleared, got %v/%q", mu.CostUSD, mu.CostSource)
}

// Receiver not mutated.
if orig.TokenUsage.CostUSD == nil || *orig.TokenUsage.CostUSD != 1.23 {
t.Errorf("WithoutCost must not mutate the receiver's flat cost")
}
if orig.TokenUsage.SubagentTokens.CostUSD == nil {
t.Errorf("WithoutCost must not mutate the receiver's subagent cost")
}
if orig.ModelUsage[0].TokenUsage.CostUSD == nil {
t.Errorf("WithoutCost must not mutate the receiver's per-model cost")
}
}

// CheckpointSummary.WithoutCost strips cost from the aggregated summary while
// keeping token counts and per-model ids.
func TestCheckpointSummary_WithoutCost(t *testing.T) {
t.Parallel()

orig := CheckpointSummary{
TokenUsage: &types.TokenUsage{InputTokens: 1000, OutputTokens: 200, CostUSD: costPtr(1.23), CostSource: types.CostSourceEstimated},
ModelUsage: []types.ModelUsage{
{Model: "gpt-5.5", TokenUsage: types.TokenUsage{InputTokens: 1000, OutputTokens: 200, CostUSD: costPtr(1.23), CostSource: types.CostSourceEstimated}},
},
}

got := orig.WithoutCost()
if got.TokenUsage.InputTokens != 1000 || got.TokenUsage.OutputTokens != 200 {
t.Errorf("summary token counts changed: %+v", got.TokenUsage)
}
if got.TokenUsage.CostUSD != nil || got.TokenUsage.CostSource != "" {
t.Errorf("summary flat cost must be cleared, got %v/%q", got.TokenUsage.CostUSD, got.TokenUsage.CostSource)
}
if len(got.ModelUsage) != 1 || got.ModelUsage[0].Model != "gpt-5.5" {
t.Fatalf("summary per-model breakdown must be preserved, got %+v", got.ModelUsage)
}
if got.ModelUsage[0].TokenUsage.CostUSD != nil || got.ModelUsage[0].TokenUsage.CostSource != "" {
t.Errorf("summary per-model cost must be cleared, got %+v", got.ModelUsage[0].TokenUsage)
}
if orig.TokenUsage.CostUSD == nil {
t.Errorf("WithoutCost must not mutate the receiver")
}
}

// The per-model breakdown must be omitted from the wire form when empty, so
// legacy readers and backends that never asked for it see no field at all.
func TestModelUsage_OmittedWhenEmpty(t *testing.T) {
t.Parallel()

b, err := json.Marshal(Metadata{})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if strings.Contains(string(b), "model_usage") {
t.Fatalf("empty ModelUsage should be omitted, got: %s", b)
}

b, err = json.Marshal(CheckpointSummary{})
if err != nil {
t.Fatalf("marshal summary: %v", err)
}
if strings.Contains(string(b), "model_usage") {
t.Fatalf("empty summary ModelUsage should be omitted, got: %s", b)
}
}

// The backend contract is a nested shape: each entry has a "model" string and a
// "token_usage" object. Assert the exact raw JSON keys so a field rename can't
// silently break ingestion.
func TestModelUsage_NestedShapeExact(t *testing.T) {
t.Parallel()

meta := Metadata{
ModelUsage: []types.ModelUsage{
{
Model: testWithoutCostModel,
TokenUsage: types.TokenUsage{
InputTokens: 1200000,
OutputTokens: 3400,
CostUSD: costPtr(0.42),
CostSource: types.CostSourceEstimated,
},
},
},
}
b, err := json.Marshal(meta)
if err != nil {
t.Fatalf("marshal: %v", err)
}

// Decode into a generic map to assert the exact nested key names.
var raw map[string]any
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("unmarshal raw: %v", err)
}
list, ok := raw["model_usage"].([]any)
if !ok || len(list) != 1 {
t.Fatalf("model_usage not a 1-element array: %v", raw["model_usage"])
}
entry, ok := list[0].(map[string]any)
if !ok {
t.Fatalf("model_usage[0] not an object: %v", list[0])
}
if entry["model"] != testWithoutCostModel {
t.Fatalf(`entry["model"] = %v, want %s`, entry["model"], testWithoutCostModel)
}
tu, ok := entry["token_usage"].(map[string]any)
if !ok {
t.Fatalf(`entry["token_usage"] not a nested object: %v`, entry["token_usage"])
}
if in, ok := tu["input_tokens"].(float64); !ok || in != 1200000 {
t.Fatalf("token_usage.input_tokens = %v, want 1200000", tu["input_tokens"])
}
if cost, ok := tu["cost_usd"].(float64); !ok || cost != 0.42 {
t.Fatalf("token_usage.cost_usd = %v, want 0.42", tu["cost_usd"])
}

// Full round-trip preserves the typed value.
var got Metadata
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
if len(got.ModelUsage) != 1 || got.ModelUsage[0].Model != testWithoutCostModel ||
got.ModelUsage[0].TokenUsage.InputTokens != 1200000 {
t.Fatalf("round-trip mismatch: %+v", got.ModelUsage)
}
}

// Legacy metadata written before this field existed has no "model_usage" key;
// decoding must leave ModelUsage nil (not panic, not a zero-length non-nil slice).
func TestModelUsage_LegacyJSONNil(t *testing.T) {
t.Parallel()

legacy := `{"strategy":"manual-commit","token_usage":{"input_tokens":5}}`
var got Metadata
if err := json.Unmarshal([]byte(legacy), &got); err != nil {
t.Fatalf("unmarshal legacy: %v", err)
}
if got.ModelUsage != nil {
t.Fatalf("legacy ModelUsage = %v, want nil", got.ModelUsage)
}
}
15 changes: 15 additions & 0 deletions cmd/entire/cli/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ type TokenCalculator interface {
CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error)
}

// ModelUsageCalculator is an optional capability for agents that can attribute
// token usage to the specific model that produced it. Implementations must cover
// the same usage the flat TokenUsage covers (including subagent usage when the
// agent also implements SubagentAwareExtractor) so per-model buckets sum to the
// flat total. Buckets carry token counts only; CostUSD is left nil unless the
// agent itself reports cost (then CostSource must be CostSourceReported).
type ModelUsageCalculator interface {
Agent

// CalculateModelUsage attributes token usage to each producing model,
// starting at the given offset. The returned buckets must sum to the flat
// TokenUsage from CalculateTokenUsage over the same slice.
CalculateModelUsage(transcriptData []byte, fromOffset int) ([]types.ModelUsage, error)
}

// ModelExtractor extracts the LLM model identifier from a transcript for agents
// that do not report the model through lifecycle hooks. Pi, for example, records
// the model on every assistant message (message.model) but its hook events carry
Expand Down
7 changes: 7 additions & 0 deletions cmd/entire/cli/agent/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type DeclaredCaps struct {
TranscriptAnalyzer bool `json:"transcript_analyzer"`
TranscriptPreparer bool `json:"transcript_preparer"`
TokenCalculator bool `json:"token_calculator"`
ModelUsageCalculator bool `json:"model_usage_calculator"`
CompactTranscript bool `json:"compact_transcript"`
TextGenerator bool `json:"text_generator"`
HookResponseWriter bool `json:"hook_response_writer"`
Expand Down Expand Up @@ -94,6 +95,12 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) {
return declaredCapability[TokenCalculator](ag, func(c DeclaredCaps) bool { return c.TokenCalculator })
}

// AsModelUsageCalculator returns the agent as ModelUsageCalculator if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsModelUsageCalculator(ag Agent) (ModelUsageCalculator, bool) {
return declaredCapability[ModelUsageCalculator](ag, func(c DeclaredCaps) bool { return c.ModelUsageCalculator })
}

// AsTextGenerator returns the agent as TextGenerator if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsTextGenerator(ag Agent) (TextGenerator, bool) {
Expand Down
1 change: 1 addition & 0 deletions cmd/entire/cli/agent/claudecode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ var (
_ agent.TranscriptAnalyzer = (*ClaudeCodeAgent)(nil)
_ agent.TranscriptPreparer = (*ClaudeCodeAgent)(nil)
_ agent.TokenCalculator = (*ClaudeCodeAgent)(nil)
_ agent.ModelUsageCalculator = (*ClaudeCodeAgent)(nil)
_ agent.SkillEventExtractor = (*ClaudeCodeAgent)(nil)
_ agent.SubagentAwareExtractor = (*ClaudeCodeAgent)(nil)
_ agent.HookResponseWriter = (*ClaudeCodeAgent)(nil)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{"type":"system","subtype":"init","cwd":"/redacted/worktree","session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","model":"claude-opus-4-8","fast_mode_state":"on","uuid":"11111111-1111-1111-1111-111111111111"}
{"type":"user","message":{"role":"user","content":"go"},"session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"22222222-2222-2222-2222-222222222222"}
{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_std","type":"message","role":"assistant","content":[{"type":"text","text":"standard turn"}],"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":50,"output_tokens":100,"speed":"standard"}},"session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"33333333-3333-3333-3333-333333333333"}
{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_fast","type":"message","role":"assistant","content":[{"type":"text","text":"fast turn (streaming)"}],"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":80,"output_tokens":50,"speed":"fast"}},"session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"44444444-4444-4444-4444-444444444444"}
{"type":"assistant","message":{"model":"claude-opus-4-8","id":"msg_fast","type":"message","role":"assistant","content":[{"type":"text","text":"fast turn (final)"}],"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":80,"output_tokens":200,"speed":"fast"}},"session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"55555555-5555-5555-5555-555555555555"}
{"type":"assistant","message":{"model":"claude-sonnet-5","id":"msg_sonnet","type":"message","role":"assistant","content":[{"type":"text","text":"second model fast turn"}],"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":9,"output_tokens":40,"speed":"fast"}},"session_id":"fa570000-aaaa-aaaa-aaaa-aaaaaaaaaaaa","uuid":"66666666-6666-6666-6666-666666666666"}
Loading
Loading