From b8726ff4f40f7254d22ba9f5567558a1dd526e7d Mon Sep 17 00:00:00 2001 From: Zezae Oh Date: Tue, 30 Jun 2026 18:53:46 +0900 Subject: [PATCH] fix(providers): parse Codex cached token usage (#1309) --- internal/providers/adapter_codex.go | 38 ++++++++++++++++------ internal/providers/adapter_codex_test.go | 35 +++++++++++++++++++++ internal/providers/codex.go | 10 +----- internal/providers/codex_test.go | 9 ++++-- internal/providers/codex_types.go | 40 ++++++++++++++---------- 5 files changed, 94 insertions(+), 38 deletions(-) diff --git a/internal/providers/adapter_codex.go b/internal/providers/adapter_codex.go index 42aaf691..a77ec01f 100644 --- a/internal/providers/adapter_codex.go +++ b/internal/providers/adapter_codex.go @@ -128,6 +128,33 @@ func (a *CodexAdapter) FromStreamChunk(data []byte) (*StreamChunk, error) { return nil, nil } +func usageFromCodexUsage(u *codexUsage) *Usage { + if u == nil { + return nil + } + usage := &Usage{ + PromptTokens: u.InputTokens, + CompletionTokens: u.OutputTokens, + TotalTokens: u.TotalTokens, + } + if u.InputTokensDetails != nil { + usage.CacheReadTokens = u.InputTokensDetails.CachedTokens + usage.PromptTokensIncludeCachedSegments = true + } + if u.PromptTokensDetails != nil { + // Some Responses API payloads use the older Chat Completions naming. + // Prefer the larger value if both aliases are present. + if u.PromptTokensDetails.CachedTokens > usage.CacheReadTokens { + usage.CacheReadTokens = u.PromptTokensDetails.CachedTokens + } + usage.PromptTokensIncludeCachedSegments = true + } + if u.OutputTokensDetails != nil { + usage.ThinkingTokens = u.OutputTokensDetails.ReasoningTokens + } + return usage +} + // parseCodexAPIResponse converts a Codex API response to internal ChatResponse. // Simple concatenation — does not handle phase-aware ordering like the streaming // path (codex_stream_state.go). Sufficient for Pipeline non-streaming use. @@ -169,16 +196,7 @@ func parseCodexAPIResponse(resp *codexAPIResponse) *ChatResponse { result.FinishReason = "length" } - if resp.Usage != nil { - result.Usage = &Usage{ - PromptTokens: resp.Usage.InputTokens, - CompletionTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.TotalTokens, - } - if resp.Usage.OutputTokensDetails != nil { - result.Usage.ThinkingTokens = resp.Usage.OutputTokensDetails.ReasoningTokens - } - } + result.Usage = usageFromCodexUsage(resp.Usage) return result } diff --git a/internal/providers/adapter_codex_test.go b/internal/providers/adapter_codex_test.go index 44eca806..fbb5d3a9 100644 --- a/internal/providers/adapter_codex_test.go +++ b/internal/providers/adapter_codex_test.go @@ -151,6 +151,7 @@ func TestCodexAdapter_FromResponse_TextAndUsage(t *testing.T) { "input_tokens":10, "output_tokens":3, "total_tokens":13, + "input_tokens_details":{"cached_tokens":7}, "output_tokens_details":{"reasoning_tokens":2} } }`) @@ -170,6 +171,12 @@ func TestCodexAdapter_FromResponse_TextAndUsage(t *testing.T) { if resp.Usage == nil || resp.Usage.ThinkingTokens != 2 { t.Errorf("Usage ThinkingTokens = %+v, want 2", resp.Usage) } + if resp.Usage == nil || resp.Usage.CacheReadTokens != 7 { + t.Errorf("Usage CacheReadTokens = %+v, want 7", resp.Usage) + } + if resp.Usage == nil || !resp.Usage.PromptTokensIncludeCachedSegments { + t.Errorf("Usage PromptTokensIncludeCachedSegments = %+v, want true", resp.Usage) + } } // TestCodexAdapter_FromResponse_FunctionCallAndIncomplete covers two branches: @@ -351,3 +358,31 @@ func TestCodexAdapter_ToRequest_StreamOption(t *testing.T) { // ensure the adapter satisfies the interface at compile time. var _ ProviderAdapter = (*CodexAdapter)(nil) + +func TestCodexAdapter_FromResponse_PromptTokensDetailsAlias(t *testing.T) { + a, _ := NewCodexAdapter(ProviderConfig{}) + raw := []byte(`{ + "id":"resp_cache_alias", + "object":"response", + "status":"completed", + "output":[ + {"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]} + ], + "usage":{ + "input_tokens":100, + "output_tokens":5, + "total_tokens":105, + "prompt_tokens_details":{"cached_tokens":80} + } + }`) + resp, err := a.FromResponse(raw) + if err != nil { + t.Fatalf("FromResponse: %v", err) + } + if resp.Usage == nil || resp.Usage.CacheReadTokens != 80 { + t.Fatalf("CacheReadTokens = %+v, want 80", resp.Usage) + } + if !resp.Usage.PromptTokensIncludeCachedSegments { + t.Fatal("PromptTokensIncludeCachedSegments = false, want true") + } +} diff --git a/internal/providers/codex.go b/internal/providers/codex.go index 8687cbb5..9e182a43 100644 --- a/internal/providers/codex.go +++ b/internal/providers/codex.go @@ -313,15 +313,7 @@ func (p *CodexProvider) processSSEEvent(event *codexSSEEvent, result *ChatRespon } } if event.Response.Usage != nil { - u := event.Response.Usage - result.Usage = &Usage{ - PromptTokens: u.InputTokens, - CompletionTokens: u.OutputTokens, - TotalTokens: u.TotalTokens, - } - if u.OutputTokensDetails != nil { - result.Usage.ThinkingTokens = u.OutputTokensDetails.ReasoningTokens - } + result.Usage = usageFromCodexUsage(event.Response.Usage) } if event.Response.Status == "incomplete" { result.FinishReason = "length" diff --git a/internal/providers/codex_test.go b/internal/providers/codex_test.go index 415b2b92..54899ecb 100644 --- a/internal/providers/codex_test.go +++ b/internal/providers/codex_test.go @@ -305,7 +305,7 @@ func TestCodexProviderChatStream(t *testing.T) { events := []string{ `{"type":"response.output_text.delta","delta":"Hello"}`, `{"type":"response.output_text.delta","delta":" world"}`, - `{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + `{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":4}}}}`, } for _, e := range events { @@ -344,6 +344,12 @@ func TestCodexProviderChatStream(t *testing.T) { if result.Usage.TotalTokens != 7 { t.Errorf("TotalTokens = %d, want 7", result.Usage.TotalTokens) } + if result.Usage.CacheReadTokens != 4 { + t.Errorf("CacheReadTokens = %d, want 4", result.Usage.CacheReadTokens) + } + if !result.Usage.PromptTokensIncludeCachedSegments { + t.Error("PromptTokensIncludeCachedSegments = false, want true") + } } func TestCodexProviderChatFallsBackToOutputTextDone(t *testing.T) { @@ -1122,4 +1128,3 @@ func TestCodexBuildRequestBody_NilFunction_HandlesGracefully(t *testing.T) { t.Errorf("expected function tool 'web_search', got: %v", tool) } } - diff --git a/internal/providers/codex_types.go b/internal/providers/codex_types.go index dc778f37..cc4650ff 100644 --- a/internal/providers/codex_types.go +++ b/internal/providers/codex_types.go @@ -42,31 +42,37 @@ type codexSummary struct { } type codexUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - TotalTokens int `json:"total_tokens"` - OutputTokensDetails *codexTokensDetails `json:"output_tokens_details,omitempty"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + InputTokensDetails *codexInputTokensDetails `json:"input_tokens_details,omitempty"` + PromptTokensDetails *codexInputTokensDetails `json:"prompt_tokens_details,omitempty"` + OutputTokensDetails *codexOutputTokensDetails `json:"output_tokens_details,omitempty"` } -type codexTokensDetails struct { +type codexInputTokensDetails struct { + CachedTokens int `json:"cached_tokens"` +} + +type codexOutputTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } // SSE streaming types type codexSSEEvent struct { - Type string `json:"type"` - Delta string `json:"delta,omitempty"` - Text string `json:"text,omitempty"` - ItemID string `json:"item_id,omitempty"` - OutputIndex int `json:"output_index,omitempty"` - ContentIndex int `json:"content_index,omitempty"` - Item *codexItem `json:"item,omitempty"` - Part *codexContentPart `json:"part,omitempty"` - Response *codexAPIResponse `json:"response,omitempty"` - OutputFormat string `json:"output_format,omitempty"` // response.image_generation_call.partial_image - PartialImageB64 string `json:"partial_image_b64,omitempty"` // response.image_generation_call.partial_image - PartialImageIndex int `json:"partial_image_index,omitempty"` // response.image_generation_call.partial_image + Type string `json:"type"` + Delta string `json:"delta,omitempty"` + Text string `json:"text,omitempty"` + ItemID string `json:"item_id,omitempty"` + OutputIndex int `json:"output_index,omitempty"` + ContentIndex int `json:"content_index,omitempty"` + Item *codexItem `json:"item,omitempty"` + Part *codexContentPart `json:"part,omitempty"` + Response *codexAPIResponse `json:"response,omitempty"` + OutputFormat string `json:"output_format,omitempty"` // response.image_generation_call.partial_image + PartialImageB64 string `json:"partial_image_b64,omitempty"` // response.image_generation_call.partial_image + PartialImageIndex int `json:"partial_image_index,omitempty"` // response.image_generation_call.partial_image } type codexToolCallAcc struct {