Skip to content

Commit 2f8e10d

Browse files
fix(service): preserve anthropic usage fields across compat endpoints
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 5418e15 commit 2f8e10d

4 files changed

Lines changed: 317 additions & 73 deletions

File tree

backend/internal/service/gateway_forward_as_chat_completions.go

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
1818
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
1919
"github.com/gin-gonic/gin"
20+
"github.com/tidwall/gjson"
2021
"go.uber.org/zap"
2122
)
2223

@@ -171,26 +172,48 @@ func (s *GatewayService) ForwardAsChatCompletions(
171172
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
172173
}
173174

174-
// 13. Handle normal response
175+
// 13. Extract reasoning effort from CC request body
176+
reasoningEffort := extractCCReasoningEffortFromBody(body)
177+
178+
// 14. Handle normal response
175179
// Read Anthropic SSE → convert to Responses events → convert to CC format
176180
var result *ForwardResult
177181
var handleErr error
178182
if clientStream {
179-
result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, startTime, includeUsage)
183+
result, handleErr = s.handleCCStreamingFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime, includeUsage)
180184
} else {
181-
result, handleErr = s.handleCCBufferedFromAnthropic(resp, c, originalModel, mappedModel, startTime)
185+
result, handleErr = s.handleCCBufferedFromAnthropic(resp, c, originalModel, mappedModel, reasoningEffort, startTime)
182186
}
183187

184188
return result, handleErr
185189
}
186190

191+
// extractCCReasoningEffortFromBody reads reasoning effort from a Chat Completions
192+
// request body. It checks both nested (reasoning.effort) and flat (reasoning_effort)
193+
// formats used by OpenAI-compatible clients.
194+
func extractCCReasoningEffortFromBody(body []byte) *string {
195+
raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
196+
if raw == "" {
197+
raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
198+
}
199+
if raw == "" {
200+
return nil
201+
}
202+
normalized := normalizeOpenAIReasoningEffort(raw)
203+
if normalized == "" {
204+
return nil
205+
}
206+
return &normalized
207+
}
208+
187209
// handleCCBufferedFromAnthropic reads Anthropic SSE events, assembles the full
188210
// response, then converts Anthropic → Responses → Chat Completions.
189211
func (s *GatewayService) handleCCBufferedFromAnthropic(
190212
resp *http.Response,
191213
c *gin.Context,
192214
originalModel string,
193215
mappedModel string,
216+
reasoningEffort *string,
194217
startTime time.Time,
195218
) (*ForwardResult, error) {
196219
requestID := resp.Header.Get("x-request-id")
@@ -225,18 +248,16 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
225248
continue
226249
}
227250

251+
// message_start carries the initial response structure and cache usage
228252
if event.Type == "message_start" && event.Message != nil {
229253
finalResp = event.Message
254+
mergeAnthropicUsage(&usage, event.Message.Usage)
230255
}
256+
257+
// message_delta carries final usage and stop_reason
231258
if event.Type == "message_delta" {
232259
if event.Usage != nil {
233-
usage = ClaudeUsage{
234-
InputTokens: event.Usage.InputTokens,
235-
OutputTokens: event.Usage.OutputTokens,
236-
}
237-
if event.Usage.CacheReadInputTokens > 0 {
238-
usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens
239-
}
260+
mergeAnthropicUsage(&usage, *event.Usage)
240261
}
241262
if event.Delta != nil && event.Delta.StopReason != "" && finalResp != nil {
242263
finalResp.StopReason = event.Delta.StopReason
@@ -274,10 +295,13 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
274295
return nil, fmt.Errorf("upstream stream ended without response")
275296
}
276297

298+
// Update usage from accumulated delta
277299
if usage.InputTokens > 0 || usage.OutputTokens > 0 {
278300
finalResp.Usage = apicompat.AnthropicUsage{
279-
InputTokens: usage.InputTokens,
280-
OutputTokens: usage.OutputTokens,
301+
InputTokens: usage.InputTokens,
302+
OutputTokens: usage.OutputTokens,
303+
CacheCreationInputTokens: usage.CacheCreationInputTokens,
304+
CacheReadInputTokens: usage.CacheReadInputTokens,
281305
}
282306
}
283307

@@ -291,12 +315,13 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
291315
c.JSON(http.StatusOK, ccResp)
292316

293317
return &ForwardResult{
294-
RequestID: requestID,
295-
Usage: usage,
296-
Model: originalModel,
297-
UpstreamModel: mappedModel,
298-
Stream: false,
299-
Duration: time.Since(startTime),
318+
RequestID: requestID,
319+
Usage: usage,
320+
Model: originalModel,
321+
UpstreamModel: mappedModel,
322+
ReasoningEffort: reasoningEffort,
323+
Stream: false,
324+
Duration: time.Since(startTime),
300325
}, nil
301326
}
302327

@@ -307,6 +332,7 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
307332
c *gin.Context,
308333
originalModel string,
309334
mappedModel string,
335+
reasoningEffort *string,
310336
startTime time.Time,
311337
includeUsage bool,
312338
) (*ForwardResult, error) {
@@ -341,13 +367,14 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
341367

342368
resultWithUsage := func() *ForwardResult {
343369
return &ForwardResult{
344-
RequestID: requestID,
345-
Usage: usage,
346-
Model: originalModel,
347-
UpstreamModel: mappedModel,
348-
Stream: true,
349-
Duration: time.Since(startTime),
350-
FirstTokenMs: firstTokenMs,
370+
RequestID: requestID,
371+
Usage: usage,
372+
Model: originalModel,
373+
UpstreamModel: mappedModel,
374+
ReasoningEffort: reasoningEffort,
375+
Stream: true,
376+
Duration: time.Since(startTime),
377+
FirstTokenMs: firstTokenMs,
351378
}
352379
}
353380

@@ -369,18 +396,13 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
369396
firstTokenMs = &ms
370397
}
371398

