History

feat(usage): implement usage tracking infrastructure across executors

- Added `LoggerPlugin` to log usage metrics for observability. - Introduced a new `Manager` to handle usage record queuing and plugin registration. - Integrated new usage reporter and detailed metrics parsing into executors, covering providers like OpenAI, Codex, Claude, and Gemini. - Improved token usage breakdown across streaming and non-streaming responses.

Luis Pater committed 1 year ago

Commit 3ade03f1 parent 5090d9813 files · +706 −133Browse files

Unified · Split

13 files
  1. internal/runtime/executor/claude_executor.go +17 −1
  2. internal/runtime/executor/codex_executor.go +38 −3
  3. internal/runtime/executor/gemini_cli_executor.go +7 −0
  4. internal/runtime/executor/gemini_executor.go +6 −0
  5. internal/runtime/executor/gemini_web_executor.go +4 −0
  6. internal/runtime/executor/openai_compat_executor.go +6 −0
  7. internal/runtime/executor/qwen_executor.go +7 −0
  8. internal/runtime/executor/usage_helpers.go +292 −0
  9. internal/translator/claude/openai/chat-completions/claude_openai_response.go +3 −9
  10. internal/translator/codex/openai/chat-completions/codex_openai_response.go +107 −120
  11. internal/usage/logger_plugin.go +26 −0
  12. sdk/cliproxy/service.go +11 −0
  13. sdk/cliproxy/usage/manager.go +182 −0
