fix(providers): parse Codex cached token usage (#1309)

This commit is contained in:
Zezae Oh authored and GitHub committed 2026-06-30 16:53:46 +07:00
1 parent 8963ec3177
commit b8726ff4f4
5 files changed
+94 -38

No files matched your search

+28 -10
View File
@@ -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
}
+35
View File
@@ -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")
}
}
+1 -9
View File
@@ -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"
+7 -2
View File
@@ -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)
}
}
+23 -17
View File
@@ -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 {