372-
// Extract usage
399+
// Extract usage from message_delta
373400
if event.Type == "message_delta" && event.Usage != nil {
374-
usage = ClaudeUsage{
375-
InputTokens: event.Usage.InputTokens,
376-
OutputTokens: event.Usage.OutputTokens,
377-
}
378-
if event.Usage.CacheReadInputTokens > 0 {
379-
usage.CacheReadInputTokens = event.Usage.CacheReadInputTokens
380-
}
401+
mergeAnthropicUsage(&usage, *event.Usage)
381402
}
382-
if event.Type == "message_start" && event.Message != nil && event.Message.Usage.InputTokens > 0 {
383-
usage.InputTokens = event.Message.Usage.InputTokens
403+
// Also capture usage from message_start (carries cache fields)
404+
if event.Type == "message_start" && event.Message != nil {
405+
mergeAnthropicUsage(&usage, event.Message.Usage)
384406
}
385407

386408
// Chain: Anthropic event → Responses events → CC chunks
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//go:build unit
2+
3+
package service
4+
5+
import (
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/gin-gonic/gin"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func TestExtractCCReasoningEffortFromBody(t *testing.T) {
18+
t.Parallel()
19+
20+
t.Run("nested reasoning.effort", func(t *testing.T) {
21+
got := extractCCReasoningEffortFromBody([]byte(`{"reasoning":{"effort":"HIGH"}}`))
22+
require.NotNil(t, got)
23+
require.Equal(t, "high", *got)
24+
})
25+
26+
t.Run("flat reasoning_effort", func(t *testing.T) {
27+
got := extractCCReasoningEffortFromBody([]byte(`{"reasoning_effort":"x-high"}`))
28+
require.NotNil(t, got)
29+
require.Equal(t, "xhigh", *got)
30+
})
31+
32+
t.Run("missing effort", func(t *testing.T) {
33+
require.Nil(t, extractCCReasoningEffortFromBody([]byte(`{"model":"gpt-5"}`)))
34+
})
35+
}
36+
37+
func TestHandleCCBufferedFromAnthropic_PreservesMessageStartCacheUsageAndReasoning(t *testing.T) {
38+
t.Parallel()
39+
gin.SetMode(gin.TestMode)
40+
41+
rec := httptest.NewRecorder()
42+
c, _ := gin.CreateTestContext(rec)
43+
44+
reasoningEffort := "high"
45+
resp := &http.Response{
46+
Header: http.Header{"x-request-id": []string{"rid_cc_buffered"}},
47+
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
48+
`event: message_start`,
49+
`data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4.5","stop_reason":"","usage":{"input_tokens":12,"cache_read_input_tokens":9,"cache_creation_input_tokens":3}}}`,
50+
``,
51+
`event: content_block_start`,
52+
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"hello"}}`,
53+
``,
54+
`event: message_delta`,
55+
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`,
56+
``,
57+
}, "\n"))),
58+
}
59+
60+
svc := &GatewayService{}
61+
result, err := svc.handleCCBufferedFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now())
62+
require.NoError(t, err)
63+
require.NotNil(t, result)
64+
require.Equal(t, 12, result.Usage.InputTokens)
65+
require.Equal(t, 7, result.Usage.OutputTokens)
66+
require.Equal(t, 9, result.Usage.CacheReadInputTokens)
67+
require.Equal(t, 3, result.Usage.CacheCreationInputTokens)
68+
require.NotNil(t, result.ReasoningEffort)
69+
require.Equal(t, "high", *result.ReasoningEffort)
70+
}
71+
72+
func TestHandleCCStreamingFromAnthropic_PreservesMessageStartCacheUsageAndReasoning(t *testing.T) {
73+
t.Parallel()
74+
gin.SetMode(gin.TestMode)
75+
76+
rec := httptest.NewRecorder()
77+
c, _ := gin.CreateTestContext(rec)
78+
79+
reasoningEffort := "medium"
80+
resp := &http.Response{
81+
Header: http.Header{"x-request-id": []string{"rid_cc_stream"}},
82+
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
83+
`event: message_start`,
84+
`data: {"type":"message_start","message":{"id":"msg_2","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4.5","stop_reason":"","usage":{"input_tokens":20,"cache_read_input_tokens":11,"cache_creation_input_tokens":4}}}`,
85+
``,
86+
`event: content_block_start`,
87+
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":"hello"}}`,
88+
``,
89+
`event: message_delta`,
90+
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
91+
``,
92+
`event: message_stop`,
93+
`data: {"type":"message_stop"}`,
94+
``,
95+
}, "\n"))),
96+
}
97+
98+
svc := &GatewayService{}
99+
result, err := svc.handleCCStreamingFromAnthropic(resp, c, "gpt-5", "claude-sonnet-4.5", &reasoningEffort, time.Now(), true)
100+
require.NoError(t, err)
101+
require.NotNil(t, result)
102+
require.Equal(t, 20, result.Usage.InputTokens)
103+
require.Equal(t, 8, result.Usage.OutputTokens)
104+
require.Equal(t, 11, result.Usage.CacheReadInputTokens)
105+
require.Equal(t, 4, result.Usage.CacheCreationInputTokens)
106+
require.NotNil(t, result.ReasoningEffort)
107+
require.Equal(t, "medium", *result.ReasoningEffort)
108+
require.Contains(t, rec.Body.String(), `[DONE]`)
109+
}

0 commit comments

Comments
 (0)