Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 9 additions & 5 deletions internal/llmadapters/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,8 @@ type openAIUsage struct {
}

type openAIInputTokensDetails struct {
CachedTokens *int `json:"cached_tokens"`
CachedTokens *int `json:"cached_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`
}

func parseOpenAIResponse(body []byte) (string, Response, error) {
Expand Down Expand Up @@ -478,13 +479,16 @@ func parseOpenAIResponse(body []byte) (string, Response, error) {
case "standard", "default":
speed = "standard"
}
// OpenAI's input total includes cache reads and writes. Keep that total
// intact rather than treating cache writes as an additional input bucket.
return payload.ID, Response{
StructuredOutput: []byte(text.String()),
Usage: Usage{
TokensIn: payload.Usage.InputTokens,
TokensOut: payload.Usage.OutputTokens,
CacheRead: payload.Usage.InputTokensDetails.CachedTokens,
Speed: speed,
TokensIn: payload.Usage.InputTokens,
TokensOut: payload.Usage.OutputTokens,
CacheRead: payload.Usage.InputTokensDetails.CachedTokens,
CacheCreate: payload.Usage.InputTokensDetails.CacheWriteTokens,
Speed: speed,
},
}, nil
}
Expand Down
107 changes: 107 additions & 0 deletions internal/llmadapters/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (

"github.com/open-cli-collective/codereview-cli/internal/config"
"github.com/open-cli-collective/codereview-cli/internal/credentials"
"github.com/open-cli-collective/codereview-cli/internal/modelcatalog"
"github.com/open-cli-collective/codereview-cli/internal/pricing"
)

func TestAnthropicAPIAdapterRequestAndResponse(t *testing.T) {
Expand Down Expand Up @@ -234,6 +236,111 @@ func TestOpenAIAPIAdapterFastRequest(t *testing.T) {
}
}

func TestParseOpenAIResponseCacheWrites(t *testing.T) {
zero, five, seven, twelve, negative := 0, 5, 7, 12, -3
for _, tt := range []struct {
name string
details string
want *int
wantRead *int
}{
{name: "missing details"},
{name: "null details", details: `,"input_tokens_details":null`},
{name: "missing count", details: `,"input_tokens_details":{"cached_tokens":5}`, wantRead: &five},
{name: "null count", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":null}`, wantRead: &five},
{name: "zero count", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":0}`, want: &zero, wantRead: &five},
{name: "positive count", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":7}`, want: &seven, wantRead: &five},
{name: "negative count retained", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":-3}`, want: &negative, wantRead: &five},
{name: "overlapping counts retained", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":12}`, want: &twelve, wantRead: &five},
{name: "unknown field ignored", details: `,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":7,"future_detail":{"count":9}}`, want: &seven, wantRead: &five},
} {
t.Run(tt.name, func(t *testing.T) {
body := fmt.Sprintf(`{"id":"resp-cache","service_tier":"default","output_text":"{}","usage":{"input_tokens":16,"output_tokens":3%s}}`, tt.details)
_, response, err := parseOpenAIResponse([]byte(body))
if err != nil {
t.Fatalf("parseOpenAIResponse: %v", err)
}
got := response.Usage.CacheCreate
if (got == nil) != (tt.want == nil) || got != nil && *got != *tt.want {
t.Fatalf("CacheCreate = %v, want %v", got, tt.want)
}
read := response.Usage.CacheRead
if (read == nil) != (tt.wantRead == nil) || read != nil && *read != *tt.wantRead {
t.Fatalf("CacheRead = %v, want %v", read, tt.wantRead)
}
// OpenAI input is inclusive of cache reads and writes. Preserve the
// provider's total. Raw negative or overlapping counts are not
// proof of billable usage; a future estimator must validate them.
if response.Usage.TokensIn == nil || *response.Usage.TokensIn != 16 ||
response.Usage.TokensOut == nil || *response.Usage.TokensOut != 3 {
t.Fatalf("Usage = %#v, want unchanged input/output totals", response.Usage)
}
if response.Usage.CacheCreate5m != nil || response.Usage.CacheCreate1h != nil || response.Usage.CostUSD != nil {
t.Fatalf("Usage = %#v, want unknown TTL buckets and cost", response.Usage)
}
if response.Usage.Speed != "standard" {
t.Fatalf("Speed = %q, want observed standard tier", response.Usage.Speed)
}
})
}
}

func TestParseOpenAIResponseRejectsMalformedCacheWriteCount(t *testing.T) {
for _, count := range []string{`"7"`, `1.5`, `true`, `{}`, `[]`, `9223372036854775808`} {
t.Run(count, func(t *testing.T) {
body := fmt.Sprintf(`{"output_text":"{}","usage":{"input_tokens_details":{"cache_write_tokens":%s}}}`, count)
if _, _, err := parseOpenAIResponse([]byte(body)); err == nil {
t.Fatal("expected malformed cache-write count to fail response decoding")
}
})
}
}

func TestParsedOpenAIUsageLeavesBundledEstimatesUnavailable(t *testing.T) {
catalog, err := modelcatalog.LoadBundled()
if err != nil {
t.Fatalf("LoadBundled: %v", err)
}
for _, count := range []string{`null`, `0`, `7`} {
t.Run(count, func(t *testing.T) {
body := fmt.Sprintf(`{"service_tier":"default","output_text":"{}","usage":{"input_tokens":16,"output_tokens":3,"input_tokens_details":{"cached_tokens":5,"cache_write_tokens":%s}}}`, count)
_, response, err := parseOpenAIResponse([]byte(body))
if err != nil {
t.Fatalf("parseOpenAIResponse: %v", err)
}
usage := response.Usage
_, ok := pricing.EstimateUsageUSDFor(catalog, "gpt-6.1-sol", pricing.Usage{
TokensIn: usage.TokensIn,
TokensOut: usage.TokensOut,
CacheRead: usage.CacheRead,
CacheCreateTotal: usage.CacheCreate,
CacheCreate5m: usage.CacheCreate5m,
CacheCreate1h: usage.CacheCreate1h,
Speed: usage.Speed,
})
if ok {
t.Fatal("parsed cache-write telemetry must not activate bundled context-banded estimates")
}
})
}
}

func TestParseAnthropicResponseKeepsSeparateCacheBuckets(t *testing.T) {
_, response, err := parseAnthropicResponse([]byte(`{"content":[{"type":"text","text":"{}"}],"usage":{"input_tokens":16,"output_tokens":3,"cache_read_input_tokens":5,"cache_creation_input_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":3,"ephemeral_1h_input_tokens":4},"input_tokens_details":{"cached_tokens":100,"cache_write_tokens":200}}}`))
if err != nil {
t.Fatalf("parseAnthropicResponse: %v", err)
}
assertIntPtr(t, "TokensIn", response.Usage.TokensIn, 16)
assertIntPtr(t, "TokensOut", response.Usage.TokensOut, 3)
assertIntPtr(t, "CacheRead", response.Usage.CacheRead, 5)
assertIntPtr(t, "CacheCreate", response.Usage.CacheCreate, 7)
assertIntPtr(t, "CacheCreate5m", response.Usage.CacheCreate5m, 3)
assertIntPtr(t, "CacheCreate1h", response.Usage.CacheCreate1h, 4)
if response.Usage.CostUSD != nil {
t.Fatal("cache usage must not become a reported cost")
}
}

func TestParseOpenAIResponseSpeed(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down
Loading