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
43 if baseURL == "" {43 if baseURL == "" {
44 baseURL = "https://api.anthropic.com"44 baseURL = "https://api.anthropic.com"
45 }45 }
46 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
46 from := opts.SourceFormat47 from := opts.SourceFormat
47 to := sdktranslator.FromString("claude")48 to := sdktranslator.FromString("claude")
48 // Use streaming translation to preserve function calling, except for claude.49 // 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)
50 52
51 if !strings.HasPrefix(req.Model, "claude-3-5-haiku") {53 if !strings.HasPrefix(req.Model, "claude-3-5-haiku") {
52 body, _ = sjson.SetRawBytes(body, "system", []byte(misc.ClaudeCodeInstructions))54 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
94 return cliproxyexecutor.Response{}, err96 return cliproxyexecutor.Response{}, err
95 }97 }
96 appendAPIResponseChunk(ctx, e.cfg, data)98 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 }
97 var param any109 var param any
98 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)110 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
99 return cliproxyexecutor.Response{Payload: []byte(out)}, nil111 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -107,6 +119,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 7 unmodified lines
107 if baseURL == "" {119 if baseURL == "" {
108 baseURL = "https://api.anthropic.com"120 baseURL = "https://api.anthropic.com"
109 }121 }
122 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
110 from := opts.SourceFormat123 from := opts.SourceFormat
111 to := sdktranslator.FromString("claude")124 to := sdktranslator.FromString("claude")
112 body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)125 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
146 for scanner.Scan() {159 for scanner.Scan() {
147 line := scanner.Bytes()160 line := scanner.Bytes()
148 appendAPIResponseChunk(ctx, e.cfg, line)161 appendAPIResponseChunk(ctx, e.cfg, line)
162 if detail, ok := parseClaudeStreamUsage(line); ok {
163 reporter.publish(ctx, detail)
164 }
149 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)165 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
150 for i := range chunks {166 for i := range chunks {
151 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}167 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
internal/runtime/executor/codex_executor.go+38−3
@@ -18,12 +18,15 @@ import ( · 17 unmodified lines
18 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"18 cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
19 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"19 sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
20 log "github.com/sirupsen/logrus"20 log "github.com/sirupsen/logrus"
21 "github.com/tidwall/gjson"
21 "github.com/tidwall/sjson"22 "github.com/tidwall/sjson"
22 23
23 "github.com/gin-gonic/gin"24 "github.com/gin-gonic/gin"
24 "github.com/google/uuid"25 "github.com/google/uuid"
25)26)
26 27
28var dataTag = []byte("data:")
29
27// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).30// CodexExecutor is a stateless executor for Codex (OpenAI Responses API entrypoint).
28// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.31// If api_key is unavailable on auth, it falls back to legacy via ClientAdapter.
29type CodexExecutor struct {32type CodexExecutor struct {
@@ -44,6 +47,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 14 unmodified lines
44 if baseURL == "" {47 if baseURL == "" {
45 baseURL = "https://chatgpt.com/backend-api/codex"48 baseURL = "https://chatgpt.com/backend-api/codex"
46 }49 }
50 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
47 51
48 from := opts.SourceFormat52 from := opts.SourceFormat
49 to := sdktranslator.FromString("codex")53 to := sdktranslator.FromString("codex")
@@ -75,6 +79,8 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re · 25 unmodified lines
75 }79 }
76 }80 }
77 81
82 body, _ = sjson.SetBytes(body, "stream", true)
83
78 url := strings.TrimSuffix(baseURL, "/") + "/responses"84 url := strings.TrimSuffix(baseURL, "/") + "/responses"
79 recordAPIRequest(ctx, e.cfg, body)85 recordAPIRequest(ctx, e.cfg, body)
80 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))86 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
103 return cliproxyexecutor.Response{}, err109 return cliproxyexecutor.Response{}, err
104 }110 }
105 appendAPIResponseChunk(ctx, e.cfg, data)111 appendAPIResponseChunk(ctx, e.cfg, data)
106 var param any112
107 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)113 lines := bytes.Split(data, []byte("\n"))
108 return cliproxyexecutor.Response{Payload: []byte(out)}, nil114 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"}
109}133}
110 134
111func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {135func (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
116 if baseURL == "" {140 if baseURL == "" {
117 baseURL = "https://chatgpt.com/backend-api/codex"141 baseURL = "https://chatgpt.com/backend-api/codex"
118 }142 }
143 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
119 144
120 from := opts.SourceFormat145 from := opts.SourceFormat
121 to := sdktranslator.FromString("codex")146 to := sdktranslator.FromString("codex")
@@ -181,6 +206,16 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au · 59 unmodified lines
181 for scanner.Scan() {206 for scanner.Scan() {
182 line := scanner.Bytes()207 line := scanner.Bytes()
183 appendAPIResponseChunk(ctx, e.cfg, line)208 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
184 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)219 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
185 for i := range chunks {220 for i := range chunks {
186 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}221 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
54 if err != nil {54 if err != nil {
55 return cliproxyexecutor.Response{}, err55 return cliproxyexecutor.Response{}, err
56 }56 }
57 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
57 58
58 from := opts.SourceFormat59 from := opts.SourceFormat
59 to := sdktranslator.FromString("gemini-cli")60 to := sdktranslator.FromString("gemini-cli")
@@ -117,6 +118,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 57 unmodified lines
117 _ = resp.Body.Close()118 _ = resp.Body.Close()
118 appendAPIResponseChunk(ctx, e.cfg, data)119 appendAPIResponseChunk(ctx, e.cfg, data)
119 if resp.StatusCode >= 200 && resp.StatusCode < 300 {120 if resp.StatusCode >= 200 && resp.StatusCode < 300 {
121 reporter.publish(ctx, parseGeminiCLIUsage(data))
120 var param any122 var param any
121 out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, &param)123 out := sdktranslator.TranslateNonStream(respCtx, to, from, attemptModel, bytes.Clone(opts.OriginalRequest), payload, data, &param)
122 return cliproxyexecutor.Response{Payload: []byte(out)}, nil124 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -139,6 +141,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 16 unmodified lines
139 if err != nil {141 if err != nil {
140 return nil, err142 return nil, err
141 }143 }
144 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
142 145
143 from := opts.SourceFormat146 from := opts.SourceFormat
144 to := sdktranslator.FromString("gemini-cli")147 to := sdktranslator.FromString("gemini-cli")
@@ -215,6 +218,9 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 70 unmodified lines
215 for scanner.Scan() {218 for scanner.Scan() {
216 line := scanner.Bytes()219 line := scanner.Bytes()
217 appendAPIResponseChunk(ctx, e.cfg, line)220 appendAPIResponseChunk(ctx, e.cfg, line)
221 if detail, ok := parseGeminiCLIStreamUsage(line); ok {
222 reporter.publish(ctx, detail)
223 }
218 if bytes.HasPrefix(line, dataTag) {224 if bytes.HasPrefix(line, dataTag) {
219 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), &param)225 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, bytes.Clone(line), &param)
220 for i := range segments {226 for i := range segments {
@@ -239,6 +245,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 18 unmodified lines
239 return245 return
240 }246 }
241 appendAPIResponseChunk(ctx, e.cfg, data)247 appendAPIResponseChunk(ctx, e.cfg, data)
248 reporter.publish(ctx, parseGeminiCLIUsage(data))
242 var param any249 var param any
243 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, &param)250 segments := sdktranslator.TranslateStream(respCtx, to, from, attempt, bytes.Clone(opts.OriginalRequest), reqBody, data, &param)
244 for i := range segments {251 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
43 // Fallback to legacy client43 // Fallback to legacy client
44 return NewClientAdapter("gemini").Execute(ctx, auth, req, opts)44 return NewClientAdapter("gemini").Execute(ctx, auth, req, opts)
45 }45 }
46 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
46 47
47 // Official Gemini API via API key or OAuth bearer48 // Official Gemini API via API key or OAuth bearer
48 from := opts.SourceFormat49 from := opts.SourceFormat
@@ -92,6 +93,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r · 43 unmodified lines
92 return cliproxyexecutor.Response{}, err93 return cliproxyexecutor.Response{}, err
93 }94 }
94 appendAPIResponseChunk(ctx, e.cfg, data)95 appendAPIResponseChunk(ctx, e.cfg, data)
96 reporter.publish(ctx, parseGeminiUsage(data))
95 var param any97 var param any
96 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)98 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
97 return cliproxyexecutor.Response{Payload: []byte(out)}, nil99 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -103,6 +105,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 5 unmodified lines
103 // Fallback to legacy streaming105 // Fallback to legacy streaming
104 return NewClientAdapter("gemini").ExecuteStream(ctx, auth, req, opts)106 return NewClientAdapter("gemini").ExecuteStream(ctx, auth, req, opts)
105 }107 }
108 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
106 109
107 from := opts.SourceFormat110 from := opts.SourceFormat
108 to := sdktranslator.FromString("gemini")111 to := sdktranslator.FromString("gemini")
@@ -152,6 +155,9 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A · 43 unmodified lines
152 for scanner.Scan() {155 for scanner.Scan() {
153 line := scanner.Bytes()156 line := scanner.Bytes()
154 appendAPIResponseChunk(ctx, e.cfg, line)157 appendAPIResponseChunk(ctx, e.cfg, line)
158 if detail, ok := parseGeminiStreamUsage(line); ok {
159 reporter.publish(ctx, detail)
160 }
155 lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)161 lines := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
156 for i := range lines {162 for i := range lines {
157 out <- cliproxyexecutor.StreamChunk{Payload: []byte(lines[i])}163 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
38 if err = state.ensureClient(); err != nil {38 if err = state.ensureClient(); err != nil {
39 return cliproxyexecutor.Response{}, err39 return cliproxyexecutor.Response{}, err
40 }40 }
41 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
41 42
42 mutex := state.getRequestMutex()43 mutex := state.getRequestMutex()
43 if mutex != nil {44 if mutex != nil {
@@ -51,6 +52,7 @@ func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth · 7 unmodified lines
51 return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)52 return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
52 }53 }
53 resp = state.convertToTarget(ctx, req.Model, prep, resp)54 resp = state.convertToTarget(ctx, req.Model, prep, resp)
55 reporter.publish(ctx, parseGeminiUsage(resp))
54 56
55 from := opts.SourceFormat57 from := opts.SourceFormat
56 to := sdktranslator.FromString("gemini-web")58 to := sdktranslator.FromString("gemini-web")
@@ -68,6 +70,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 11 unmodified lines
68 if err = state.ensureClient(); err != nil {70 if err = state.ensureClient(); err != nil {
69 return nil, err71 return nil, err
70 }72 }
73 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
71 74
72 mutex := state.getRequestMutex()75 mutex := state.getRequestMutex()
73 if mutex != nil {76 if mutex != nil {
@@ -81,6 +84,7 @@ func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut · 7 unmodified lines
81 }84 }
82 return nil, geminiWebErrorFromMessage(errMsg)85 return nil, geminiWebErrorFromMessage(errMsg)
83 }86 }
87 reporter.publish(ctx, parseGeminiUsage(gemBytes))
84 88
85 from := opts.SourceFormat89 from := opts.SourceFormat
86 to := sdktranslator.FromString("gemini-web")90 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
43 if baseURL == "" || apiKey == "" {43 if baseURL == "" || apiKey == "" {
44 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}44 return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
45 }45 }
46 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
46 47
47 // Translate inbound request to OpenAI format48 // Translate inbound request to OpenAI format
48 from := opts.SourceFormat49 from := opts.SourceFormat
@@ -82,6 +83,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A · 33 unmodified lines
82 return cliproxyexecutor.Response{}, err83 return cliproxyexecutor.Response{}, err
83 }84 }
84 appendAPIResponseChunk(ctx, e.cfg, body)85 appendAPIResponseChunk(ctx, e.cfg, body)
86 reporter.publish(ctx, parseOpenAIUsage(body))
85 // Translate response back to source format when needed87 // Translate response back to source format when needed
86 var param any88 var param any
87 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), translated, body, &param)89 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
93 if baseURL == "" || apiKey == "" {95 if baseURL == "" || apiKey == "" {
94 return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}96 return nil, statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL or apiKey"}
95 }97 }
98 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
96 from := opts.SourceFormat99 from := opts.SourceFormat
97 to := sdktranslator.FromString("openai")100 to := sdktranslator.FromString("openai")
98 translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)101 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
138 for scanner.Scan() {141 for scanner.Scan() {
139 line := scanner.Bytes()142 line := scanner.Bytes()
140 appendAPIResponseChunk(ctx, e.cfg, line)143 appendAPIResponseChunk(ctx, e.cfg, line)
144 if detail, ok := parseOpenAIStreamUsage(line); ok {
145 reporter.publish(ctx, detail)
146 }
141 if len(line) == 0 {147 if len(line) == 0 {
142 continue148 continue
143 }149 }
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
46 if baseURL == "" {46 if baseURL == "" {
47 baseURL = "https://portal.qwen.ai/v1"47 baseURL = "https://portal.qwen.ai/v1"
48 }48 }
49 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
49 50
50 from := opts.SourceFormat51 from := opts.SourceFormat
51 to := sdktranslator.FromString("openai")52 to := sdktranslator.FromString("openai")
@@ -79,6 +80,7 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req · 27 unmodified lines
79 return cliproxyexecutor.Response{}, err80 return cliproxyexecutor.Response{}, err
80 }81 }
81 appendAPIResponseChunk(ctx, e.cfg, data)82 appendAPIResponseChunk(ctx, e.cfg, data)
83 reporter.publish(ctx, parseOpenAIUsage(data))
82 var param any84 var param any
83 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)85 out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, &param)
84 return cliproxyexecutor.Response{Payload: []byte(out)}, nil86 return cliproxyexecutor.Response{Payload: []byte(out)}, nil
@@ -92,6 +94,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 7 unmodified lines
92 if baseURL == "" {94 if baseURL == "" {
93 baseURL = "https://portal.qwen.ai/v1"95 baseURL = "https://portal.qwen.ai/v1"
94 }96 }
97 reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
95 98
96 from := opts.SourceFormat99 from := opts.SourceFormat
97 to := sdktranslator.FromString("openai")100 to := sdktranslator.FromString("openai")
@@ -103,6 +106,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 5 unmodified lines
103 if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {106 if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {
104 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"]}}}]`))107 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"]}}}]`))
105 }108 }
109 body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
106 110
107 url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"111 url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
108 recordAPIRequest(ctx, e.cfg, body)112 recordAPIRequest(ctx, e.cfg, body)
@@ -138,6 +142,9 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut · 29 unmodified lines
138 for scanner.Scan() {142 for scanner.Scan() {
139 line := scanner.Bytes()143 line := scanner.Bytes()
140 appendAPIResponseChunk(ctx, e.cfg, line)144 appendAPIResponseChunk(ctx, e.cfg, line)
145 if detail, ok := parseOpenAIStreamUsage(line); ok {
146 reporter.publish(ctx, detail)
147 }
141 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)148 chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), &param)
142 for i := range chunks {149 for i := range chunks {
143 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}150 out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
internal/runtime/executor/usage_helpers.go+292−0
@@ -0,0 +1,292 @@
1package executor
2
3import (
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
16type usageReporter struct {
17 provider string
18 model string
19 authID string
20 apiKey string
21 requestedAt time.Time
22 once sync.Once
23}
24
25func 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
38func (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
63func 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
84func 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
103func 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
122func 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
145func 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
163func 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
184func 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
205func 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
226func 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
250func 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
274func 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
6package chat_completions6package chat_completions
7 7
8import (8import (
9 "bufio"
10 "bytes"9 "bytes"
11 "context"10 "context"
12 "encoding/json"11 "encoding/json"
@@ -278,19 +277,14 @@ func mapAnthropicStopReasonToOpenAI(anthropicReason string) string { · 265 unmodified lines
278// Returns:277// Returns:
279// - string: An OpenAI-compatible JSON response containing all message content and metadata278// - string: An OpenAI-compatible JSON response containing all message content and metadata
280func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {279func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
281
282 chunks := make([][]byte, 0)280 chunks := make([][]byte, 0)
283 281
284 scanner := bufio.NewScanner(bytes.NewReader(rawJSON))282 lines := bytes.Split(rawJSON, []byte("\n"))
285 buffer := make([]byte, 10240*1024)283 for _, line := range lines {
286 scanner.Buffer(buffer, 10240*1024)
287 for scanner.Scan() {
288 line := scanner.Bytes()
289 // log.Debug(string(line))
290 if !bytes.HasPrefix(line, dataTag) {284 if !bytes.HasPrefix(line, dataTag) {
291 continue285 continue
292 }286 }
293 chunks = append(chunks, bytes.TrimSpace(rawJSON[5:]))287 chunks = append(chunks, bytes.TrimSpace(line[5:]))
294 }288 }
295 289
296 // Base OpenAI non-streaming response template290 // 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
6package chat_completions6package chat_completions
7 7
8import (8import (
9 "bufio"
10 "bytes"9 "bytes"
11 "context"10 "context"
12 "time"11 "time"
@@ -166,153 +165,141 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR · 153 unmodified lines
166// Returns:165// Returns:
167// - string: An OpenAI-compatible JSON response containing all message content and metadata166// - string: An OpenAI-compatible JSON response containing all message content and metadata
168func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {167func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
169 scanner := bufio.NewScanner(bytes.NewReader(rawJSON))168 rootResult := gjson.ParseBytes(rawJSON)
170 buffer := make([]byte, 10240*1024)169 // Verify this is a response.completed event
171 scanner.Buffer(buffer, 10240*1024)170 if rootResult.Get("type").String() != "response.completed" {
172 for scanner.Scan() {171 return ""
173 line := scanner.Bytes()172 }
174 // log.Debug(string(line))
175 if !bytes.HasPrefix(line, dataTag) {
176 continue
177 }
178 rawJSON = bytes.TrimSpace(rawJSON[5:])
179 173
180 rootResult := gjson.ParseBytes(rawJSON)174 unixTimestamp := time.Now().Unix()
181 // Verify this is a response.completed event
182 if rootResult.Get("type").String() != "response.completed" {
183 continue
184 }
185 unixTimestamp := time.Now().Unix()
186 175
187 responseResult := rootResult.Get("response")176 responseResult := rootResult.Get("response")
188 177
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}]}`
190 179
191 // Extract and set the model version.180 // Extract and set the model version.
192 if modelResult := responseResult.Get("model"); modelResult.Exists() {181 if modelResult := responseResult.Get("model"); modelResult.Exists() {
193 template, _ = sjson.Set(template, "model", modelResult.String())182 template, _ = sjson.Set(template, "model", modelResult.String())
194 }183 }
195 184
196 // Extract and set the creation timestamp.185 // Extract and set the creation timestamp.
197 if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {186 if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {
198 template, _ = sjson.Set(template, "created", createdAtResult.Int())187 template, _ = sjson.Set(template, "created", createdAtResult.Int())
199 } else {188 } else {
200 template, _ = sjson.Set(template, "created", unixTimestamp)189 template, _ = sjson.Set(template, "created", unixTimestamp)
201 }190 }
202 191
203 // Extract and set the response ID.192 // Extract and set the response ID.
204 if idResult := responseResult.Get("id"); idResult.Exists() {193 if idResult := responseResult.Get("id"); idResult.Exists() {
205 template, _ = sjson.Set(template, "id", idResult.String())194 template, _ = sjson.Set(template, "id", idResult.String())
206 }195 }
207 196
208 // Extract and set usage metadata (token counts).197 // Extract and set usage metadata (token counts).
209 if usageResult := responseResult.Get("usage"); usageResult.Exists() {198 if usageResult := responseResult.Get("usage"); usageResult.Exists() {
210 if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {199 if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
211 template, _ = sjson.Set(template, "usage.completion_tokens", outputTokensResult.Int())200 template, _ = sjson.Set(template, "usage.completion_tokens", outputTokensResult.Int())
212 }201 }
213 if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {202 if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
214 template, _ = sjson.Set(template, "usage.total_tokens", totalTokensResult.Int())203 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 }
222 }204 }
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 }
223 212
224 // Process the output array for content and function calls213 // Process the output array for content and function calls
225 outputResult := responseResult.Get("output")214 outputResult := responseResult.Get("output")
226 if outputResult.IsArray() {215 if outputResult.IsArray() {
227 outputArray := outputResult.Array()216 outputArray := outputResult.Array()
228 var contentText string217 var contentText string
229 var reasoningText string218 var reasoningText string
230 var toolCalls []string219 var toolCalls []string
231 220
232 for _, outputItem := range outputArray {221 for _, outputItem := range outputArray {
233 outputType := outputItem.Get("type").String()222 outputType := outputItem.Get("type").String()
234 223
235 switch outputType {224 switch outputType {
236 case "reasoning":225 case "reasoning":
237 // Extract reasoning content from summary226 // Extract reasoning content from summary
238 if summaryResult := outputItem.Get("summary"); summaryResult.IsArray() {227 if summaryResult := outputItem.Get("summary"); summaryResult.IsArray() {
239 summaryArray := summaryResult.Array()228 summaryArray := summaryResult.Array()
240 for _, summaryItem := range summaryArray {229 for _, summaryItem := range summaryArray {
241 if summaryItem.Get("type").String() == "summary_text" {230 if summaryItem.Get("type").String() == "summary_text" {
242 reasoningText = summaryItem.Get("text").String()231 reasoningText = summaryItem.Get("text").String()
243 break232 break
244 }
245 }233 }
246 }234 }
247 case "message":235 }
248 // Extract message content236 case "message":
249 if contentResult := outputItem.Get("content"); contentResult.IsArray() {237 // Extract message content
250 contentArray := contentResult.Array()238 if contentResult := outputItem.Get("content"); contentResult.IsArray() {
251 for _, contentItem := range contentArray {239 contentArray := contentResult.Array()
252 if contentItem.Get("type").String() == "output_text" {240 for _, contentItem := range contentArray {
253 contentText = contentItem.Get("text").String()241 if contentItem.Get("type").String() == "output_text" {
254 break242 contentText = contentItem.Get("text").String()
255 }243 break
256 }244 }
257 }245 }
258 case "function_call":246 }
259 // Handle function call content247 case "function_call":
260 functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`248 // Handle function call content
261 249 functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
262 if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
263 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", callIdResult.String())
264 }
265 250
266 if nameResult := outputItem.Get("name"); nameResult.Exists() {251 if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
267 n := nameResult.String()252 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", callIdResult.String())
268 rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)253 }
269 if orig, ok := rev[n]; ok {
270 n = orig
271 }
272 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", n)
273 }
274 254
275 if argsResult := outputItem.Get("arguments"); argsResult.Exists() {255 if nameResult := outputItem.Get("name"); nameResult.Exists() {
276 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", argsResult.String())256 n := nameResult.String()
257 rev := buildReverseMapFromOriginalOpenAI(originalRequestRawJSON)
258 if orig, ok := rev[n]; ok {
259 n = orig
277 }260 }
261 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", n)
262 }
278 263
279 toolCalls = append(toolCalls, functionCallTemplate)264 if argsResult := outputItem.Get("arguments"); argsResult.Exists() {
265 functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", argsResult.String())
280 }266 }
281 }
282 267
283 // Set content and reasoning content if found268 toolCalls = append(toolCalls, functionCallTemplate)
284 if contentText != "" {
285 template, _ = sjson.Set(template, "choices.0.message.content", contentText)
286 template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
287 }269 }
270 }
288 271
289 if reasoningText != "" {272 // Set content and reasoning content if found
290 template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningText)273 if contentText != "" {
291 template, _ = sjson.Set(template, "choices.0.message.role", "assistant")274 template, _ = sjson.Set(template, "choices.0.message.content", contentText)
292 }275 template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
276 }
293 277
294 // Add tool calls if any278 if reasoningText != "" {
295 if len(toolCalls) > 0 {279 template, _ = sjson.Set(template, "choices.0.message.reasoning_content", reasoningText)
296 template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)280 template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
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 }
302 }281 }
303 282
304 // Extract and set the finish reason based on status283 // Add tool calls if any
305 if statusResult := responseResult.Get("status"); statusResult.Exists() {284 if len(toolCalls) > 0 {
306 status := statusResult.String()285 template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
307 if status == "completed" {286 for _, toolCall := range toolCalls {
308 template, _ = sjson.Set(template, "choices.0.finish_reason", "stop")287 template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", toolCall)
309 template, _ = sjson.Set(template, "choices.0.native_finish_reason", "stop")
310 }288 }
289 template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
311 }290 }
291 }
312 292
313 return template293 // 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 }
314 }300 }
315 return ""301
302 return template
316}303}
317 304
318// buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name305// buildReverseMapFromOriginalOpenAI builds a map of shortened tool name -> original tool name
internal/usage/logger_plugin.go+26−0
@@ -0,0 +1,26 @@
1package usage
2
3import (
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
11func init() {
12 coreusage.RegisterPlugin(NewLoggerPlugin())
13}
14
15// LoggerPlugin outputs every usage record to the application log.
16type LoggerPlugin struct{}
17
18// NewLoggerPlugin constructs a new logger plugin instance.
19func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{} }
20
21// HandleUsage implements coreusage.Plugin.
22func (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
14 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"14 "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
15 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"15 "github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
16 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"16 "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
17 _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
17 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"18 "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
18 "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"19 "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
19 sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"20 sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
20 _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey"21 _ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey"
21 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"22 sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
22 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"23 coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
24 "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
23 log "github.com/sirupsen/logrus"25 log "github.com/sirupsen/logrus"
24)26)
25 27
@@ -51,6 +53,11 @@ type Service struct { · 25 unmodified lines
51 shutdownOnce sync.Once53 shutdownOnce sync.Once
52}54}
53 55
56// RegisterUsagePlugin registers a usage plugin on the global usage manager.
57func (s *Service) RegisterUsagePlugin(plugin usage.Plugin) {
58 usage.RegisterPlugin(plugin)
59}
60
54func newDefaultAuthManager() *sdkAuth.Manager {61func newDefaultAuthManager() *sdkAuth.Manager {
55 return sdkAuth.NewManager(62 return sdkAuth.NewManager(
56 sdkAuth.NewFileTokenStore(),63 sdkAuth.NewFileTokenStore(),
@@ -217,6 +224,8 @@ func (s *Service) Run(ctx context.Context) error { · 160 unmodified lines
217 ctx = context.Background()224 ctx = context.Background()
218 }225 }
219 226
227 usage.StartDefault(ctx)
228
220 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)229 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
221 defer shutdownCancel()230 defer shutdownCancel()
222 defer func() {231 defer func() {
@@ -388,6 +397,8 @@ func (s *Service) Shutdown(ctx context.Context) error { · 165 unmodified lines
388 }397 }
389 }398 }
390 }399 }
400
401 usage.StopDefault()
391 })402 })
392 return shutdownErr403 return shutdownErr
393}404}
sdk/cliproxy/usage/manager.go+182−0
@@ -0,0 +1,182 @@
1package usage
2
3import (
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.
12type 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.
22type 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.
31type Plugin interface {
32 HandleUsage(ctx context.Context, record Record)
33}
34
35type 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.
41type 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.
52func 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.
60func (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.
75func (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.
88func (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.
99func (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
113func (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
128func (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
142func (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
158func 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
167var defaultManager = NewManager(512)
168
169// DefaultManager returns the global usage manager instance.
170func DefaultManager() *Manager { return defaultManager }
171
172// RegisterPlugin registers a plugin on the default manager.
173func RegisterPlugin(plugin Plugin) { DefaultManager().Register(plugin) }
174
175// PublishRecord publishes a record using the default manager.
176func PublishRecord(ctx context.Context, record Record) { DefaultManager().Publish(ctx, record) }
177
178// StartDefault starts the default manager's dispatcher.
179func StartDefault(ctx context.Context) { DefaultManager().Start(ctx) }
180
181// StopDefault stops the default manager's dispatcher.
182func StopDefault() { DefaultManager().Stop() }