internal/runtime/executor/claude_executor.go+17−1
@@ -43,10 +43,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 42 unmodified lines
4343 if baseURL == "" {
4444 baseURL = "https://api.anthropic.com"
4545 }
46+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4647 from := opts.SourceFormat
4748 to := sdktranslator.FromString("claude")
4849 // Use streaming translation to preserve function calling, except for claude.
49− body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), from != to)
50+ stream := from != to
51+ body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
5052
5153 if !strings.HasPrefix(req.Model, "claude-3-5-haiku") {
5254 body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions))
@@ -94,6 +96,16 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 41 unmodified lines
9496 return cliproxyexecutor.Response{}, err
9597 }
9698 appendAPIResponseChunk(ctx, e.cfg, data)
99+ if stream {
100+ lines := bytes.Split(data, []byte("\n"))
101+ for _, line := range lines {
102+ if detail, ok := parseClaudeStreamUsage(line); ok {
103+ reporter.publish(ctx, detail)
104+ }
105+ }
106+ } else {
107+ reporter.publish(ctx, parseClaudeUsage(data))
108+ }
97109 var param any
98110 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
99111 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -107,6 +119,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 7 unmodified lines
107119 if baseURL == "" {
108120 baseURL = "https://api.anthropic.com"
109121 }
122+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
110123 from := opts.SourceFormat
111124 to := sdktranslator.FromString("claude")
112125 body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
@@ -146,6 +159,9 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 33 unmodified lines
146159 for scanner.Scan() {
147160 line := scanner.Bytes()
148161 appendAPIResponseChunk(ctx, e.cfg, line)
162+ if detail, ok := parseClaudeStreamUsage(line); ok {
163+ reporter.publish(ctx, detail)
164+ }
149165 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
150166 for i := range chunks {
151167 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
internal/runtime/executor/codex_executor.go+38−3
@@ -18,12 +18,15 @@ import ( · 17 unmodified lines
1818 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
1919 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
2020 log "github.com/sirupsen/logrus"
21+ "github.com/tidwall/gjson"
2122 "github.com/tidwall/sjson"
2223
2324 "github.com/gin-gonic/gin"
2425 "github.com/google/uuid"
2526 )
2627
28+var dataTag = []byte("data:")
29+
2730 // CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
2831 // If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
2932 type CodexExecutor struct {
@@ -44,6 +47,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 14 unmodified lines
4447 if baseURL == "" {
4548 baseURL = "https://chatgpt.com/backend-api/codex"
4649 }
50+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4751
4852 from := opts.SourceFormat
4953 to := sdktranslator.FromString("codex")
@@ -75,6 +79,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 25 unmodified lines
7579 }
7680 }
7781
82+ body, _ = sjson.SetBytes(body, "stream", true)
83+
7884 url := strings.TrimSuffix(baseURL, "/") + "/responses"
7985 recordAPIRequest(ctx, e.cfg, body)
8086 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
@@ -103,9 +109,27 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 22 unmodified lines
103109 return cliproxyexecutor.Response{}, err
104110 }
105111 appendAPIResponseChunk(ctx, e.cfg, data)
106− var param any
107− out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
108− return cliproxyexecutor.Response{Payload: []byte(out)}, nil
112+
113+ lines := bytes.Split(data, []byte("\n"))
114+ for _, line := range lines {
115+ if !bytes.HasPrefix(line, dataTag) {
116+ continue
117+ }
118+
119+ line = bytes.TrimSpace(line[5:])
120+ if gjson.GetBytes(line, "type").String() != "response.completed" {
121+ continue
122+ }
123+
124+ if detail, ok := parseCodexUsage(line); ok {
125+ reporter.publish(ctx, detail)
126+ }
127+
128+ var param any
129+ out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, line, &param)
130+ return cliproxyexecutor.Response{Payload: []byte(out)}, nil
131+ }
132+ return cliproxyexecutor.Response{}, statusErr{code: 408, msg: "stream error: stream disconnected before completion: stream closed before response.completed"}
109133 }
110134
111135 func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
@@ -116,6 +140,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 4 unmodified lines
116140 if baseURL == "" {
117141 baseURL = "https://chatgpt.com/backend-api/codex"
118142 }
143+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
119144
120145 from := opts.SourceFormat
121146 to := sdktranslator.FromString("codex")
@@ -181,6 +206,16 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 59 unmodified lines
181206 for scanner.Scan() {
182207 line := scanner.Bytes()
183208 appendAPIResponseChunk(ctx, e.cfg, line)
209+
210+ if bytes.HasPrefix(line, dataTag) {
211+ data := bytes.TrimSpace(line[5:])
212+ if gjson.GetBytes(data, "type").String() == "response.completed" {
213+ if detail, ok := parseCodexUsage(data); ok {
214+ reporter.publish(ctx, detail)
215+ }
216+ }
217+ }
218+
184219 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
185220 for i := range chunks {
186221 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
internal/runtime/executor/gemini_cli_executor.go+7−0
@@ -54,6 +54,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 53 unmodified lines
5454 if err != nil {
5555 return cliproxyexecutor.Response{}, err
5656 }
57+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
5758
5859 from := opts.SourceFormat
5960 to := sdktranslator.FromString("gemini-cli")
@@ -117,6 +118,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 57 unmodified lines
117118 _ = resp.Body.Close()
118119 appendAPIResponseChunk(ctx, e.cfg, data)
119120 if resp.StatusCode >= 200 && resp.StatusCode < 300 {
121+ reporter.publish(ctx, parseGeminiCLIUsage(data))
120122 var param any
121123 out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, &param)
122124 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -139,6 +141,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 16 unmodified lines
139141 if err != nil {
140142 return nil, err
141143 }
144+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
142145
143146 from := opts.SourceFormat
144147 to := sdktranslator.FromString("gemini-cli")
@@ -215,6 +218,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 70 unmodified lines
215218 for scanner.Scan() {
216219 line := scanner.Bytes()
217220 appendAPIResponseChunk(ctx, e.cfg, line)
221+ if detail, ok := parseGeminiCLIStreamUsage(line); ok {
222+ reporter.publish(ctx, detail)
223+ }
218224 if bytes.HasPrefix(line, dataTag) {
219225 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), &param)
220226 for i := range segments {
@@ -239,6 +245,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 18 unmodified lines
239245 return
240246 }
241247 appendAPIResponseChunk(ctx, e.cfg, data)
248+ reporter.publish(ctx, parseGeminiCLIUsage(data))
242249 var param any
243250 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, &param)
244251 for i := range segments {
internal/runtime/executor/gemini_executor.go+6−0
@@ -43,6 +43,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 42 unmodified lines
4343 // Fallback to legacy client
4444 return NewClientAdapter("gemini").Execute(ctx, auth, req, opts)
4545 }
46+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4647
4748 // Official Gemini API via API key or OAuth bearer
4849 from := opts.SourceFormat
@@ -92,6 +93,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 43 unmodified lines
9293 return cliproxyexecutor.Response{}, err
9394 }
9495 appendAPIResponseChunk(ctx, e.cfg, data)
96+ reporter.publish(ctx, parseGeminiUsage(data))
9597 var param any
9698 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
9799 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -103,6 +105,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 5 unmodified lines
103105 // Fallback to legacy streaming
104106 return NewClientAdapter("gemini").ExecuteStream(ctx, auth, req, opts)
105107 }
108+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
106109
107110 from := opts.SourceFormat
108111 to := sdktranslator.FromString("gemini")
@@ -152,6 +155,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 43 unmodified lines
152155 for scanner.Scan() {
153156 line := scanner.Bytes()
154157 appendAPIResponseChunk(ctx, e.cfg, line)
158+ if detail, ok := parseGeminiStreamUsage(line); ok {
159+ reporter.publish(ctx, detail)
160+ }
155161 lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
156162 for i := range lines {
157163 out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}
internal/runtime/executor/gemini_web_executor.go+4−0
@@ -38,6 +38,7 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 37 unmodified lines
3838 if err = state.ensureClient(); err != nil {
3939 return cliproxyexecutor.Response{}, err
4040 }
41+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4142
4243 mutex := state.getRequestMutex()
4344 if mutex != nil {
@@ -51,6 +52,7 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 7 unmodified lines
5152 return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
5253 }
5354 resp = state.convertToTarget(ctx, req.Model, prep, resp)
55+ reporter.publish(ctx, parseGeminiUsage(resp))
5456
5557 from := opts.SourceFormat
5658 to := sdktranslator.FromString("gemini-web")
@@ -68,6 +70,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 11 unmodified lines
6870 if err = state.ensureClient(); err != nil {
6971 return nil, err
7072 }
73+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
7174
7275 mutex := state.getRequestMutex()
7376 if mutex != nil {
@@ -81,6 +84,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 7 unmodified lines
8184 }
8285 return nil, geminiWebErrorFromMessage(errMsg)
8386 }
87+ reporter.publish(ctx, parseGeminiUsage(gemBytes))
8488
8589 from := opts.SourceFormat
8690 to := sdktranslator.FromString("gemini-web")
internal/runtime/executor/openai_compat_executor.go+6−0
@@ -43,6 +43,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 42 unmodified lines
4343 if baseURL == "" || apiKey == "" {
4444 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
4545 }
46+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4647
4748 // Translate inbound request to OpenAI format
4849 from := opts.SourceFormat
@@ -82,6 +83,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 33 unmodified lines
8283 return cliproxyexecutor.Response{}, err
8384 }
8485 appendAPIResponseChunk(ctx, e.cfg, body)
86+ reporter.publish(ctx, parseOpenAIUsage(body))
8587 // Translate response back to source format when needed
8688 var param any
8789 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)
@@ -93,6 +95,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy · 5 unmodified lines
9395 if baseURL == "" || apiKey == "" {
9496 return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
9597 }
98+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
9699 from := opts.SourceFormat
97100 to := sdktranslator.FromString("openai")
98101 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
@@ -138,6 +141,9 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy · 39 unmodified lines
138141 for scanner.Scan() {
139142 line := scanner.Bytes()
140143 appendAPIResponseChunk(ctx, e.cfg, line)
144+ if detail, ok := parseOpenAIStreamUsage(line); ok {
145+ reporter.publish(ctx, detail)
146+ }
141147 if len(line) == 0 {
142148 continue
143149 }
internal/runtime/executor/qwen_executor.go+7−0
@@ -46,6 +46,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 45 unmodified lines
4646 if baseURL == "" {
4747 baseURL = "https://portal.qwen.ai/v1"
4848 }
49+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
4950
5051 from := opts.SourceFormat
5152 to := sdktranslator.FromString("openai")
@@ -79,6 +80,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 27 unmodified lines
7980 return cliproxyexecutor.Response{}, err
8081 }
8182 appendAPIResponseChunk(ctx, e.cfg, data)
83+ reporter.publish(ctx, parseOpenAIUsage(data))
8284 var param any
8385 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
8486 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -92,6 +94,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 7 unmodified lines
9294 if baseURL == "" {
9395 baseURL = "https://portal.qwen.ai/v1"
9496 }
97+ reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
9598
9699 from := opts.SourceFormat
97100 to := sdktranslator.FromString("openai")
@@ -103,6 +106,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 5 unmodified lines
103106 if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {
104107 body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`))
105108 }
109+ body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
106110
107111 url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
108112 recordAPIRequest(ctx, e.cfg, body)
@@ -138,6 +142,9 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 29 unmodified lines
138142 for scanner.Scan() {
139143 line := scanner.Bytes()
140144 appendAPIResponseChunk(ctx, e.cfg, line)
145+ if detail, ok := parseOpenAIStreamUsage(line); ok {
146+ reporter.publish(ctx, detail)
147+ }
141148 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
142149 for i := range chunks {
143150 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
internal/runtime/executor/usage_helpers.go+292−0
@@ -0,0 +1,292 @@
1+package executor
2+
3+import (
4+ "bytes"
5+ "context"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ "github.com/gin-gonic/gin"
11+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
12+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
13+ "github.com/tidwall/gjson"
14+)
15+
16+type usageReporter struct {
17+ provider string
18+ model string
19+ authID string
20+ apiKey string
21+ requestedAt time.Time
22+ once sync.Once
23+}
24+
25+func newUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *usageReporter {
26+ reporter := &usageReporter{
27+ provider: provider,
28+ model: model,
29+ requestedAt: time.Now(),
30+ }
31+ if auth != nil {
32+ reporter.authID = auth.ID
33+ }
34+ reporter.apiKey = apiKeyFromContext(ctx)
35+ return reporter
36+}
37+
38+func (r *usageReporter) publish(ctx context.Context, detail usage.Detail) {
39+ if r == nil {
40+ return
41+ }
42+ if detail.TotalTokens == 0 {
43+ total := detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
44+ if total > 0 {
45+ detail.TotalTokens = total
46+ }
47+ }
48+ if detail.InputTokens == 0 && detail.OutputTokens == 0 && detail.ReasoningTokens == 0 && detail.CachedTokens == 0 && detail.TotalTokens == 0 {
49+ return
50+ }
51+ r.once.Do(func() {
52+ usage.PublishRecord(ctx, usage.Record{
53+ Provider: r.provider,
54+ Model: r.model,
55+ APIKey: r.apiKey,
56+ AuthID: r.authID,
57+ RequestedAt: r.requestedAt,
58+ Detail: detail,
59+ })
60+ })
61+}
62+
63+func apiKeyFromContext(ctx context.Context) string {
64+ if ctx == nil {
65+ return ""
66+ }
67+ ginCtx, ok := ctx.Value("gin").(*gin.Context)
68+ if !ok || ginCtx == nil {
69+ return ""
70+ }
71+ if v, exists := ginCtx.Get("apiKey"); exists {
72+ switch value := v.(type) {
73+ case string:
74+ return value
75+ case fmt.Stringer:
76+ return value.String()
77+ default:
78+ return fmt.Sprintf("%v", value)
79+ }
80+ }
81+ return ""
82+}
83+
84+func parseCodexUsage(data []byte) (usage.Detail, bool) {
85+ usageNode := gjson.ParseBytes(data).Get("response.usage")
86+ if !usageNode.Exists() {
87+ return usage.Detail{}, false
88+ }
89+ detail := usage.Detail{
90+ InputTokens: usageNode.Get("input_tokens").Int(),
91+ OutputTokens: usageNode.Get("output_tokens").Int(),
92+ TotalTokens: usageNode.Get("total_tokens").Int(),
93+ }
94+ if cached := usageNode.Get("input_tokens_details.cached_tokens"); cached.Exists() {
95+ detail.CachedTokens = cached.Int()
96+ }
97+ if reasoning := usageNode.Get("output_tokens_details.reasoning_tokens"); reasoning.Exists() {
98+ detail.ReasoningTokens = reasoning.Int()
99+ }
100+ return detail, true
101+}
102+
103+func parseOpenAIUsage(data []byte) usage.Detail {
104+ usageNode := gjson.ParseBytes(data).Get("usage")
105+ if !usageNode.Exists() {
106+ return usage.Detail{}
107+ }
108+ detail := usage.Detail{
109+ InputTokens: usageNode.Get("prompt_tokens").Int(),
110+ OutputTokens: usageNode.Get("completion_tokens").Int(),
111+ TotalTokens: usageNode.Get("total_tokens").Int(),
112+ }
113+ if cached := usageNode.Get("prompt_tokens_details.cached_tokens"); cached.Exists() {
114+ detail.CachedTokens = cached.Int()
115+ }
116+ if reasoning := usageNode.Get("completion_tokens_details.reasoning_tokens"); reasoning.Exists() {
117+ detail.ReasoningTokens = reasoning.Int()
118+ }
119+ return detail
120+}
121+
122+func parseOpenAIStreamUsage(line []byte) (usage.Detail, bool) {
123+ payload := jsonPayload(line)
124+ if len(payload) == 0 || !gjson.ValidBytes(payload) {
125+ return usage.Detail{}, false
126+ }
127+ usageNode := gjson.GetBytes(payload, "usage")
128+ if !usageNode.Exists() {
129+ return usage.Detail{}, false
130+ }
131+ detail := usage.Detail{
132+ InputTokens: usageNode.Get("prompt_tokens").Int(),
133+ OutputTokens: usageNode.Get("completion_tokens").Int(),
134+ TotalTokens: usageNode.Get("total_tokens").Int(),
135+ }
136+ if cached := usageNode.Get("prompt_tokens_details.cached_tokens"); cached.Exists() {
137+ detail.CachedTokens = cached.Int()
138+ }
139+ if reasoning := usageNode.Get("completion_tokens_details.reasoning_tokens"); reasoning.Exists() {
140+ detail.ReasoningTokens = reasoning.Int()
141+ }
142+ return detail, true
143+}
144+
145+func parseClaudeUsage(data []byte) usage.Detail {
146+ usageNode := gjson.ParseBytes(data).Get("usage")
147+ if !usageNode.Exists() {
148+ return usage.Detail{}
149+ }
150+ detail := usage.Detail{
151+ InputTokens: usageNode.Get("input_tokens").Int(),
152+ OutputTokens: usageNode.Get("output_tokens").Int(),
153+ CachedTokens: usageNode.Get("cache_read_input_tokens").Int(),
154+ }
155+ if detail.CachedTokens == 0 {
156+ // fall back to creation tokens when read tokens are absent
157+ detail.CachedTokens = usageNode.Get("cache_creation_input_tokens").Int()
158+ }
159+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens
160+ return detail
161+}
162+
163+func parseClaudeStreamUsage(line []byte) (usage.Detail, bool) {
164+ payload := jsonPayload(line)
165+ if len(payload) == 0 || !gjson.ValidBytes(payload) {
166+ return usage.Detail{}, false
167+ }
168+ usageNode := gjson.GetBytes(payload, "usage")
169+ if !usageNode.Exists() {
170+ return usage.Detail{}, false
171+ }
172+ detail := usage.Detail{
173+ InputTokens: usageNode.Get("input_tokens").Int(),
174+ OutputTokens: usageNode.Get("output_tokens").Int(),
175+ CachedTokens: usageNode.Get("cache_read_input_tokens").Int(),
176+ }
177+ if detail.CachedTokens == 0 {
178+ detail.CachedTokens = usageNode.Get("cache_creation_input_tokens").Int()
179+ }
180+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens
181+ return detail, true
182+}
183+
184+func parseGeminiCLIUsage(data []byte) usage.Detail {
185+ usageNode := gjson.ParseBytes(data)
186+ node := usageNode.Get("response.usageMetadata")
187+ if !node.Exists() {
188+ node = usageNode.Get("response.usage_metadata")
189+ }
190+ if !node.Exists() {
191+ return usage.Detail{}
192+ }
193+ detail := usage.Detail{
194+ InputTokens: node.Get("promptTokenCount").Int(),
195+ OutputTokens: node.Get("candidatesTokenCount").Int(),
196+ ReasoningTokens: node.Get("thoughtsTokenCount").Int(),
197+ TotalTokens: node.Get("totalTokenCount").Int(),
198+ }
199+ if detail.TotalTokens == 0 {
200+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
201+ }
202+ return detail
203+}
204+
205+func parseGeminiUsage(data []byte) usage.Detail {
206+ usageNode := gjson.ParseBytes(data)
207+ node := usageNode.Get("usageMetadata")
208+ if !node.Exists() {
209+ node = usageNode.Get("usage_metadata")
210+ }
211+ if !node.Exists() {
212+ return usage.Detail{}
213+ }
214+ detail := usage.Detail{
215+ InputTokens: node.Get("promptTokenCount").Int(),
216+ OutputTokens: node.Get("candidatesTokenCount").Int(),
217+ ReasoningTokens: node.Get("thoughtsTokenCount").Int(),
218+ TotalTokens: node.Get("totalTokenCount").Int(),
219+ }
220+ if detail.TotalTokens == 0 {
221+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
222+ }
223+ return detail
224+}
225+
226+func parseGeminiStreamUsage(line []byte) (usage.Detail, bool) {
227+ payload := jsonPayload(line)
228+ if len(payload) == 0 || !gjson.ValidBytes(payload) {
229+ return usage.Detail{}, false
230+ }
231+ node := gjson.GetBytes(payload, "usageMetadata")
232+ if !node.Exists() {
233+ node = gjson.GetBytes(payload, "usage_metadata")
234+ }
235+ if !node.Exists() {
236+ return usage.Detail{}, false
237+ }
238+ detail := usage.Detail{
239+ InputTokens: node.Get("promptTokenCount").Int(),
240+ OutputTokens: node.Get("candidatesTokenCount").Int(),
241+ ReasoningTokens: node.Get("thoughtsTokenCount").Int(),
242+ TotalTokens: node.Get("totalTokenCount").Int(),
243+ }
244+ if detail.TotalTokens == 0 {
245+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
246+ }
247+ return detail, true
248+}
249+
250+func parseGeminiCLIStreamUsage(line []byte) (usage.Detail, bool) {
251+ payload := jsonPayload(line)
252+ if len(payload) == 0 || !gjson.ValidBytes(payload) {
253+ return usage.Detail{}, false
254+ }
255+ node := gjson.GetBytes(payload, "response.usageMetadata")
256+ if !node.Exists() {
257+ node = gjson.GetBytes(payload, "usage_metadata")
258+ }
259+ if !node.Exists() {
260+ return usage.Detail{}, false
261+ }
262+ detail := usage.Detail{
263+ InputTokens: node.Get("promptTokenCount").Int(),
264+ OutputTokens: node.Get("candidatesTokenCount").Int(),
265+ ReasoningTokens: node.Get("thoughtsTokenCount").Int(),
266+ TotalTokens: node.Get("totalTokenCount").Int(),
267+ }
268+ if detail.TotalTokens == 0 {
269+ detail.TotalTokens = detail.InputTokens + detail.OutputTokens + detail.ReasoningTokens
270+ }
271+ return detail, true
272+}
273+
274+func jsonPayload(line []byte) []byte {
275+ trimmed := bytes.TrimSpace(line)
276+ if len(trimmed) == 0 {
277+ return nil
278+ }
279+ if bytes.Equal(trimmed, []byte("[DONE]")) {
280+ return nil
281+ }
282+ if bytes.HasPrefix(trimmed, []byte("event:")) {
283+ return nil
284+ }
285+ if bytes.HasPrefix(trimmed, []byte("data:")) {
286+ trimmed = bytes.TrimSpace(trimmed[len("data:"):])
287+ }
288+ if len(trimmed) == 0 || trimmed[0] != '{' {
289+ return nil
290+ }
291+ return trimmed
292+}
internal/translator/claude/openai/chat-completions/claude_openai_response.go+3−9
@@ -6,7 +6,6 @@ · 5 unmodified lines
66 package chat_completions
77
88 import (
9− "bufio"
109 "bytes"
1110 "context"
1211 "encoding/json"
@@ -278,19 +277,14 @@ func mapAnthropicStopReasonToOpenAI(anthropicReason string) string { · 265 unmodified lines
278277 // Returns:
279278 // - string: An OpenAI-compatible JSON response containing all message content and metadata
280279 func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
281−
282280 chunks := make([][]byte, 0)
283281
284− scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
285− buffer := make([]byte, 10240*1024)
286− scanner.Buffer(buffer, 10240*1024)
287− for scanner.Scan() {
288− line := scanner.Bytes()
289− // log.Debug(string(line))
282+ lines := bytes.Split(rawJSON, []byte("\n"))
283+ for _, line := range lines {
290284 if !bytes.HasPrefix(line, dataTag) {
291285 continue
292286 }
293− chunks = append(chunks, bytes.TrimSpace(rawJSON[5:]))
287+ chunks = append(chunks, bytes.TrimSpace(line[5:]))
294288 }
295289
296290 // Base OpenAI non-streaming response template
internal/translator/codex/openai/chat-completions/codex_openai_response.go+107−120
@@ -6,7 +6,6 @@ · 5 unmodified lines
66 package chat_completions
77
88 import (
9− "bufio"
109 "bytes"
1110 "context"
1211 "time"
@@ -166,153 +165,141 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR · 153 unmodified lines
166165 // Returns:
167166 // - string: An OpenAI-compatible JSON response containing all message content and metadata
168167 func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
169− scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
170− buffer := make([]byte, 10240*1024)
171− scanner.Buffer(buffer, 10240*1024)
172− for scanner.Scan() {
173− line := scanner.Bytes()
174− // log.Debug(string(line))
175− if !bytes.HasPrefix(line, dataTag) {
176− continue
177− }
178− rawJSON = bytes.TrimSpace(rawJSON[5:])
168+ rootResult := gjson.ParseBytes(rawJSON)
169+ // Verify this is a response.completed event
170+ if rootResult.Get("type").String() != "response.completed" {
171+ return ""
172+ }
179173
180− rootResult := gjson.ParseBytes(rawJSON)
181− // Verify this is a response.completed event
182− if rootResult.Get("type").String() != "response.completed" {
183− continue
184− }
185− unixTimestamp := time.Now().Unix()
174+ unixTimestamp := time.Now().Unix()
186175
187− responseResult := rootResult.Get("response")
176+ responseResult := rootResult.Get("response")
188177
189− template := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
178+ template := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
190179
191− // Extract and set the model version.
192− if modelResult := responseResult.Get("model"); modelResult.Exists() {
193− template, _ = sjson.Set(template, "model", modelResult.String())
194− }
180+ // Extract and set the model version.
181+ if modelResult := responseResult.Get("model"); modelResult.Exists() {
182+ template, _ = sjson.Set(template, "model", modelResult.String())
183+ }
195184
196− // Extract and set the creation timestamp.
197− if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {
198− template, _ = sjson.Set(template, "created", createdAtResult.Int())
199− } else {
200− template, _ = sjson.Set(template, "created", unixTimestamp)
201− }
185+ // Extract and set the creation timestamp.
186+ if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {
187+ template, _ = sjson.Set(template, "created", createdAtResult.Int())
188+ } else {
189+ template, _ = sjson.Set(template, "created", unixTimestamp)
190+ }
202191
203− // Extract and set the response ID.
204− if idResult := responseResult.Get("id"); idResult.Exists() {
205− template, _ = sjson.Set(template, "id", idResult.String())
206− }
192+ // Extract and set the response ID.
193+ if idResult := responseResult.Get("id"); idResult.Exists() {
194+ template, _ = sjson.Set(template, "id", idResult.String())
195+ }
207196
208− // Extract and set usage metadata (token counts).
209− if usageResult := responseResult.Get("usage"); usageResult.Exists() {
210− if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
211− template, _ = sjson.Set(template, "usage.completion_tokens", outputTokensResult.Int())
212− }
213− if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
214− template, _ = sjson.Set(template, "usage.total_tokens", totalTokensResult.Int())
215− }
216− if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
217− template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokensResult.Int())
218− }
219− if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
220− template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
221− }
197+ // Extract and set usage metadata (token counts).
198+ if usageResult := responseResult.Get("usage"); usageResult.Exists() {
199+ if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
200+ template, _ = sjson.Set(template, "usage.completion_tokens", outputTokensResult.Int())
201+ }
202+ if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
203+ template, _ = sjson.Set(template, "usage.total_tokens", totalTokensResult.Int())
222204 }
205+ if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
206+ template, _ = sjson.Set(template, "usage.prompt_tokens", inputTokensResult.Int())
207+ }
208+ if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
209+ template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
210+ }
211+ }
223212
224− // Process the output array for content and function calls
225− outputResult := responseResult.Get("output")
226− if outputResult.IsArray() {
227− outputArray := outputResult.Array()
228− var contentText string
229− var reasoningText string
230− var toolCalls []string
231−
232− for _, outputItem := range outputArray {
233− outputType := outputItem.Get("type").String()
234−
235− switch outputType {
236− case "reasoning":
237− // Extract reasoning content from summary
238− if summaryResult := outputItem.Get("summary"); summaryResult.IsArray() {
239− summaryArray := summaryResult.Array()
240− for _, summaryItem := range summaryArray {
241− if summaryItem.Get("type").String() == "summary_text" {
242− reasoningText = summaryItem.Get("text").String()
243− break
244− }
213+ // Process the output array for content and function calls
214+ outputResult := responseResult.Get("output")
215+ if outputResult.IsArray() {
216+ outputArray := outputResult.Array()
217+ var contentText string
218+ var reasoningText string
219+ var toolCalls []string
220+
221+ for _, outputItem := range outputArray {
222+ outputType := outputItem.Get("type").String()
223+
224+ switch outputType {
225+ case "reasoning":
226+ // Extract reasoning content from summary
227+ if summaryResult := outputItem.Get("summary"); summaryResult.IsArray() {
228+ summaryArray := summaryResult.Array()
229+ for _, summaryItem := range summaryArray {
230+ if summaryItem.Get("type").String() == "summary_text" {
231+ reasoningText = summaryItem.Get("text").String()
232+ break
245233 }
246234 }
247− case "message":
248− // Extract message content
249− if contentResult := outputItem.Get("content"); contentResult.IsArray() {
250− contentArray := contentResult.Array()
251− for _, contentItem := range contentArray {
252− if contentItem.Get("type").String() == "output_text" {
253− contentText = contentItem.Get("text").String()
254− break
255− }
235+ }
236+ case "message":
237+ // Extract message content
238+ if contentResult := outputItem.Get("content"); contentResult.IsArray() {
239+ contentArray := contentResult.Array()
240+ for _, contentItem := range contentArray {
241+ if contentItem.Get("type").String() == "output_text" {
242+ contentText = contentItem.Get("text").String()
243+ break
256244 }
257245 }
258− case "function_call":
259− // Handle function call content
260− functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
261−
262− if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
263− functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", callIdResult.String())
264− }
246+ }
247+ case "function_call":
248+ // Handle function call content
249+ functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
265250
266− if nameResult := outputItem.Get("name"); nameResult.Exists() {
267− n := nameResult.String()
268− rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
269− if orig, ok := rev[n]; ok {
270− n = orig
271− }
272− functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", n)
273− }
251+ if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
252+ functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", callIdResult.String())
253+ }
274254
275− if argsResult := outputItem.Get("arguments"); argsResult.Exists() {
276− functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", argsResult.String())
255+ if nameResult := outputItem.Get("name"); nameResult.Exists() {
256+ n := nameResult.String()
257+ rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
258+ if orig, ok := rev[n]; ok {
259+ n = orig
277260 }
261+ functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", n)
262+ }
278263
279− toolCalls = append(toolCalls, functionCallTemplate)
264+ if argsResult := outputItem.Get("arguments"); argsResult.Exists() {
265+ functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", argsResult.String())
280266 }
281− }
282267
283− // Set content and reasoning content if found
284− if contentText != "" {
285− template, _ = sjson.Set(template, "choices.0.message.content", contentText)
286− template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
268+ toolCalls = append(toolCalls, functionCallTemplate)
287269 }
270+ }
288271
289− if reasoningText != "" {
290− template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningText)
291− template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
292− }
272+ // Set content and reasoning content if found
273+ if contentText != "" {
274+ template, _ = sjson.Set(template, "choices.0.message.content", contentText)
275+ template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
276+ }
293277
294− // Add tool calls if any
295− if len(toolCalls) > 0 {
296− template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
297− for _, toolCall := range toolCalls {
298− template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", toolCall)
299− }
300− template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
301− }
278+ if reasoningText != "" {
279+ template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningText)
280+ template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
302281 }
303282
304− // Extract and set the finish reason based on status
305− if statusResult := responseResult.Get("status"); statusResult.Exists() {
306− status := statusResult.String()
307− if status == "completed" {
308− template, _ = sjson.Set(template, "choices.0.finish_reason", "stop")
309− template, _ = sjson.Set(template, "choices.0.native_finish_reason", "stop")
283+ // Add tool calls if any
284+ if len(toolCalls) > 0 {
285+ template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
286+ for _, toolCall := range toolCalls {
287+ template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", toolCall)
310288 }
289+ template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
311290 }
291+ }
312292
313− return template
293+ // Extract and set the finish reason based on status
294+ if statusResult := responseResult.Get("status"); statusResult.Exists() {
295+ status := statusResult.String()
296+ if status == "completed" {
297+ template, _ = sjson.Set(template, "choices.0.finish_reason", "stop")
298+ template, _ = sjson.Set(template, "choices.0.native_finish_reason", "stop")
299+ }
314300 }
315− return ""
301+
302+ return template
316303 }
317304
318305 // buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name
internal/usage/logger_plugin.go+26−0
@@ -0,0 +1,26 @@
1+package usage
2+
3+import (
4+ "context"
5+ "encoding/json"
6+
7+ coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
8+ log "github.com/sirupsen/logrus"
9+)
10+
11+func init() {
12+ coreusage.RegisterPlugin(NewLoggerPlugin())
13+}
14+
15+// LoggerPlugin outputs every usage record to the application log.
16+type LoggerPlugin struct{}
17+
18+// NewLoggerPlugin constructs a new logger plugin instance.
19+func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{} }
20+
21+// HandleUsage implements coreusage.Plugin.
22+func (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) {
23+ // Output all relevant fields for observability; keep logging lightweight and non-blocking.
24+ data, _ := json.Marshal(record)
25+ log.Debug(string(data))
26+}
sdk/cliproxy/service.go+11−0
@@ -14,12 +14,14 @@ import ( · 13 unmodified lines
1414 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
1515 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
1616 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
17+ _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
1718 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
1819 "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
1920 sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
2021 _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey"
2122 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
2223 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
24+ "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
2325 log "github.com/sirupsen/logrus"
2426 )
2527
@@ -51,6 +53,11 @@ type Service struct { · 25 unmodified lines
5153 shutdownOnce sync.Once
5254 }
5355
56+// RegisterUsagePlugin registers a usage plugin on the global usage manager.
57+func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {
58+ usage.RegisterPlugin(plugin)
59+}
60+
5461 func newDefaultAuthManager() *sdkAuth.Manager {
5562 return sdkAuth.NewManager(
5663 sdkAuth.NewFileTokenStore(),
@@ -217,6 +224,8 @@ func (s *Service) Run(ctx context.Context) error { · 160 unmodified lines
217224 ctx = context.Background()
218225 }
219226
227+ usage.StartDefault(ctx)
228+
220229 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
221230 defer shutdownCancel()
222231 defer func() {
@@ -388,6 +397,8 @@ func (s *Service) Shutdown(ctx context.Context) error { · 165 unmodified lines
388397 }
389398 }
390399 }
400+
401+ usage.StopDefault()
391402 })
392403 return shutdownErr
393404 }
sdk/cliproxy/usage/manager.go+182−0
@@ -0,0 +1,182 @@
1+package usage
2+
3+import (
4+ "context"
5+ "sync"
6+ "time"
7+
8+ log "github.com/sirupsen/logrus"
9+)
10+
11+// Record contains the usage statistics captured for a single provider request.
12+type Record struct {
13+ Provider string
14+ Model string
15+ APIKey string
16+ AuthID string
17+ RequestedAt time.Time
18+ Detail Detail
19+}
20+
21+// Detail holds the token usage breakdown.
22+type Detail struct {
23+ InputTokens int64
24+ OutputTokens int64
25+ ReasoningTokens int64
26+ CachedTokens int64
27+ TotalTokens int64
28+}
29+
30+// Plugin consumes usage records emitted by the proxy runtime.
31+type Plugin interface {
32+ HandleUsage(ctx context.Context, record Record)
33+}
34+
35+type queueItem struct {
36+ ctx context.Context
37+ record Record
38+}
39+
40+// Manager maintains a queue of usage records and delivers them to registered plugins.
41+type Manager struct {
42+ once sync.Once
43+ stopOnce sync.Once
44+ cancel context.CancelFunc
45+ queue chan queueItem
46+
47+ pluginsMu sync.RWMutex
48+ plugins []Plugin
49+}
50+
51+// NewManager constructs a manager with a buffered queue.
52+func NewManager(buffer int) *Manager {
53+ if buffer <= 0 {
54+ buffer = 256
55+ }
56+ return &Manager{queue: make(chan queueItem, buffer)}
57+}
58+
59+// Start launches the background dispatcher. Calling Start multiple times is safe.
60+func (m *Manager) Start(ctx context.Context) {
61+ if m == nil {
62+ return
63+ }
64+ m.once.Do(func() {
65+ if ctx == nil {
66+ ctx = context.Background()
67+ }
68+ var workerCtx context.Context
69+ workerCtx, m.cancel = context.WithCancel(ctx)
70+ go m.run(workerCtx)
71+ })
72+}
73+
74+// Stop stops the dispatcher and drains the queue.
75+func (m *Manager) Stop() {
76+ if m == nil {
77+ return
78+ }
79+ m.stopOnce.Do(func() {
80+ if m.cancel != nil {
81+ m.cancel()
82+ }
83+ close(m.queue)
84+ })
85+}
86+
87+// Register appends a plugin to the delivery list.
88+func (m *Manager) Register(plugin Plugin) {
89+ if m == nil || plugin == nil {
90+ return
91+ }
92+ m.pluginsMu.Lock()
93+ m.plugins = append(m.plugins, plugin)
94+ m.pluginsMu.Unlock()
95+}
96+
97+// Publish enqueues a usage record for processing. If no plugin is registered
98+// the record will be discarded downstream.
99+func (m *Manager) Publish(ctx context.Context, record Record) {
100+ if m == nil {
101+ return
102+ }
103+ // ensure worker is running even if Start was not called explicitly
104+ m.Start(context.Background())
105+ select {
106+ case m.queue <- queueItem{ctx: ctx, record: record}:
107+ default:
108+ // queue is full; drop the record to avoid blocking runtime paths
109+ log.Debugf("usage: queue full, dropping record for provider %s", record.Provider)
110+ }
111+}
112+
113+func (m *Manager) run(ctx context.Context) {
114+ for {
115+ select {
116+ case <-ctx.Done():
117+ m.drain()
118+ return
119+ case item, ok := <-m.queue:
120+ if !ok {
121+ return
122+ }
123+ m.dispatch(item)
124+ }
125+ }
126+}
127+
128+func (m *Manager) drain() {
129+ for {
130+ select {
131+ case item, ok := <-m.queue:
132+ if !ok {
133+ return
134+ }
135+ m.dispatch(item)
136+ default:
137+ return
138+ }
139+ }
140+}
141+
142+func (m *Manager) dispatch(item queueItem) {
143+ m.pluginsMu.RLock()
144+ plugins := make([]Plugin, len(m.plugins))
145+ copy(plugins, m.plugins)
146+ m.pluginsMu.RUnlock()
147+ if len(plugins) == 0 {
148+ return
149+ }
150+ for _, plugin := range plugins {
151+ if plugin == nil {
152+ continue
153+ }
154+ safeInvoke(plugin, item.ctx, item.record)
155+ }
156+}
157+
158+func safeInvoke(plugin Plugin, ctx context.Context, record Record) {
159+ defer func() {
160+ if r := recover(); r != nil {
161+ log.Errorf("usage: plugin panic recovered: %v", r)
162+ }
163+ }()
164+ plugin.HandleUsage(ctx, record)
165+}
166+
167+var defaultManager = NewManager(512)
168+
169+// DefaultManager returns the global usage manager instance.
170+func DefaultManager() *Manager { return defaultManager }
171+
172+// RegisterPlugin registers a plugin on the default manager.
173+func RegisterPlugin(plugin Plugin) { DefaultManager().Register(plugin) }
174+
175+// PublishRecord publishes a record using the default manager.
176+func PublishRecord(ctx context.Context, record Record) { DefaultManager().Publish(ctx, record) }
177+
178+// StartDefault starts the default manager's dispatcher.
179+func StartDefault(ctx context.Context) { DefaultManager().Start(ctx) }
180+
181+// StopDefault stops the default manager's dispatcher.
182+func StopDefault() { DefaultManager().Stop() }